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()