Hybrid RAG in Practice: Combining Dense Vector Search with Sparse BM25 via Reciprocal Rank Fusion
An engineering breakdown of why pure semantic vector search fails on technical queries, and how combining FAISS cosine embeddings with BM25 inverted lexical indexing using Reciprocal Rank Fusion (RRF) eliminates retrieval blind spots.
The Hidden Blind Spots of Dense-Only Vector Retrieval
Dense vector embeddings (e.g., `sentence-transformers/all-MiniLM-L6-v2`) transform text chunks into continuous mathematical spaces where semantic similarity translates to cosine distance. In academic literature and technical documentation, however, dense embeddings exhibit well-documented failure modes:
1. **Exact Identifier Mismatch**: Queries containing specific product codes, part numbers, or pharmaceutical terms (e.g., `"Model CR-2032"` or `"LOINC 1558-6"`) often yield dense vectors close to general hardware or chemistry concepts, retrieving semantically "similar" paragraphs that lack the specific identifier. 2. **Rare Lexical Tokens**: Embedding models compress vocabulary into subword tokens. Unseen specialized jargon suffers from high dispersion across dimensions, diluting relevance. 3. **Short Keyword Queries**: Dense embeddings perform best with natural language sentences. For terse 2-word queries like `"liljegren wbgt"`, dense projections often collapse into generic weather summaries.
To solve this in **DocMind AI**, we implemented a dual-indexing hybrid retrieval architecture using **Reciprocal Rank Fusion (RRF)**:
┌──► FAISS IndexFlatIP (Dense 384-d Cosine) ──► Rank List D
│
[ User Query ] ──┤
│
└──► BM25Okapi (Sparse Inverted Index) ──► Rank List S
│
┌─────────────────────────────────────────────────────────────┘
▼
[ Reciprocal Rank Fusion (RRF) Engine ]
│
│ RRF_Score(c) = ∑ 1 / (k + rank_i(c)) where k = 60
▼
[ Deduplicated Top-K Chunks with Page Coordinates ] ──► [ LLM Generator ]Mathematical Formulation: Reciprocal Rank Fusion
Rather than attempting to calibrate and normalize raw cosine similarities (bounded between -1.0 and 1.0) with unbounded BM25 scores (which scale with document length and term frequency), RRF operates purely on **ordinal ranks**.
For any retrieved chunk $c$:
$$\text{RRF Score}(c) = \sum_{m \in \{\text{dense}, \text{sparse}\}} \frac{1}{k + r_m(c)}$$
Where: - $r_m(c)$ is the 1-indexed position of chunk $c$ in retriever $m$'s ranking list (or $\infty$ if not retrieved in top $N$). - $k$ is a smoothing constant, conventionally set to $60$ to prevent high ranks from completely dominating the combined score.
def reciprocal_rank_fusion(
dense_results: list[dict],
sparse_results: list[dict],
k: int = 60
) -> list[dict]:
scores: dict[str, float] = {}# Accumulate dense rank scores for rank, item in enumerate(dense_results, start=1): cid = item["chunk_id"] chunk_map[cid] = item scores[cid] = scores.get(cid, 0.0) + (1.0 / (k + rank))
# Accumulate sparse BM25 rank scores for rank, item in enumerate(sparse_results, start=1): cid = item["chunk_id"] chunk_map[cid] = item scores[cid] = scores.get(cid, 0.0) + (1.0 / (k + rank))
# Sort descending by combined RRF score sorted_chunks = sorted(scores.items(), key=lambda x: x[1], reverse=True) return [{**chunk_map[cid], "rrf_score": score} for cid, score in sorted_chunks] ```
Guaranteeing Lineage: Word-to-Page Coordinate Indexing
A major cause of user distrust in RAG systems is the inability to quickly verify citations. If an AI states *"The maximum allowable wind speed for mobile cranes is 38 km/h"*, the user must be able to verify that on the exact source page.
In DocMind AI, during the ingestion phase, we avoid naive global string concatenation. Instead, we preserve character offsets mapped directly to physical PDF page numbers:
def chunk_document_with_page_lineage(
pages: list[dict],
chunk_size: int = 400,
overlap: int = 50
) -> list[dict]:
# Stream words with their origin page number
word_tokens: list[tuple[str, int]] = []
for page in pages:
p_num = page["page_number"]
for word in page["text"].split():chunks = [] step = chunk_size - overlap for i in range(0, len(word_tokens), step): window = word_tokens[i : i + chunk_size] chunk_text = " ".join(w[0] for w in window) # Capture all distinct pages spanned by this sliding window spanned_pages = sorted(list({w[1] for w in window})) chunks.append({ "chunk_id": f"chunk_{i // step}", "text": chunk_text, "pages": spanned_pages, "primary_page": spanned_pages[0], }) return chunks ```
When context is supplied to the LLM prompt, each chunk is prefixed with `[Source: Page X]`. The frontend renders clickable citation pills that open the exact page preview.
Results & Trade-Offs
- **Retrieval Precision**: On technical and acronym-heavy queries, hybrid RRF recovered key documents missed in top-5 dense search 34% of the time in our benchmark tests. - **Latency Overhead**: Running BM25 alongside FAISS in Python added less than 12ms of CPU time per request—well within acceptable limits for a real-time conversational interface.