The two-stage funnel
Retrieve wide, rerank sharp
One search can’t be both fast over millions of chunks and precise about which one answers the question — so we do two passes.
- Retrieve casts a wide net. Vector search compares the question’s embedding to every chunk’s and returns the top
k“roughly similar” ones — cheap, and tuned for recall (don’t miss the answer), not for getting the order right. Defaultk = 12. - Rerank decides the answer. A reranker reads the question and each candidate together and scores true relevance, then keeps the top
rerank_k(default 5). That smaller, sharper set is the context budget the grounding step gets. - The order flips. The chunk vector search ranked #9 can be the one the reranker puts #1 — because “similar embeddings” and “actually answers this” are not the same thing.
- It fails soft. If the reranker errors, the pipeline degrades to retrieval order (
candidates[:k]) instead of breaking — rerank is a quality boost, not a correctness gate.
A reranker is accurate but expensive — you can’t run it over a million chunks per query. Vector search is the opposite: cheap enough to score everything, but only “roughly” right about order. So the net catches a wide dozen fast, and the reranker spends its precision on just those twelve. Recall first, precision second.
↳ The reranker reorders all 12; the budget decides how many survive to the model.
Budget 5: the underwriting note (vector rank #9) sits comfortably inside the budget — the reranker put it at #1.
Vector search is a librarian who fetches every book with the right words on the spine. The reranker is the one who opens each book and checks which page actually answers you. You need the first pass to be fast; you need the second to be right.
That funnel isn’t a diagram of the code — it is the code. The retrieve node embeds each sub-query, searches with a permission filter, and merges the hits, keeping each chunk’s best score:
The rerank node is deliberately tiny — reorder by true relevance, trim to the budget, hand the sharper set on to grounding:
Where vectors fumble
When meaning isn’t enough
Embeddings are great at “what is this about” and surprisingly bad at “does it contain exactly POL-55012”.
- Dense search matches meaning. It embeds the query and finds chunks with similar meaning — perfect for “why did the premium rise”, weak when the answer hinges on an exact string the embedding blurs.
- Identifiers are exact tokens.
POL-55012,claim 88431, a form code, a state abbreviation — a near-miss (POL-55021) is a wrong answer, not a close one. Vectors don’t guarantee the exact token is even present. - Keyword search nails exact tokens. Classic term matching (BM25) finds the chunk that literally contains
POL-55012, even when its surrounding prose isn’t semantically “similar” to the question. - Hybrid = both, then merge. Run dense and keyword, combine the scores, hand the union to the reranker. You get semantic recall and exact-match precision.
Ask a librarian who only understands topics to find the file numbered 55012 and they’ll hand you five files about the same policy. Add a clerk who reads numbers and the exact file comes first. Production RAG usually wants both.
Be honest about the code: the default query today is dense k-NN + the permission filter + a reranker. True hybrid — a keyword (BM25) pass merged with the vectors — is the documented next step, not something already wired into the default query. It’s a small reach, though: the search index already stores the chunk text as a full-text field, so a keyword pass is one query away. This section shows why you’d add it — an honest next commit, not a claim that it runs.
The reranker is the one place this stage can call an outside service, so it’s built to fail soft — a service error degrades to plain retrieval order rather than breaking the answer. Local mode uses a no-op passthrough, so the graph runs with zero cloud:
This whole post is one repo at one tag. Clone it, seed synthetic P&C insurance data, and watch the two-stage funnel run on your laptop — dense recall, then a reranker that reorders and trims to the budget.
You’ll see the answer with numbered citations back to the source chunks — the top-5 reranked passages the model was allowed to read. Both vector-store backends implement the same search(query_vector, flt, k) contract and apply the permission filter before scoring: local mode is numpy cosine, the default is a k-NN index with the filter compiled into the query.
Teaching-grade reference implementation, not a production insurance product. It reproduces the ideas; bring your own data and keys. MIT-licensed.
Explain it back
Your vector search returns 12 chunks and the one that actually answers the question is sitting at rank #9. You send the top 5 to the model. Two things are now true — name the problem, and name the fix.
Reveal a model answer
The problem: dense similarity ranked the answer #9, so a naive “top-5 by vector score” cuts it — the model never sees the passage it needs and either guesses or abstains. The fix: rerank before you trim. A reranker reads the question and each candidate together, scores true relevance, and can lift that #9 chunk to #1 — so it’s safely inside the top-5 budget the model receives. (And if the exact identifier matters, add a keyword/hybrid pass so the chunk that literally names POL-55012 can’t be missed in the first place.)
You saw build_filter(identity) ride along on every search here — quietly deciding which chunks were even candidates. In a regulated insurer that filter is the whole game: an adjuster and an underwriter ask the same question and get different answers, because restricted chunks are excluded before the model sees them. Next: how the signed token becomes a query filter, why fail-closed matters, and what the audit log records.