Part 1 (pb-47) gets you a working pipeline; this playbook gets you one you can defend. Pure vector search misses exact identifiers ("error E4021", "SKU-9981", function names) that keyword search nails, and top-k similarity ordering is a weak ranking signal.
Tier: Advanced · Workflow area: Building LLM Features
The production-grade stack — hybrid retrieval, a reranker, and verifiable citations — is precisely the sub-skill list that appears verbatim in applied-LLM job descriptions. Each upgrade here is measured against the pb-47 baseline before it stays.
sentence-transformers (free).Add keyword retrieval beside vector retrieval. Postgres full-text search is already in your database:
ALTER TABLE chunks ADD COLUMN tsv tsvector
GENERATED ALWAYS AS (to_tsvector('english', content)) STORED;
CREATE INDEX ON chunks USING gin (tsv);
def keyword_retrieve(query: str, k: int = 20) -> list[Chunk]:
return db.execute(
"""SELECT id, title, section, url, content,
ts_rank(tsv, websearch_to_tsquery('english', %s)) AS score
FROM chunks
WHERE tsv @@ websearch_to_tsquery('english', %s)
ORDER BY score DESC LIMIT %s""",
(query, query, k)).fetchall()
Fuse the two lists with Reciprocal Rank Fusion. RRF needs no score normalization, which is why it is the standard fusion choice:
def rrf_fuse(vector_hits, keyword_hits, k_const: int = 60) -> list[Chunk]:
scores: dict[int, float] = {}
for hits in (vector_hits, keyword_hits):
for rank, chunk in enumerate(hits):
scores[chunk.id] = scores.get(chunk.id, 0) + 1 / (k_const + rank + 1)
by_id = {c.id: c for c in [*vector_hits, *keyword_hits]}
return [by_id[cid] for cid, _ in
sorted(scores.items(), key=lambda x: -x[1])]
Rerank the fused candidates, then cut hard. Retrieve wide (top ~30 fused), rerank with a cross-encoder that reads query and chunk together, keep the top 5–6 for the context. This is the single biggest precision win in most RAG stacks:
def rerank(query: str, chunks: list[Chunk], top_n: int = 6) -> list[Chunk]:
resp = rerank_client.rerank(
query=query, documents=[c.content for c in chunks], top_n=top_n)
return [chunks[r.index] for r in resp.results if r.relevance_score > 0.3]
The reranker's score threshold replaces pb-47's raw-similarity floor as your refusal trigger — it is a far better relevance signal.
Upgrade citations from decoration to verification. Require claim-level bracketed citations (pb-47's [n] scheme), then check them mechanically: for each cited passage, verify n-gram overlap or run a cheap entailment check between the claim sentence and the cited chunk. Strip or flag claims whose citation does not support them. This turns "sources listed below" into "every claim traceable to a passage".
Add a groundedness gate for high-stakes surfaces. For answers going to customers (vs internal users), run a second cheap model call: "Given only these passages, is each claim in this answer supported? Answer per-claim: supported / unsupported." Block or soften answers with unsupported claims. This is your first LLM-as-judge — pb-50 industrializes it.
Re-run the eval and publish the comparison table. The deliverable that makes this engineering:
| Config | Hit rate | Answer correct | p95 latency | Cost/query |
|---|---|---|---|---|
| Vector only (pb-47 baseline) | 8/10 | 7/10 | 900ms | $0.004 |
| Hybrid (RRF) | 9/10 | 8/10 | 950ms | $0.004 |
| Hybrid + rerank | 10/10 | 9/10 | 1250ms | $0.006 |
Keep whichever row your latency/cost budget can afford — with numbers, that is now a decision, not a debate.
Grow the eval set from production: every user-flagged bad answer becomes a labeled eval case. Twenty real failures beat two hundred synthetic questions.
websearch_to_tsquery handles user-typed queries safely.BAAI/bge-reranker via sentence-transformers (local, free, GPU-hungry at scale).citations: {enabled: true} feature returns span-level cited text natively — worth evaluating as a replacement for hand-rolled [n] parsing when your context fits its document-block model.[3] without checking that passage 3 supports the claim is worse than no citations — it launders hallucinations with fake provenance. Step 4's check is the point.