Hybrid Search¶
ragwise runs hybrid BM25+dense retrieval on every query automatically. No configuration needed.
Why hybrid?¶
Dense (embedding) search finds semantically similar text. BM25 (sparse) search finds exact keyword matches. Neither is best alone:
| Query type | Dense alone | BM25 alone | Hybrid |
|---|---|---|---|
| "show me the refund policy" | ✅ | ✅ | ✅ |
| "error code 0x80004005" | ❌ poor | ✅ exact | ✅ |
| "what does the SDK do conceptually" | ✅ | ❌ no keywords | ✅ |
Industry benchmarks consistently show hybrid outperforms either alone by 5–15% on NDCG@10 across mixed corpora.
How RRF fusion works¶
ragwise uses Reciprocal Rank Fusion (RRF) to merge dense and sparse results into a single ranked list.
For each document d that appears in any result list:
where k = 60 (standard constant) and rank_i(d) is the document's rank in result list i. Documents not in a list get a rank of ∞ (score contribution = 0).
RRF is rank-based, so it requires no score normalization between dense and sparse results — they use different scales (cosine similarity vs BM25 score), but ranks are universal.
Example¶
| Doc | Dense rank | BM25 rank | RRF score |
|---|---|---|---|
| A | 1 | 5 | 1/61 + 1/65 = 0.0321 |
| B | 3 | 1 | 1/63 + 1/61 = 0.0322 |
| C | 2 | 10 | 1/62 + 1/70 = 0.0304 |
Doc B wins despite not ranking first in either list — RRF rewards consistent presence across both signals.
Per-store implementation¶
InMemoryStore (store="memory")¶
- Dense: numpy cosine similarity —
np.dot(query_vec, doc_vecs.T) / (norms * query_norm) - Sparse:
rank-bm25library — standard BM25 Okapi implementation - Both run in-process. No I/O.
LanceDBStore (store="lance://...")¶
- Dense: LanceDB native ANN index (IVF-PQ by default)
- Sparse: LanceDB full-text search (FTS) index built automatically on first
ingest() - Both queries run as embedded (no network, no server process)
PgVectorStore (store="postgresql://...")¶
- Dense:
pgvectorHNSW index —embedding <=> query_vector ORDER BY ... LIMIT k - Sparse: PostgreSQL FTS —
tsvectorcolumn +ts_rankscoring;to_tsvector('english', content) @@ plainto_tsquery('english', query) - Both run in a single SQL query using
UNION ALL+ RRF in SQL: no round-trips
Tuning¶
The default top_k=5 returns 5 fused results. Increase for wider coverage:
from ragwise import QueryConfig
answer = await rag.query(
"What changed in v2?",
config=QueryConfig(top_k=10),
)
To inspect raw retrieved chunks before generation:
from ragwise.retrieval.search import HybridSearcher
searcher = HybridSearcher(store=rag._store, embedder=rag._embedder)
results = await searcher.search("error code 0x80004005", top_k=5)
for r in results:
print(r.score, r.source, r.text[:100])
Reranker (opt-in)¶
For higher precision, add a cross-encoder reranker after retrieval:
RAG(
llm="openai/gpt-4o-mini",
reranker="cross-encoder/ms-marco-MiniLM-L-6-v2", # or "bge-reranker-v2-m3"
)
The reranker scores each retrieved chunk against the query using a model that jointly encodes both. Adds ~50–150ms per query but improves precision by ~8–12% on BEIR benchmarks.