Spaces:
Sleeping
Sleeping
File size: 5,001 Bytes
b2931f4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 | """Hybrid retrieval: dense (Qdrant) + lexical (BM25) fused via RRF.
Why RRF rather than score normalization: BM25 scores and cosine similarities
are on different scales with different distributions. Normalizing them risks
arbitrary calibration choices. RRF discards raw scores and uses *ranks*,
which makes the fusion calibration-free and notably robust.
Formula (Cormack, Clarke, Buettcher 2009):
RRF_score(d) = Ξ£ over rankers r: 1 / (k + rank_r(d))
rank is 1-based; k=60 is the paper's value and works in practice.
"""
from __future__ import annotations
from finrag.retrieval import lexical
from finrag.retrieval.vector import (
RetrievedChunk,
payload_to_chunk,
retrieve_by_chunk_ids,
search as dense_search,
)
# Constants
RRF_K = 60 # smoothing β paper default, do not tune without eval
DEFAULT_K_EACH = 50 # candidates per retriever before fusion
def _rrf_fuse(
ranked_lists: list[list[str]], k: int = RRF_K
) -> dict[str, float]:
"""Compute RRF scores given multiple ranked lists of chunk_ids.
Each list should be in retrieval-order (best first). A chunk_id absent
from a list contributes 0 from that ranker.
"""
scores: dict[str, float] = {}
for ranks in ranked_lists:
for position, chunk_id in enumerate(ranks, start=1):
scores[chunk_id] = scores.get(chunk_id, 0.0) + 1.0 / (k + position)
return scores
def hybrid_search(
question: str,
top_k: int = 5,
ticker: str | None = None,
fiscal_year: int | None = None,
chunk_type: str | None = None,
k_each: int = DEFAULT_K_EACH,
) -> list[RetrievedChunk]:
"""End-to-end hybrid retrieval.
Steps:
1. Run dense and BM25 in parallel-ish (sequential here; both fast).
2. RRF-fuse the two rank orderings into a single score per chunk_id.
3. Take top_k by fused score.
4. Hydrate any chunk_ids that came only from BM25 by batch-fetching
their payloads from Qdrant.
5. Return RetrievedChunk objects with `score` = the RRF fused score.
The score field is now the RRF score, not raw cosine or BM25. RRF scores
are small (typically 0.01-0.05 for top results) β don't compare them to
Day-1 cosine scores; they're on different scales.
"""
# 1. Candidates from each retriever, both filter-aware so the candidate
# pool already respects the user's scoping.
dense_chunks = dense_search(
question=question,
top_k=k_each,
ticker=ticker,
fiscal_year=fiscal_year,
chunk_type=chunk_type,
)
bm25_results = lexical.search(
query=question,
top_k=k_each,
ticker=ticker,
fiscal_year=fiscal_year,
chunk_type=chunk_type,
)
dense_ids = [c.chunk_id for c in dense_chunks]
bm25_ids = [cid for cid, _ in bm25_results]
# 2. RRF fuse
rrf_scores = _rrf_fuse([dense_ids, bm25_ids])
# 3. Top-K by fused score
top_ids = sorted(rrf_scores, key=rrf_scores.get, reverse=True)[:top_k]
# 4. Hydrate. Dense gave us full payloads; for BM25-only chunks, batch
# fetch from Qdrant.
dense_map = {c.chunk_id: c for c in dense_chunks}
missing_ids = [cid for cid in top_ids if cid not in dense_map]
extra_payloads = retrieve_by_chunk_ids(missing_ids)
# 5. Build final list, score = RRF score
results: list[RetrievedChunk] = []
for cid in top_ids:
rrf_score = rrf_scores[cid]
if cid in dense_map:
# Reuse the dense RetrievedChunk; just swap the score for the
# fused one. model_copy keeps the object immutable-ish.
results.append(dense_map[cid].model_copy(update={"score": rrf_score}))
elif cid in extra_payloads:
results.append(payload_to_chunk(extra_payloads[cid], rrf_score))
else:
# Should not happen β a fused id with no source. Defensive skip.
continue
return results
# ββ CLI βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def main() -> None:
"""Compare dense-only, BM25-only, and hybrid on a few canary queries."""
queries = [
"How did Apple's services revenue change in 2023?",
"Tesla R&D expense fiscal 2023",
"SG&A expense",
"supply chain risk",
]
for q in queries:
print(f"\n=== {q!r} ===")
print(" Dense top-3:")
for c in dense_search(q, top_k=3):
print(f" {c.ticker} FY{c.fiscal_year} score={c.score:.3f} | {c.text[:60]}")
print(" BM25 top-3:")
for cid, score in lexical.search(q, top_k=3):
print(f" {cid} score={score:.3f}")
print(" Hybrid top-3:")
for c in hybrid_search(q, top_k=3):
print(f" {c.ticker} FY{c.fiscal_year} rrf={c.score:.4f} | {c.text[:60]}")
if __name__ == "__main__":
main()
|