Production RAG in 2026: Hybrid Search with pgvector, Reciprocal Rank Fusion, and Context Caching
A deep dive into building production-grade RAG pipelines using PostgreSQL: combining dense embeddings with BM25 full-text search, implementing Reciprocal Rank Fusion, and leveraging context caching to reduce latency and cost. Includes a complete SQL/TypeScript implementation and performance trade-offs.
Key Takeaway / TL;DR:
- Naive vector search alone fails on exact keywords, IDs, and rare terms; hybrid search with BM25 + dense embeddings significantly improves retrieval quality.
- Reciprocal Rank Fusion (RRF) is a simple, robust way to merge heterogeneous relevance scores without complex calibration.
- Context caching (e.g., Anthropic's prompt caching) can cut RAG latency and cost by up to 90% for repeated system prompts and retrieved contexts.
The Problem & Industry Shift
In 2026, RAG (Retrieval-Augmented Generation) has moved from demos to production, but many pipelines still rely on a single vector index. This approach fails on exact-match queries like product codes, legal citations, or user IDs. Dense embeddings capture semantic similarity but often miss lexical precision. The industry shift is toward hybrid search: combining dense vectors with BM25 full-text search, then fusing results using Reciprocal Rank Fusion (RRF). This is now a standard pattern in production RAG, and PostgreSQL with pgvector is a compelling platform because it supports both vector and full-text indexing in the same database, eliminating the need for a separate vector database.
Architecture & Core Mechanics
A production RAG pipeline involves several stages: ingestion, retrieval, reranking, and generation. Here's the data flow:
[Documents] -> [Chunking] -> [Embedding Model] -> [PostgreSQL: pgvector + tsvector]
|
v
[Query] -> [Embedding] -> [Vector Search] -> [RRF Fusion] -> [Reranker] -> [LLM with Context Cache]
|-> [Full-Text Search] -> [RRF Fusion] -> [Reranker] -> [LLM with Context Cache]
Key components:
- Chunking: Overlap and hierarchical chunking to preserve context. For example, chunk size 512 tokens with 50-token overlap.
- Dense embeddings: Use a model like
text-embedding-3-largeto generate vectors. - BM25: PostgreSQL's
tsvector/tsquerywithwebsearch_to_tsqueryfor full-text search. - RRF: Combine ranked lists from both retrievers using the formula:
score = Σ 1/(k + rank), wherekis typically 60. - Reranking: A cross-encoder model (e.g.,
cross-encoder/ms-marco-MiniLM-L-6-v2) to refine top-k results. - Context caching: Cache the system prompt and static context to avoid re-encoding them for every request.
Production Code Example
Below is a TypeScript implementation using pg and pgvector for a hybrid search query. It assumes a table documents with columns id, content, embedding vector(1536), and tsv tsvector.
import { Pool } from 'pg';
import { embed, generate } from './ai';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// RRF fusion function
function reciprocalRankFusion(results: { id: string; rank: number }[][], k = 60) {
const scores = new Map<string, number>();
for (const list of results) {
list.forEach((item, index) => {
const score = 1 / (k + index + 1); // rank starts at 1
scores.set(item.id, (scores.get(item.id) || 0) + score);
});
}
return Array.from(scores.entries())
.sort((a, b) => b[1] - a[1])
.map(([id]) => id);
}
async function hybridSearch(query: string, topK = 10) {
// 1. Generate embedding for query
const queryEmbedding = await embed(query);
// 2. Run vector and full-text searches in parallel
const [vectorResults, ftsResults] = await Promise.all([
pool.query(
`SELECT id, embedding <=> $1 AS distance
FROM documents
ORDER BY embedding <=> $1
LIMIT $2`,
[queryEmbedding, topK]
),
pool.query(
`SELECT id, ts_rank(tsv, websearch_to_tsquery('english', $1)) AS rank
FROM documents
WHERE tsv @@ websearch_to_tsquery('english', $1)
ORDER BY rank DESC
LIMIT $2`,
[query, topK]
),
]);
// 3. Convert to ranked lists (lower rank is better)
const vectorRanked = vectorResults.rows.map((r, i) => ({ id: r.id, rank: i + 1 }));
const ftsRanked = ftsResults.rows.map((r, i) => ({ id: r.id, rank: i + 1 }));
// 4. Fuse using RRF
const fusedIds = reciprocalRankFusion([vectorRanked, ftsRanked]);
// 5. Fetch full documents for reranking
const docs = await pool.query(
`SELECT id, content FROM documents WHERE id = ANY($1)`,
[fusedIds.slice(0, topK)]
);
// 6. Rerank with cross-encoder (pseudo-code)
const reranked = await rerank(query, docs.rows);
return reranked;
}
Critical decisions:
- Use
<=>for cosine distance (pgvector operator). - Use
websearch_to_tsqueryfor robust query parsing. - Run both searches in parallel to minimize latency.
- RRF avoids score normalization issues.
Performance, Cost & Trade-offs
Benchmarks (typical):
- Vector search alone: Recall@10 ~65% on MS MARCO.
- Hybrid (vector + BM25 + RRF): Recall@10 ~82% (+17% relative).
- Adding a reranker: +5-10% further, but adds ~50ms latency per query.
Latency breakdown:
- Embedding query: ~10ms (local model) or ~100ms (API).
- Vector search: ~20ms for 1M vectors with HNSW index.
- Full-text search: ~5ms.
- RRF: <1ms.
- Reranking: ~50ms for top 10 with cross-encoder.
- LLM generation: 1-3s (dominates).
Cost:
- Context caching can reduce LLM cost by up to 90% for repeated system prompts. For example, Anthropic's prompt caching charges 1.25x for cache writes but 0.1x for cache reads [1].
- Storing embeddings in PostgreSQL avoids separate vector DB costs.
Trade-offs:
- Hybrid search requires maintaining both
tsvectorandembeddingcolumns; use triggers to keep them in sync. - RRF is simple but not optimal; weighted RRF can improve results if you have validation data.
- Context caching increases memory usage; set appropriate TTLs.
Actionable Checklist / Summary
- Start with hybrid search: Combine
tsvectorandpgvectorin PostgreSQL. Use RRF for fusion. - Chunk wisely: Use overlapping chunks (e.g., 512 tokens with 50 overlap) and consider hierarchical chunking for long documents.
- Index properly: Create HNSW index on
embeddingand GIN index ontsv. - Rerank: Add a cross-encoder reranker for top 10 results to improve precision.
- Cache context: Use prompt caching (e.g., Anthropic's) for system prompts and static retrieved context to reduce latency/cost.
- Monitor and evaluate: Track retrieval metrics (Recall@k, MRR) and end-to-end quality.
- Consider weighted RRF: Tune
kand weights if you have labeled data.
References
- [1] Anthropic Prompt Caching Documentation: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
- [2] pgvector GitHub Repository: https://github.com/pgvector/pgvector
- [3] PostgreSQL Full-Text Search Documentation: https://www.postgresql.org/docs/current/textsearch.html
- [4] Reciprocal Rank Fusion paper (Cormack et al.): https://plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf
- [5] Cross-Encoder models (SBERT): https://www.sbert.net/examples/applications/cross-encoder/README.html