The last four posts made vector search good. This post makes retrieval good, which is a different problem. Pure vector search misses exact terms (error codes, function names, version numbers). Pure full-text search misses meaning. A reranker can sit on top of both and demote the wrong-but-similar answers. And once you have the right context, there's a separate craft to handing it to a language model without making it hallucinate.
One distinction worth stating plainly: RAG is not vector search. RAG is retrieve the right thing, then put it in the context window. Vector search is one way to retrieve. Full-text is another. The interesting work is fusing them and bounding what goes in the prompt.
This is also the post where the docs assistant stops being a similarity search and starts being a thing that answers questions.
The single best change to retrieval quality on a docs-style corpus is hybrid search. The technique that works without tuning is Reciprocal Rank Fusion (RRF). It operates on ranks, not raw scores, which means you don't have to normalize incomparable signals. I went into the SQL in detail in the RAG with Postgres, Drizzle, and pgvector post; here I'll show the shape and call out what changes for multimodal.
WITH vector_results AS (
SELECT id,
ROW_NUMBER() OVER (ORDER BY embedding <=> $1) AS rank
FROM chunks
ORDER BY embedding <=> $1
LIMIT 60
),
fts_results AS (
SELECT id,
ROW_NUMBER() OVER (
ORDER BY ts_rank(
to_tsvector('english', body),
plainto_tsquery('english', $2)
) DESC
) AS rank
FROM chunks
WHERE to_tsvector('english', body) @@ plainto_tsquery('english', $2)
AND body IS NOT NULL
LIMIT 60
),
rrf AS (
SELECT
COALESCE(v.id, f.id) AS id,
COALESCE(1.0 / (60 + v.rank), 0)
+ COALESCE(1.0 / (60 + f.rank), 0) AS score
FROM vector_results v
FULL OUTER JOIN fts_results f ON v.id = f.id
)
SELECT c.id, c.body, c.metadata, rrf.score
FROM rrf JOIN chunks c ON c.id = rrf.id
ORDER BY rrf.score DESC
LIMIT 20;60 is the empirical constant from the RRF paper. Don't tune it; it's been
robust across orders of magnitude. The FULL OUTER JOIN is what matters:
a row that appears in only one ranking still gets a (smaller) score,
which is the whole point. If you INNER JOIN, you throw away every row
that one side missed, which defeats the purpose.
Two things about hybrid in our multimodal setting.
First, only text chunks participate in the FTS side. The WHERE body IS NOT NULL guard handles image rows. They still participate on the
vector side, so the multimodal retrieval is intact; FTS just can't
contribute to ranking them.
Second, the FTS side genuinely rescues queries with exact terms.
"CREATE INDEX CONCURRENTLY" as a vector query lands in a neighborhood
that includes "CREATE INDEX" docs and "concurrent" docs and a few
unrelated chunks about concurrency. The FTS side puts the exact-string
match at rank 1. Fused, the right doc wins.
RRF score per doc: 1 / (k + rank_v) + 1 / (k + rank_f). A doc that appears in only one list still gets credit; the FULL OUTER JOIN is the whole point.
k = 60 is the empirical constant from the paper. Don't tune it. The pills above show each doc's rank in each source list.
Two lists disagree. RRF fuses them on rank, not raw score, so incomparable signals cooperate.
Hybrid handles the "exact term vs meaning" axis. Metadata handles a different one: scope. If the user is searching docs for Postgres 16 and your corpus mixes 14, 15, and 16, the wrong-version chunk is going to land in the top-10 because the embedding doesn't know about versions. You can fix that without rewriting the retrieval:
WHERE (metadata->>'pg_version')::int = 16
AND embedding <=> $1 < 0.5 -- vector still wins inside the sliceA few things to keep in mind so the filter doesn't quietly destroy your recall.
1. Selectivity matters. A WHERE that keeps 99% of rows is free.
A WHERE that keeps 0.1% of rows can make the planner fall back to a
sequential scan because the index can't enforce the filter. Part 5's
EXPLAIN (ANALYZE, BUFFERS) is the truth-teller.
2. Index the filter column. A btree on (metadata->>'pg_version')
is cheap and the planner will love it when selectivity changes.
3. Pre-filter beats post-filter when the slice is large. When it isn't, you want the vector search to run first and the filter to apply on top. The planner usually gets this right; check it.
Metadata + hybrid is the combination that gets you to "answers feel right on real queries" faster than any reranker. The reranker is the polish on top of that, not the substitute.
RRF is cheap and broadly correct. It is also unaware of meaning at the top of the list. The top 20 after RRF are usually close enough that the order is partially arbitrary. A reranker fixes that: take the top-K from hybrid retrieval, send them through a cross-encoder that compares each candidate to the query directly, and reorder.
Voyage has a multimodal reranker (rerank-multimodal-2) that scores
mixed text-and-image candidates against a text query. The shape is
straightforward:
type RerankPiece =
| { type: "text"; text: string }
| { type: "image_url"; image_url: string };
async function rerank(query: string, candidates: RerankPiece[][]) {
const res = await fetch("https://api.voyageai.com/v1/rerank", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VOYAGE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "rerank-multimodal-2",
query,
documents: candidates.map((content) => ({ content })),
top_k: 5,
}),
});
const json = (await res.json()) as {
data: { index: number; relevance_score: number }[];
};
return json.data; // ordered by relevance_score desc
}Pass it your top 20 candidates from RRF, get back top 5 in a meaningfully better order. The latency cost is real (hundreds of milliseconds for 20 candidates, more for images), so this is a knob you decide to spend per query.
A reranker is a cross-encoder: it sees the query alongside each candidate and scores them together. Slow but accurate; that's why we only run it on the top-K from hybrid.
Stage 1: hybrid retrieval. 8 candidates, scores too close to be reliable.
A pattern that works well: retrieve top-20 with hybrid, rerank to top-5 when the query is "important" (user-facing, longer typed query, expensive to be wrong), skip the rerank for autocomplete-style probes. Pick on purpose; the eval in Part 7 will tell you whether you've earned it.
I'm going to undercut my own advice for a second. For a lot of docs-style
corpora, hybrid + a good chunker + the right ef_search is already
good enough. The right test is the eval, not the vibe. If your top-5
hybrid results contain the answer 95% of the time, reranking has nothing
to do.
The honest place reranking matters most: when your corpus has lookalike chunks (the same concept stated five different ways), or when you genuinely need to fuse text and image signal into a single ranking. Docs assistants are usually the former. Product catalogs are usually the latter.
You have your top-5. You're about to send them to a model along with the user's question. The shape of that prompt decides whether the answer is good or hallucinated.
Three rules, in order of how often they get violated.
1. Tell the model to use only the context. This sounds obvious; it's the difference between answers that cite your docs and answers that quote training data.
2. Ask for citations with markers. [1], [2], etc. Then verify
them on the way out. Citation discipline is the difference between
"helpful" and "trustworthy."
3. Cap context length on purpose. More chunks is not always better. Past a certain point you're paying for tokens, latency, and a higher chance of the model getting distracted by an irrelevant chunk. Cap at a budget. Drop low-score chunks when you exceed it.
Move the budget. Watch chunks pack in; the meter fills; the tail drops out live.
A compact assembly that does the above:
const MAX_TOKENS = 4000;
function buildPrompt(query: string, chunks: { body: string; tokens: number }[]) {
const kept: typeof chunks = [];
let used = 0;
for (const c of chunks) {
if (used + c.tokens > MAX_TOKENS) break;
kept.push(c);
used += c.tokens;
}
const context = kept
.map((c, i) => `[${i + 1}] ${c.body}`)
.join("\n\n");
return `Answer the question using ONLY the context below. If the answer
is not in the context, say so. Cite sources as [1], [2], etc.
Context:
${context}
Question: ${query}`;
}Pre-compute tokens for each chunk at ingest time. The cost is once;
the savings are every query.
The generation step is mostly mechanical once retrieval and context are right. The interesting work is verifying the answer used the context. That's grounding.
A simple post-hoc check: parse the citation markers out of the answer, intersect with the chunks you actually passed in, and flag any answer that has zero markers or cites a chunk that wasn't in the prompt. That last one is the failure mode you really want to catch.
function verifyGrounding(answer: string, providedCount: number) {
const cites = new Set<number>();
for (const m of answer.matchAll(/\[(\d+)\]/g)) {
cites.add(parseInt(m[1], 10));
}
const hasAny = cites.size > 0;
const allValid = [...cites].every((n) => n >= 1 && n <= providedCount);
return { hasAny, allValid, cites: [...cites] };
}This is the bare minimum. Part 7 turns it into a real eval (sentence- level faithfulness, not just citation presence), but the bare minimum is already useful in production as a sanity gate.
If a CREATE INDEX CONCURRENTLY operation fails, the resulting index is left in an INVALID state. Drop it with DROP INDEX CONCURRENTLY and retry.
ERROR: index «chunks_embedding_hnsw_idx» is invalid
Sentence 4 cites nothing because no chunk in the prompt mentions Postgres 18 auto-rebuild. Lesson 7 turns this check into a real faithfulness eval.
Hover a sentence. The cited source highlights. Sentences with no citation are the failure mode you want to catch.
Reranking is the most expensive step. Optimize the input to it. A reranker on 100 candidates costs more than a reranker on 20 candidates, linearly. Be miserly about how many candidates you feed it. Quality of the candidate set matters more than quantity once you're past about 30.
Watch out for empty FTS results. If a query has no FTS hits at all (typos, very short queries), your hybrid degrades to pure vector. That's not a bug; it's a fallback. Worth logging though, so you can see when it's happening.
The model will gladly cite a chunk that wasn't relevant. Grounding checks that a citation exists, not that it's correct. The harder eval is whether the answer's claim is actually supported by the cited chunk. That's Part 7's faithfulness eval.
Don't rerank query-time and on a tight latency budget without a reason. Hybrid search alone usually buys you most of the lift. Reranking is a real second-stage decision, not a default.
The docs assistant now retrieves with hybrid search, reranks the queries that earn it, and answers inside a token budget with a grounding check on the way out. Exact-term queries no longer lose to fuzzy neighbors.
Whether all of that is better than the version before, by how much, on what queries: that is the problem Part 7 was built to answer.
Occasional notes on software, tools, and things I learn. No spam.
Unsubscribe anytime.