RAG: giving the model your data
An LLM's knowledge froze at its training cutoff; your documents change weekly. Retrieval-augmented generation (RAG) thaws it: at query time, fetch the relevant slices of your data, paste them into the prompt, and let the model answer from evidence with citations. The model does reasoning; the index does memory.
The pipeline, one worked example
Take a 12,000-token maintenance manual and walk it through end to end:
- Chunk. Split on headers first (sections survive intact), then re-split any section over ~400 tokens, keeping ~60 tokens of overlap (10–15%). Sensible chunk sizes run 200–800 tokens. Result: roughly 35 chunks.
- Embed. Each chunk becomes one vector — bge-m3 emits 1,024 floats per chunk. On CPU this takes seconds for the whole manual.
- Index. Store vectors + text + metadata (source file, header path) in a vector store.
- Retrieve. Embed the question, fetch the top k = 5 chunks by cosine similarity (4–10 is the sane range).
- Generate. Assemble the prompt: instructions, the 5 chunks with ID tags, the question.
Chunking on semantic boundaries beats fixed windows. A table split from its header row, or a numbered procedure cut at step 4, is unretrievable garbage no matter how good the embedder is.
Two extras that pay for themselves immediately: store the header path (manual › §4 › Bolts) as metadata so you can filter and cite precisely, and embed the query exactly as the user typed it — preprocessing "cleanup" usually makes retrieval worse, not better.
Embeddings: the multilingual requirement
The embedder decides what "similar" means. For Korean and mixed Korean/English corpora the choice is narrow — English-only embedders fail silently on Korean queries, returning plausible-but-wrong chunks:
| Model | Dims | Notes |
|---|---|---|
| bge-m3 | 1024 | multilingual, strong Korean — the safe local default |
| nomic-embed-text-v1.5 | 768 | solid English, long context |
| all-MiniLM-L6-v2 | 384 | fast, English-centric, weak on Korean |
Embedding models are tiny (0.1–0.6B parameters); a CPU core chews through thousands of chunks — CPU inference. Embed the corpus once, embed queries at ask time. Keep the embedder frozen per index: swapping models mid-index makes vectors from different spaces, and similarity scores become noise.
Vector stores
| Store | When |
|---|---|
| sqlite-vec | single user, up to ~1M vectors, zero ops — it is a file |
| ChromaDB | local-first prototyping with metadata filters |
| Qdrant | production: filtering, hybrid search, scale |
All three run locally. Start with sqlite-vec; move when you actually hit its wall, not before.
Assembling the prompt
Answer only from the excerpts below. Cite every claim as [n].
If the excerpts do not contain the answer, reply "not in the docs".
[1] (manual §4.2) Torque the M8 bolt to 12 Nm after lubrication...
[2] (manual §7.1) Do not re-use stretch bolts...
Question: what torque spec applies to the M8 bolt?
Citations let users verify instead of trusting. The "not in the docs" exit matters: without it, the model invents an answer rather than fail the format.
Hybrid search and reranking
Pure vector search misses exact strings — error codes, function names, part numbers — that keyword search nails. Hybrid = BM25 + vectors, merged with reciprocal rank fusion; it is nearly free and reliably better than either alone.
For quality, add a reranker: retrieve 20–50 candidates, rerank down to the top 5 with a cross-encoder (the bge-reranker family — same multilingual story as bge-m3). The reranker reads chunk and query together, which a single-vector embedder cannot. Cheap recipe that covers most needs: hybrid retrieve 25, rerank to 5.
Diagnose: retrieval miss or generation miss?
Before tuning anything, answer one question: did the right chunk get retrieved?
- Print the top-k chunks for a failing query, with scores.
- If the answer-bearing chunk is not in the list, it is a retrieval problem — fix chunking, embeddings, or raise k.
- If it is in the list but the answer is wrong, it is a generation problem — bigger model, better prompt, rerank to push distractors out.
This one test saves hours of tuning the wrong half of the pipeline.
Failure modes
| Symptom | Cause | Fix |
|---|---|---|
| Right docs retrieved, wrong answer | distractors in top-k | rerank; shrink k |
| Answer exists in docs but never surfaces | top-k starvation or bad boundaries | raise k; chunk by headers |
| Confident but stale answers | index older than the docs | re-index on change |
| Korean queries miss English docs | monolingual embeddings | bge-m3 |
| Citations point at the wrong chunk | chunk lost its source metadata | store source + header per chunk |
Measure, do not vibe
Keep a golden set of 20–50 real questions with known answers. Score two numbers separately: retrieval hit rate (is the right chunk in the top-k?) and answer correctness. Track them after every change to chunk size, embedder, or k. RAG work without a golden set is astrology.
When to fine-tune instead
Fine-tuning changes behavior — format, style, terminology. It is a poor way to add facts: the model cannot cite what it was tuned on, and fresh documents mean a fresh training run. Rule of thumb: facts → RAG, behavior → LoRA, both only when you have exhausted prompting: fine-tuning.
Local stacks, smallest first
Open WebUI ships document upload and hybrid retrieval built in — start there and you may never write pipeline code. The honest core of RAG is ~50 lines; write it once so you know what the frameworks are doing:
chunks = chunk_by_header(read("manual.md"), max_tokens=400, overlap=60)
index = [(embed(c), c) for c in chunks] # done once
def answer(question):
hits = topk_by_cosine(embed(question), index, k=5)
ctx = "\n\n".join("[{}] {}".format(i, c) for i, (v, c) in enumerate(hits))
prompt = TEMPLATE.replace("{ctx}", ctx).replace("{q}", question)
return chat(prompt) # small local model is plenty
Reach for LlamaIndex or LangChain when you need permissions, scheduled refresh, or evaluation harnesses — not for the loop above. For Korean documents specifically, multilingual embeddings (bge-m3) are not optional, and header-based chunking should respect Korean document structure (장/절 headings) the same way it respects markdown ## headings.
Related: fine-tuning · prompting · structured output · cpu inference