RAG — Retrieval-Augmented Generation
Goal: let an agent answer questions from a private knowledge base, not just from what the model learned during training.
Background: the knowledge boundary of LLMs
An LLM’s knowledge comes from its training data, with two hard limits:
- Freshness: training data has a cutoff date; the model does not know what happened after that
- Privacy: it does not know your company’s internal docs, codebases, or product manuals
RAG (Retrieval-Augmented Generation) addresses both:
Classic path: user question → LLM (training knowledge only) → answer
RAG path: user question → retrieve relevant docs → stuff them into the prompt → LLM → answer
On the same agent capability chain: RAG covers “reading knowledge”, MCP covers “using tools” (dual-role write-up), and A2A covers “collaborating with other agents”. The three complement each other.
Core idea: vector similarity retrieval
Why vectors?
You cannot compare text similarity directly, but you can turn text into an embedding — a high-dimensional numeric array. Texts that are close in meaning are also close in vector space.
"how to reset a password" → [0.12, -0.34, 0.89, ...] ← 1536-dim vector
"password recovery steps" → [0.11, -0.35, 0.87, ...] ← close; similar meaning
"what's the weather today" → [0.78, 0.23, -0.45, ...] ← far; different meaning
Three stages of RAG
[Offline: build the knowledge base]
docs → chunking → embedding → store in a vector database
[Online: retrieve + generate]
user question → embed → nearest neighbors in the vector DB → take relevant chunks
→ stitch into the prompt → LLM generates an answer
Implementation: a simple RAG from scratch
Step 1: Chunking
// Split a long document into small pieces, ~500 characters each
// Keep some overlap so key information is not cut in half
function chunkText(text: string, chunkSize = 500, overlap = 50): string[] {
const chunks: string[] = [];
let start = 0;
while (start < text.length) {
const end = Math.min(start + chunkSize, text.length);
chunks.push(text.slice(start, end));
start += chunkSize - overlap; // advance by (chunkSize - overlap) to keep overlap
}
return chunks;
}
Step 2: Embedding
import { embed, embedMany } from 'ai';
import { openai } from '@ai-sdk/openai';
const embeddingModel = openai.embedding('text-embedding-3-small');
// Batch-embed document chunks
async function embedChunks(chunks: string[]): Promise<number[][]> {
const { embeddings } = await embedMany({
model: embeddingModel,
values: chunks,
});
return embeddings; // one vector per chunk
}
// Embed the user question
async function embedQuery(query: string): Promise<number[]> {
const { embedding } = await embed({
model: embeddingModel,
value: query,
});
return embedding;
}
Step 3: Vector store and retrieval
// A simple in-memory vector store (use Chroma, Pinecone, or pgvector in production)
interface VectorDocument {
id: string;
content: string;
embedding: number[];
metadata?: Record<string, string>;
}
class SimpleVectorStore {
private documents: VectorDocument[] = [];
add(doc: VectorDocument): void {
this.documents.push(doc);
}
// Cosine similarity: how aligned two vectors are; range [-1, 1], closer to 1 is more similar
private cosineSimilarity(vectorA: number[], vectorB: number[]): number {
const dotProduct = vectorA.reduce((sum, a, i) => sum + a * vectorB[i], 0);
const magnitudeA = Math.sqrt(vectorA.reduce((sum, a) => sum + a * a, 0));
const magnitudeB = Math.sqrt(vectorB.reduce((sum, b) => sum + b * b, 0));
return dotProduct / (magnitudeA * magnitudeB);
}
// Retrieve the topK most similar documents
search(queryEmbedding: number[], topK = 3): VectorDocument[] {
return this.documents
.map(doc => ({
doc,
score: this.cosineSimilarity(queryEmbedding, doc.embedding),
}))
.sort((a, b) => b.score - a.score)
.slice(0, topK)
.map(({ doc }) => doc);
}
}
Step 4: The full RAG flow
// Wrap RAG as a tool so the agent can call it
const ragSearchTool = tool({
description: 'Search the knowledge base for documents that answer product or docs questions',
parameters: z.object({
query: z.string().describe('Search keywords or a question'),
topK: z.number().default(3).describe('How many most-relevant documents to return'),
}),
execute: async ({ query, topK }) => {
// 1. Embed the user question
const queryEmbedding = await embedQuery(query);
// 2. Retrieve the most relevant chunks
const relevantDocs = vectorStore.search(queryEmbedding, topK);
// 3. Return the document text (the agent will inject this into the prompt)
return {
documents: relevantDocs.map(doc => ({
content: doc.content,
source: doc.metadata?.source ?? 'unknown source',
})),
message: `Found ${relevantDocs.length} relevant documents`,
};
},
});
Offline indexing
// One-shot script: embed a document library and store it in the vector DB
async function buildIndex(documents: { content: string; source: string }[]) {
for (const doc of documents) {
// 1. Chunk
const chunks = chunkText(doc.content);
// 2. Batch embed
const embeddings = await embedChunks(chunks);
// 3. Store
chunks.forEach((chunk, i) => {
vectorStore.add({
id: `${doc.source}-${i}`,
content: chunk,
embedding: embeddings[i],
metadata: { source: doc.source },
});
});
console.log(`✅ Indexed: ${doc.source} (${chunks.length} chunks)`);
}
}
// Example usage
await buildIndex([
{ content: fs.readFileSync('docs/product-manual.md', 'utf-8'), source: 'product-manual' },
{ content: fs.readFileSync('docs/faq.md', 'utf-8'), source: 'faq' },
{ content: fs.readFileSync('docs/api-reference.md', 'utf-8'), source: 'api-reference' },
]);
Common RAG optimizations
| Problem | Fix |
|---|---|
| Chunks too short, context incomplete | Increase chunkSize, or chunk by semantics (paragraph / heading) |
| Chunks too long, too much noise | Decrease chunkSize, raise topK, then rerank |
| Retrieval is off | HyDE: let the LLM write a hypothetical answer, then retrieve with that |
| Duplicate results | MMR (Maximal Marginal Relevance) to balance similarity and diversity |
| Production | Replace the in-memory store with Chroma, Pinecone, or pgvector for persistence and scale |
The full retrieval pipeline: multi-path recall → RRF fusion → rerank → evidence filter + generation
The rerank / BM25 pieces above are gathered here into one runnable pipeline. The claim: RAG answer quality depends on evidence quality, and evidence quality depends on a layered retrieval design.
Why one retriever is not enough
- Vector search is good at meaning (“how do I apply for annual leave” can hit “employee leave policy”), but it is insensitive to literal anchors — exact terms, proper nouns, version numbers, error codes, API parameters, clause numbers. You get “close in meaning, wrong in detail”.
- BM25 reads the surface (
temperature=0,ERR_5001,v2.3.1 upgrade notesare safest as lexical hits) but does not understand meaning; a paraphrase misses. - They are orthogonal, not substitutes: vectors ask “does it mean the same?”; BM25 asks “did the word appear?”.
Metadata filtering: the third path, about “is the scope right”
Vector / BM25 are scoring. Metadata filtering is a hard constraint (boolean match, no score) — tag every chunk at ingest
(source / version / updated_at / dept / authority / tenant_id / doc_type / status),
then draw the allowed range with boolean conditions at query time:
{
"query": { "match": { "content": "annual leave application process" } },
"filter": {
"bool": { "must": [
{ "term": { "version": "v2" } },
{ "term": { "status": "active" } },
{ "term": { "authority": "hr" } }
]}
}
}
Key: pre-filter (filter, then search). Do not post-filter (search, then delete).
- ✅ Pre-filter: shrink the candidate space by tags first, then score / recall inside the legal subset — correct and faster.
- ❌ Post-filter: recall Top-K first, then delete — Top-K fills up with old / unauthorized docs, and after deletion the useful context is gone (same three failure modes as the “ACL push-down” section).
- In Elasticsearch this is
filter context(unscored, cacheable). Do not usepost_filteras your main filter.
This is also the technical carrier for three things: the 10★ fix for “recalling an old version”, ACL push-down, and the “is the scope right” path in multi-path recall.
The four-step pipeline
| Stage | Job | Goal |
|---|---|---|
| ① Multi-path recall | Vector + BM25 + metadata filter | Do not miss key evidence (high recall) |
| ② Fusion | RRF or similar rank fusion | Unify outputs from different retrievers (comparable) |
| ③ Rerank | Cross-encoder rerank | Pick the right Top-k (high precision) |
| ④ Evidence filter + generate | Score threshold + LLM answer | Fewer hallucinations, more explainable |
query ─► ① multi-path recall (vector+BM25+metadata) ─► ② RRF fusion ─► ③ rerank (top-k) ─► ④ threshold + LLM
-
① High recall: pull in anything that might be relevant; better too many than a miss (vectors catch paraphrases + BM25 catches literals + metadata shrinks the range).
-
② Fusion: RRF (keep this straight — RRF ≠ rerank) RRF is “merging ranks”. Cross-encoder rerank is “rescoring”. Two jobs, two stages:
RRF (fusion) Cross-encoder rerank Stage After recall, before rerank Rerank stage Input Rank lists from each retriever The fused candidate list What it does Merge those ranks into one order Jointly score Query + Document Cost Very cheap, pure math Higher; you run a model RRF is based on “rank position”, not raw scores:
RRF(d) = Σ_i 1 / (k + rank_i(d)) # d=doc; rank_i(d)=its rank on path i; k is often 60; skip a path that missed itExample: doc A is rank 2 on the vector path, rank 1 on BM25, missing on a graph path →
1/(k+2) + 1/(k+1); B is only rank 3 on the vector path → one term, so after fusion it usually sits behind A. Why ranks, not scores: the scales differ — vector similarity 0.87/0.92, BM25 5.3/12.7, graph hit-path counts / relation weights. Adding them is meaningless, and normalizing is messy. RRF turns “what place?” into a comparable contribution. A picture: recall = two nets, each landing a catch; RRF = dump both nets and sort by “who ranked it higher”; rerank = inspect each fish and put the truly relevant ones first. RRF is cheap (math); rerank is expensive (a model). That is why you fuse with RRF first, then rerank. -
③ Rerank: rescore the fused candidates (e.g. Top-20~50) with a cross-encoder and keep only top-k for the prompt (details in the “Rerank” section).
-
④ Filter: if even the top score is too low, the store may have no reliable answer — answer carefully or refuse. Otherwise take Top-3~5 as evidence.
Runnable skeleton (vector + BM25 + RRF, TypeScript)
This extends the earlier
SimpleVectorStore(in-memory vectors): build a BM25 inverted index at ingest, recall on both paths, then fuse with RRF.
import { embed, embedMany } from 'ai';
import { openai } from '@ai-sdk/openai';
const embeddingModel = openai.embedding('text-embedding-3-small');
// Vector store (reuse SimpleVectorStore; here we only need its "ids ranked by similarity")
interface VecLike { search(emb: number[], topN: number): { id: string }[] }
// ---------- ① Indexing: dual-path ----------
type Doc = { id: string; content: string; metadata: Record<string, string> };
// Fill the vector store and BM25 together; keep an id -> Doc map
function buildIndexes(chunks: Doc[], vec: VecLike, bm25: SimpleBM25Index) {
const docsById = new Map(chunks.map((c) => [c.id, c]));
for (const c of chunks) bm25.add(c);
// ... vectors: embedMany(chunks[i].content) then vec.add({id, embedding}) (see embedChunks above)
return docsById;
}
// Minimal BM25: tokenize and record which docs contain each term, and how often
function tokenize(s: string): string[] {
return s.toLowerCase().match(/[a-z0-9_]+/g) ?? [];
}
class SimpleBM25Index {
private df = new Map<string, number>();
private tf = new Map<string, Map<string, number>>();
private docs: Doc[] = [];
add(doc: Doc): void {
this.docs.push(doc);
const counts = new Map<string, number>();
for (const w of tokenize(doc.content)) counts.set(w, (counts.get(w) ?? 0) + 1);
for (const [w, n] of counts) {
this.tf.set(doc.id, (this.tf.get(doc.id) ?? new Map()).set(w, n));
this.df.set(w, (this.df.get(w) ?? 0) + 1);
}
}
search(query: string, topK: number): { id: string; score: number }[] {
const k1 = 1.2, N = this.docs.length;
const qws = tokenize(query);
const scoreOf = (docId: string) => {
let s = 0;
const tf = this.tf.get(docId)!;
for (const q of qws) {
const f = tf.get(q) ?? 0;
if (!f) continue;
const idf = Math.log((N - (this.df.get(q) ?? 0) + 0.5) / ((this.df.get(q) ?? 0) + 0.5) + 1);
s += idf * ((f * (k1 + 1)) / (f + k1));
}
return s;
};
return this.docs
.map((d) => ({ id: d.id, score: scoreOf(d.id) }))
.filter((r) => r.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, topK);
}
}
function rrf(
rankedLists: { id: string }[][],
k = 60,
): { id: string; score: number }[] {
const acc = new Map<string, number>();
for (const list of rankedLists) {
for (let i = 0; i < list.length; i++) {
acc.set(list[i].id, (acc.get(list[i].id) ?? 0) + 1 / (k + i + 1));
}
}
return [...acc.entries()]
.map(([id, score]) => ({ id, score }))
.sort((a, b) => b.score - a.score);
}
class RetrievalChain {
constructor(
private vector: VecLike,
private bm25: SimpleBM25Index,
private byId: (id: string) => Doc,
) {}
async recall(query: string, topN: number) {
const qEmb = await embed({ model: embeddingModel, value: query });
const vecHits = this.vector.search(qEmb.embedding, topN).map((d) => ({ id: d.id }));
const bmHits = this.bm25.search(query, topN);
return rrf([vecHits, bmHits]);
}
filter(ids: { id: string; score: number }[], minScore: number, topK: number): string[] {
if (ids.length === 0 || ids[0].score < minScore) return [];
return ids.slice(0, topK).map((r) => this.byId(r.id).id);
}
}
const ragAskTool = tool({
description: 'Retrieve and answer from the knowledge base: multi-path recall (BM25+vector) + RRF + evidence filter',
parameters: z.object({ query: z.string(), topN: z.number().default(20), topK: z.number().default(3) }),
execute: async ({ query, topN, topK }) => {
const fused = await chain.recall(query, topN);
const best = chain.filter(fused, /*minScore*/ 0.4, topK);
const docs = best.map((id) => chain.byId(id));
return {
hit: docs.length > 0,
message: docs.length ? `Found ${docs.length} pieces of evidence` : 'Not enough relevant evidence; prefer to refuse',
documents: docs.map((d) => ({ content: d.content, source: d.metadata.source })),
};
},
});
Practical rollout (incremental)
- Start with: vector search + rerank — cheap, immediate lift.
- Then add: when the store fills with jargon / product IDs / API docs / policy clauses, add BM25 + RRF fusion.
- A mature system should also make the model cite sources / mark spans; if evidence is thin, say “no relevant information found”. Caution beats invention.
One line: RAG answer quality depends on evidence quality, and evidence quality depends on a layered retrieval design.
Retrieval optimization: Rerank
Rerank is the precision layer in RAG: recall’s job is “don’t miss”; rerank’s job is “don’t feed the wrong thing to the LLM”.
query ─► coarse recall (top-50) ─► rerank (top-50→top-5) ─► inject into prompt
vector / BM25 cross-encoder / LLM
fast, high recall precise, high precision
- First stage: fast + don’t miss: vector / BM25 pull 50~100 candidates (optimize for recall).
- Second stage: be precise: rescore candidates and keep only the most relevant top-k for the prompt (raise precision). You only run the expensive model on a short list, so cost stays bounded.
What it solves (four kinds of recall noise)
Candidates from vector search and BM25 usually mix in:
- Close in meaning, but not the answer;
- Keyword hit, wrong context;
- Wrong year, product name, ID, permission, or version;
- Several chunks talk about the same thing, but only one is actually usable.
Rerank rescores those tens-to-hundreds of candidates, puts the truly relevant ones first, and truncates to Top-3 / Top-5 for the LLM.
Mechanism: Bi-encoder vs Cross-encoder (why rerank is more accurate)
| Bi-encoder (coarse recall) | Cross-encoder (rerank) | |
|---|---|---|
| Encoding | Query and passage encoded separately | (query, passage) concatenated, one forward pass |
| Token interaction | Q and P never see each other at encode time | Q and P tokens attend to each other, catching fine-grained relations |
| Scoring | Cosine similarity | The model emits a relevance score directly |
| Precompute | ✅ Passage vectors computed offline → fast | ❌ Every pair is live → slow |
| Precision | Medium (“overall meaning” is close) | High (“this passage actually answers it”) |
In plain language: coarse recall is “do these two resumes look alike when read separately”; rerank is “put them in the same interview and see if the conversation lands”.
So:
- Vector search asks “do these two vectors look alike?”;
- Rerank asks “does this document actually answer this question?”.
Example: the user asks for “2026 holiday schedule”. Vector search may also pull 2024 and 2025 holiday docs (close in meaning); rerank is much more likely to push year-mismatched docs down.
Typical pipeline (where it sits)
User query
↓
Vector + BM25 recall Top-50 ~ Top-100
↓
RRF fusion into one ranking ← RRF = "merge multi-path recall"
↓
Rerank the candidates ← Rerank = "finely score the fused list"
↓
Drop low-score docs, take Top-3 ~ Top-5
↓
Hand to the LLM
Mainstream rerank models
| Type | Representative models | Notes |
|---|---|---|
| Cross-encoder | BGE-Reranker-v2-m3, Qwen3-Reranker | High precision; strong on Chinese / multilingual |
| Listwise | Jina Reranker v3.5, mxbai-rerank-v3 | Sees several candidates at once; more consistent order |
| Late interaction | ColBERT | Faster than a cross-encoder; good for large stores |
| LLM rerank | RankLLM, Qwen3 | Strong understanding, expensive; best on small candidate sets |
How to choose in production
- Chinese RAG, private deploy: prefer BGE-Reranker-v2-m3.
- Long docs, heavier reasoning: Qwen3-Reranker-4B/8B.
- Low latency, large-scale recall: ColBERT or a light listwise model.
- Quick validation: call a Rerank API (Cohere, Jina, SiliconFlow, …).
Three concrete approaches
- Cross-encoder models: closed-source Cohere Rerank (common in production); open-source BGE-reranker,
cross-encoder/ms-marco-MiniLM, etc. Input(query, candidate[]), output one score per candidate. - LLM as reranker: stuff candidates into a “rank by relevance” prompt and let the LLM score / sort. Useful when the domain jargon is unknown to a generic reranker; the cost is another LLM call.
- A clarification: BM25 + vector RRF is often lumped under “rerank”, but strictly it is the previous stage (fusion) — it merges two paths. Rerank does the fine filter. Use them together (see “The full retrieval pipeline”).
Division of labor with query rewrite
On this chain of responsibility —
- Rewrite (query rewrite / HyDE) sits [before] recall: reshape the question into a form that retrieves better, raising recall (get it all).
- Rerank sits [after] recall: filter the candidates, raising precision (keep the right ones).
- One covers “get it all”, the other “keep it right”. Do not collapse them into one step.
A key principle: lock recall first, then add rerank
Rerank can only “pick the best from good candidates”. It cannot invent something that was never recalled. If Recall@50 is already low, coarse recall has missed the key evidence, and rerank will not help much. A more stable upgrade order:
chunking strategy → hybrid retrieval → RRF fusion → rerank → threshold filter → LLM generate
Rerank wiring skeleton (BGE-Reranker + truncate to Top-5)
This sits after RetrievalChain’s RRF fusion: fused Top-N → rerank → threshold → Top-5 into the LLM.
async function rerank(query: string, docs: Doc[], topK: number): Promise<RerankResult[]> {
const res = await fetch('https://api.siliconflow.cn/v1/rerank', {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${process.env.RERANK_API_KEY}`,
},
body: JSON.stringify({
model: 'BAAI/bge-reranker-v2-m3',
query,
documents: docs.map((d) => d.content),
top_n: topK,
return_documents: false,
}),
});
const data: { results: { index: number; relevance_score: number }[] } = await res.json();
return data.results.map((r) => ({ id: docs[r.index].id, relevance: r.relevance_score }));
}
class RetrievalChain {
async ask(
query: string,
opts: { topN: number; rerankK: number; finalK: number; minRelevance: number },
): Promise<{ hit: boolean; documents: Doc[] }> {
const fused = await this.recall(query, opts.topN);
if (fused.length === 0) return { hit: false, documents: [] };
const candidates = fused.map((r) => this.byId(r.id));
const ranked = await rerank(query, candidates, opts.rerankK);
const best = ranked.filter((r) => r.relevance >= opts.minRelevance);
if (best.length === 0) return { hit: false, documents: [] };
return {
hit: true,
documents: best.slice(0, opts.finalK).map((r) => this.byId(r.id)),
};
}
}
const { hit, documents } = await chain.ask(query, {
topN: 20,
rerankK: 5,
finalK: 5,
minRelevance: 0.3,
});
if (!hit) {
console.log('No sufficiently relevant material in the knowledge base');
} else {
const context = documents.map((d, i) => `[${i + 1}] ${d.content}`).join('\n\n');
// generateText({ prompt: `Answer only from the material below and cite the numbers:\n${context}\n\nQuestion: ${query}` })
}
Three points:
- Two-stage truncation:
topN=20(coarse keep after fusion) →rerankK=5(fine keep after rerank). Wide, then narrow. - The score’s meaning changed: RRF scores are only for ordering. Thresholding must use the rerank
relevance_score— that is the measure of “does this document actually answer the question”. - Refuse rather than invent:
minRelevanceplus an empty-result “no relevant information” branch makes thin evidence visible.
Lexical retrieval: BM25 (complements vectors)
BM25 (Best Matching 25), which shows up throughout recall / rerank, is the classic lexical (keyword) relevance scorer, and the default ranking in Lucene / Elasticsearch / OpenSearch. It is TF-IDF’s successor. Simplified:
score(D,Q) = Σ for each query term q:
IDF(rarity) × term-frequency term (with saturation) × length norm
- IDF : rarer across the collection → larger contribution
- TF term : more occurrences score higher, but 【saturate】 (k1≈1.2 caps stuffing)
- Length norm : long docs are discounted so they don't win just by being long (b≈0.75)
- TF saturation: 3 vs 100 occurrences of the same term is not that different;
k1punishes keyword stuffing. - Length normalization: a long doc that happens to contain a term ≠ highly relevant;
bremoves that bias. - Rare-term boost: a precise term (
password_reset_timeout) contributes more than a stopword (the).
Complementary to embeddings / vectors:
| BM25 (lexical) | Embedding (semantic) | |
|---|---|---|
| Match | Exact surface form | Close in meaning (paraphrases hit) |
| Strength | Exact terms, IDs, proper nouns | Natural language, synonyms |
| Weakness | A paraphrase misses | Exact keywords / unknown proper nouns slip |
| Cost | Fast, no model | Needs embedding + a vector store |
BM25 reads the “letters”; vectors read the “meaning”. Production RAG usually does hybrid: BM25 + vector dual-path recall → RRF fusion, so vectors recover paraphrases and BM25 covers exact terms / IDs, then both feed rerank.
BM25 storage requirements (indexing checklist)
Core premise: BM25 scores are computed at query time (they depend on the query terms). Storage has to keep the “raw materials” — the opposite of embeddings (those are precomputed offline; query time is just cosine). BM25 scores differ on every query and cannot be pre-stored.
The four things you must store:
| What to store | Why |
|---|---|
| Inverted list: term → (chunk_id, tf) | Who the query term hit, and how often |
| df: how many chunks contain each term | IDF (rare-term boost) |
| Field length: tokens per chunk + collection avgdl | Length norm (b) |
| Tokenization / analyzer config | How words are split, lowercased, stemmed |
(SimpleBM25Index’s df / tf maps are the minimal version; ES / Lucene maintain this for you.)
Six hard requirements:
- Same analyzer: index time and query time must use the same analyzer (mismatch = terms don’t line up = you find nothing). For Chinese, use IK / HanLP / smartcn; upgrading the tokenizer = a full rebuild of the BM25 index.
- Right field types: body as
text(analyzed, inverted); IDs / codes as a separatekeywordsubfield. Don’t mix them. - Don’t turn off norms: that is where field length lives; without it, length norm is dead.
- Granularity = one BM25 doc per chunk: too-long chunks distort (length-normed down) and add noise; too-short chunks fragment hits (see the 8★ A/B). Strong signals like titles can be separate weighted fields.
- Keep stats in sync on add/delete:
dfandavgdlchange as docs come and go. In ES, a deleted doc still counts until segment merge. A source-doc update must rebuild that chunk’s lexical index (see 10★ index sync). - Share
chunk_idacross both paths:id → embedding(vector store) andid → (term, tf, length)(BM25) must line up, or RRF cannot merge by id.
One line: storing BM25 = storing “inverted lists + df + length stats + analyzer config”. The score itself is not stored (and cannot be); each query computes it from those materials.
Production: hybrid search on OpenSearch
The handwritten skeleton (BM25 inverted index +
rrf()+ metadata filter) maps in production to OpenSearch’s “one doc, three fields + one hybrid query + one RRF pipeline” — most of the handwritten logic moves into the server.
1. Mapping: one doc, three fields (text / vector / metadata)
{
"mappings": {
"properties": {
"content": { "type": "text", "analyzer": "standard" },
"content_embed": { "type": "dense_vector", "dims": 1024,
"index": true, "similarity": "cosine" },
"chunk_id": { "type": "keyword" },
"version": { "type": "keyword" },
"authority": { "type": "keyword" },
"source": { "type": "keyword" }
}
}
}
At index time, the same chunk’s content (text) + content_embed (embedding) + metadata go into one doc.
ES automatically builds a BM25 inverted index on text (you don’t add a column by hand) and a vector index on dense_vector.
2. One hybrid query: BM25 + kNN together
POST /kb/_search?search_pipeline=kb_rrf&size=50
{
"_source": ["chunk_id", "content", "source", "version"],
"query": {
"bool": {
"filter": [
{ "term": { "version": "v2" } },
{ "term": { "authority": "hr-2026" } }
],
"must": [{
"hybrid": {
"queries": [
{ "match": { "content": "employee leave policy" } },
{ "knn": { "content_embed": { "vector": [/*...*/], "k": 50 } } }
]
}
}]
}
}
}
Don’t use
post_filteras the main filter — it runs after recall / aggregations, i.e. “Top-K fills up, then you delete” (see “Metadata filtering: pre-filter vs post-filter”).
3. RRF fusion: a search-pipeline normalization processor
PUT /_search/pipeline/kb_rrf
{
"response_processors": [{
"normalization-processor": {
"normalization_technique": "rrf",
"combination": { "parameters": { "k": 60 } }
}
}]
}
4. Chinese tokenization: the analyzer is the real production pitfall
The standard analyzer splits Chinese as “whole span / character by character” — recall is coarse. Production usually installs IK / HanLP / smartcn,
sets the analyzer to ik_smart / ik_max_word, and turns 员工休假管理制度 into 员工|休假|管理|制度.
That is the production fix for the earlier [a-z0-9_] regex failing on Chinese.
5. Where rerank plugs in
Recall (50~100) + fusion come back from OpenSearch; you still attach rerank yourself: feed top-N to Cohere Rerank / your own cross-encoder, or hang an OpenSearch neural-search text-rerank pipeline — the skeleton’s “fused topN → topK” step.
Comparison: handwritten skeleton vs OpenSearch
| Handwritten skeleton | OpenSearch |
|---|---|
You write the inverted index + rrf() |
text auto-BM25 + hybrid query + normalization (RRF) processor |
| You maintain df/tf maps | Server-side inverted index + stats |
| Metadata filter by hand | One filter / post_filter clause |
| Bare regex fails on Chinese | ik_smart / smartcn plugins |
| Cross-encoder wired outside | External rerank, or a neural-search pipeline |
One line: in production, OpenSearch is “one doc, three fields + one hybrid query + one RRF pipeline” replacing every handwritten piece; what you still own is chunking, embedding, and a cross-encoder after fusion.
Advanced: long-doc indexing / diagnosis / provenance (interview-oriented)
8★ How to index long documents, and how to pick a chunk size
- Cut on structure: prefer semantic boundaries — headings, paragraphs, tables, clauses — not a rigid fixed length. A semantically complete chunk embeds much better.
- Metadata carries version / ACL: every chunk needs
version,source,authority, or you cannot expire, cite, or permission-trim. - Overlap to avoid a hard cut: key information sitting on a boundary can be sliced in half; keep a small overlap so it is not lost.
- Pick chunk size with an A/B: don’t invent “500 tokens” by feel. Hold other variables fixed, sweep
chunk_size(with rerank as a backstop), and read the measured recall / precision curves.
9★ Where to optimize when quality is poor
Split the work into a chain of responsibility. Each layer owns one step and has independently verifiable cost:
parse → chunk → rewrite (query rewrite) → retrieve → rerank → generate
| Layer | Owns | How to verify |
|---|---|---|
| Parse | PDF / Word → clean text (OCR, layout restore) | Is the source extracted correctly? |
| Chunk | Is each chunk semantically complete? | Sample random chunks and read them |
| Rewrite | Does the query retrieve the right answer? | Measure recall before vs after rewrite |
| Retrieve | Did you get candidates? | Is the gold answer in top-50? |
| Rerank | Did the gold answer rise to top-k? | Is it in top-5? |
| Generate | Given the right material, is the answer right? | Unit-test generation quality |
Take the earliest broken step as the root cause: if parse / chunk is wrong at the source, no amount of retrieve / rerank tuning helps. Debug top-down; look at the first layer that fails.
10★ The right answer is in the store, but you recall an old version
Don’t blame “similarity” first. “Searching right” presupposes “the store itself is right” —
- Check version metadata: several file versions coexist, chunks still carry the old
version, and the query did not filter by version → prefer the latest. - Expiry filter: are retired / expired docs still in the index? Is there an active-status flag and a periodic sync?
- Index sync: after a source update, did the vector store incrementally rebuild? Did old chunks linger?
- Only after those are ruled out, return to similarity itself: chunks too large / scattered, surface-form mismatch, embedding domain mismatch, etc.
One line: “recalling an old version” is almost always a data / index problem, not a retrieval-algorithm problem.
Security: private RAG ACL must be pushed down to the retrieval layer
Lead with the conclusion: private RAG must push ACL down to the retrieval layer. Filtering after recall is not enough, and redaction cannot replace ACL.
Permissions Collapse: enterprise RAG is not a public search engine
The same knowledge base holds all-hands policies, department-internal docs, management operating data, finance / HR / contracts / customer files, plus project- / tenant- / classification-scoped content.
If retrieval has no permission isolation, a natural-language question can pull unauthorized chunks into the context. Once the model sees them, it can leak them indirectly via summary, rewrite, or inference.
That is Permissions Collapse: the source system had fine-grained permissions, but after docs enter the vector index the ACL metadata is gone, and anyone who can query the store can semantically hit every document.
Why “filter after recall” fails (three fatal problems)
1. Unauthorized data is already in the candidate set Recall has already loaded unauthorized docs into memory, into the candidate pool, and possibly into rerank, prompt assembly, and traces. Filtering only “hides them at the end”. The leak window is already open.
2. Top-K fills up with unauthorized docs Suppose Top-K=5 and recall has 4 unauthorized + 1 authorized. Post-filter drops 4 and leaves 1 — on the surface you “filtered”, in reality useful context is emptied and recall quality collapses.
3. Traces, caches, and logs leak Raw recalled text, scores, prompts, and rerank I/O often land in LangSmith / Langfuse / SIEM / homegrown logs. If ACL is not enforced at retrieval, those pipes can retain unauthorized fragments.
Where should ACL be pushed?
The retrieval layer — before / during vector recall, not after recall or after generation:
User query + trusted identity
↓
Identity resolution: user_id / role / dept / tenant / security_level
↓
Retrieval-layer ACL pre-filter: keep only docs / chunks the user may see
↓
Vector / BM25 / hybrid search inside that legal subset
↓
RRF fusion
↓
Rerank
↓
Threshold filter
↓
LLM generate
↓
Output-layer second sensitivity check + audit
In other words, permission filtering happens before similarity, or inside the search engine, so the model never sees unauthorized content in the first place.
Six engineering rules
- Bind ACL metadata at ingest: every chunk carries
tenant_id,department,role,user_id,security_level,doc_version, and similar tags. - Resolve a trusted identity before search: identity comes from the directory, SSO, JWT, or a server-side token. The client must not self-report privileges.
- The search engine pre-filters: the vector store trims the candidate space with ACL metadata before recall, and only scores inside the legal subset.
- RRF / rerank run only on legal candidates: unauthorized docs must not enter fusion or rerank.
- Second check at the output layer: field-level secrets, classification upgrades, leftovers during a permission change.
- Redact traces too: logs, caches, and eval sets must not keep unauthorized source text.
Redaction ≠ ACL (they cooperate; they do not replace each other)
| Capability | Solves | Does not solve |
|---|---|---|
| ACL | Who may see which row | Does not change the data itself |
| Redaction | How sensitive fields are shown | Does not decide who may see the original |
| Retrieval-layer filter | Shrinks the visible candidate space | Is not the same as output-layer masking |
- Redaction masks or generalizes “ID number, phone, salary”. ACL decides “this person must not see this document at all”.
- Redaction without ACL → users can still see contracts, budgets, HR files they should never touch;
- ACL without redaction → authorized users may still see overly fine-grained secrets.
One line: private RAG ACL is a hard retrieval-layer constraint that trims the visible space before recall. Post-filter is a backstop, not the main defense. Redaction is output protection; it does not replace access control.
Special cases (PDF / very long contracts)
11★ Scanned PDF / OCR: do not dump a whole page into the vector store. Run layout analysis first to restore paragraphs / fields, then field-level OCR block by block, and ingest structured text plus position metadata. Handle image-only pages separately so layout noise / headers / footers are not treated as body text. 12★ Very long contracts: cut on structure / clauses (one chunk per clause, embedded independently), let sub-agents extract their own domain fields against a schema (amount / term / breach…), then merge. Keep the original clause-number relationships so you can cite and reconcile.
Concept summary
| Concept | What it is |
|---|---|
| RAG | Retrieval-Augmented Generation: retrieve external knowledge, inject it into the prompt, and extend the LLM’s knowledge boundary |
| Embedding | Turn text into a high-dimensional vector; close meaning → close vectors |
| Chunking | Split long docs into small pieces, each embedded independently, for precise retrieval |
| Cosine similarity | How aligned two vectors are; the core scoring for RAG retrieval |
| Vector database | A store built for vectors, e.g. Chroma, Pinecone, pgvector |
| embedMany | Batch embedding in the ai SDK; cheaper than one-by-one calls |
| Two-stage retrieval | Coarse recall (top-N) then rerank down to top-k: “get it all” and “keep it right” |
| Rerank | After recall, a cross-encoder finely scores candidates to raise precision |
| Cross-encoder | (query, passage) in one forward pass; Q and P tokens attend; high precision |
| Query rewrite / HyDE | Rewrite the question or a hypothetical answer before generation to raise recall; a different stage from rerank |
| BM25 | Lexical relevance (TF saturation + length norm + rare-term boost); default ranking in ES / Lucene |
| Hybrid retrieval + RRF | BM25 (surface) + vector (meaning) dual-path recall, fused with Reciprocal Rank Fusion |
| Metadata filter (pre-filter) | Shrink range by chunk tags (version / ACL / tenant / status) before search; orthogonal to scoring |
| Permissions Collapse | Source-system fine-grained ACL is lost after ingest, so anyone who can query can semantically hit every doc |
| ACL push-down | Permissions as a hard retrieval-layer constraint, trimming visibility before similarity; post-recall filter is only a backstop |
| Redaction vs ACL | Redaction is “how a field is shown”; ACL is “whether you may look”; they cooperate and do not replace each other |
Further reading
RAG covers “reading private / fresh knowledge”. If you also want an agent to evaluate and correct its own output, look at Reflection-style designs next. On this site, tool access is in MCP intro and one platform, two MCP roles; cross-agent collaboration is in A2A.
FAQ
What problem does RAG solve?
An LLM's knowledge comes from training data, with two hard limits — freshness and privacy. RAG (Retrieval-Augmented Generation) retrieves relevant documents and stuffs them into the prompt, so the model can answer from a private knowledge base, not only from what it learned in training.
What are the three stages of RAG?
Offline, documents are chunked, embedded, and stored in a vector database. Online, the question is embedded, nearest neighbors are retrieved, relevant chunks are stitched into the prompt, and the LLM generates an answer.
Why is vector search alone not enough in production?
Vectors cover meaning; BM25 covers lexical match. Together with metadata filtering they form multi-path recall, then Reciprocal Rank Fusion merges ranks, and a cross-encoder rerank improves precision. Lock recall first, then add rerank.
What is Permissions Collapse, and how should private RAG handle ACL?
Permissions Collapse is when fine-grained source-system permissions are lost after documents enter the vector index, so anyone who can query the store may semantically hit every document. Private RAG must push ACL down to the retrieval layer and clip the visible set before similarity search. Filtering after recall is only a backstop; redaction does not replace access control.
How do RAG, MCP, and A2A divide the work?
On the same agent capability chain, RAG covers reading knowledge, MCP covers using tools, and A2A covers collaborating with other agents. The three complement each other.
Comments