rag-engine / src /rag_engine /hybrid_search.py
Jayan12321's picture
Upload folder using huggingface_hub
e43c82d verified
Raw History Blame Contribute Delete
3.51 kB
"""Hybrid search combining Dense Vector Search and BM25 Keyword Search.
Uses Reciprocal Rank Fusion (RRF) to merge the ranked result lists from both
retrieval methods into a single, high-quality candidate list.
Why Hybrid Search?
- Dense vectors understand semantic similarity (e.g. 'space telescope' -> 'astronomy').
- BM25 understands exact identifiers and keywords (e.g. 'NIRCam', 'L2', '15 millikelvin').
- Together, they significantly outperform either method on its own.
"""
from collections import defaultdict
def reciprocal_rank_fusion(dense_results, bm25_results, dense_weight=0.6, bm25_weight=0.4, k=60):
"""Merge two ranked lists using Reciprocal Rank Fusion (RRF).
Formula for each document:
Score = sum( weight / (k + rank) )
Args:
dense_results: List of dicts from vector search [{"chunk": ..., "score": ...}].
bm25_results: List of dicts from BM25 search.
dense_weight: Relative importance of vector search (default: 0.6).
bm25_weight: Relative importance of keyword search (default: 0.4).
k: Smoothing constant to dampen extreme high ranks (standard is 60).
Returns:
List of fused dicts sorted by combined RRF score.
"""
rrf_scores = defaultdict(float)
chunks_by_id = {}
dense_ranks = {}
bm25_ranks = {}
# Accumulate dense ranks
for rank, item in enumerate(dense_results, start=1):
chunk = item["chunk"]
cid = chunk.chunk_id
chunks_by_id[cid] = chunk
dense_ranks[cid] = rank
rrf_scores[cid] += dense_weight * (1.0 / (k + rank))
# Accumulate BM25 ranks
for rank, item in enumerate(bm25_results, start=1):
chunk = item["chunk"]
cid = chunk.chunk_id
chunks_by_id[cid] = chunk
bm25_ranks[cid] = rank
rrf_scores[cid] += bm25_weight * (1.0 / (k + rank))
# Sort descending by fused score
sorted_items = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)
fused_results = []
for cid, combined_score in sorted_items:
fused_results.append({
"chunk": chunks_by_id[cid],
"score": round(combined_score, 5),
"dense_rank": dense_ranks.get(cid),
"bm25_rank": bm25_ranks.get(cid),
"source": "hybrid",
})
return fused_results
class HybridRetriever:
"""Orchestrates dense vector search and BM25 search."""
def __init__(self, vector_store, bm25_index, dense_weight=0.6, bm25_weight=0.4):
self.vector_store = vector_store
self.bm25_index = bm25_index
self.dense_weight = dense_weight
self.bm25_weight = bm25_weight
def retrieve(self, query, top_k=4, dense_k=10, bm25_k=10):
"""Retrieve the best matching chunks using hybrid search.
Returns:
Dict containing:
- 'results': top_k fused results
- 'dense_results': raw vector results
- 'bm25_results': raw BM25 results
"""
dense_hits = self.vector_store.search(query, top_k=dense_k)
bm25_hits = self.bm25_index.search(query, top_k=bm25_k)
fused = reciprocal_rank_fusion(
dense_results=dense_hits,
bm25_results=bm25_hits,
dense_weight=self.dense_weight,
bm25_weight=self.bm25_weight,
)
return {
"results": fused[:top_k],
"dense_results": dense_hits,
"bm25_results": bm25_hits,
}