File size: 7,118 Bytes
b2931f4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a8bc5d0
b2931f4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a8bc5d0
 
 
 
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
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
"""Dense vector retrieval over the Qdrant `finrag_chunks` collection.

This is the minimal Day-1 retriever: embed the query with Cohere v3
(`search_query` side of the asymmetric pair) and run a single nearest-
neighbor search with optional payload filtering. Hybrid (BM25 + dense)
and reranking come on Day 2.
"""

from __future__ import annotations

from functools import lru_cache

import cohere
from pydantic import BaseModel
from qdrant_client import QdrantClient
from qdrant_client.models import (
    FieldCondition,
    Filter,
    MatchValue,
)

from finrag.config import settings
from finrag.ingestion.embed import COHERE_MODEL, COLLECTION_NAME, make_qdrant_client


# ── Public response model ─────────────────────────────────────────────────
class RetrievedChunk(BaseModel):
    """One chunk surfaced by the retriever, with its similarity score.

    All payload fields from the Chunk we indexed are copied through β€” so the
    caller (or eventually, the agent/frontend) has everything it needs to
    render a citation without joining back against another store.
    """

    chunk_id: str
    score: float
    text: str
    chunk_type: str
    section_title: str | None
    ticker: str
    company_name: str
    fiscal_year: int
    period_of_report: str
    accession_number: str
    sec_url: str


# ── Clients (one per process, cached) ─────────────────────────────────────
# lru_cache on a no-arg function is the canonical "singleton per process"
# pattern for FastAPI. Avoids re-creating TLS connections on every request.
@lru_cache(maxsize=1)
def get_cohere_client() -> cohere.ClientV2:
    return cohere.ClientV2(api_key=settings.cohere_api_key)


@lru_cache(maxsize=1)
def get_qdrant_client() -> QdrantClient:
    # Embedded (on-disk) or remote, decided by settings.qdrant_path β€” see
    # make_qdrant_client. lru_cache makes this the single client per worker that
    # embedded mode requires.
    return make_qdrant_client()


# ── Query embedding ───────────────────────────────────────────────────────
def embed_query(text: str) -> list[float]:
    """Embed a user query using the query-side encoder.

    The matching `search_document` lives in ingestion/embed.py. Mismatching
    these two silently degrades retrieval quality β€” there's no error, just
    worse results. See Decision 6's notes on asymmetric retrieval.
    """
    co = get_cohere_client()
    response = co.embed(
        texts=[text],
        model=COHERE_MODEL,
        input_type="search_query",
        embedding_types=["float"],
    )
    return response.embeddings.float_[0]


# ── Filter builder ────────────────────────────────────────────────────────
def _build_filter(
    ticker: str | None = None,
    fiscal_year: int | None = None,
    chunk_type: str | None = None,
) -> Filter | None:
    """Translate simple kwarg filters into Qdrant's filter grammar.

    Treats falsy values (None, "", 0) as "not provided" β€” important because
    JSON clients (notably Swagger UI) often send "" for unset string fields
    instead of omitting them, and we don't want to filter for ticker == "".
    """
    conditions: list[FieldCondition] = []
    if ticker:
        conditions.append(
            FieldCondition(key="ticker", match=MatchValue(value=ticker))
        )
    if fiscal_year:
        conditions.append(
            FieldCondition(key="fiscal_year", match=MatchValue(value=fiscal_year))
        )
    if chunk_type:
        conditions.append(
            FieldCondition(key="chunk_type", match=MatchValue(value=chunk_type))
        )
    return Filter(must=conditions) if conditions else None


# ── Payload β†’ RetrievedChunk ──────────────────────────────────────────────
def payload_to_chunk(payload: dict, score: float) -> RetrievedChunk:
    """Convert a Qdrant payload + score into a RetrievedChunk.

    Shared by dense search and the hybrid retriever's hydration step β€” keeps
    the mapping in one place so adding a field to the model means editing
    one function, not three.
    """
    return RetrievedChunk(
        chunk_id=payload["chunk_id"],
        score=score,
        text=payload["text"],
        chunk_type=payload["chunk_type"],
        section_title=payload.get("section_title"),
        ticker=payload["ticker"],
        company_name=payload["company_name"],
        fiscal_year=payload["fiscal_year"],
        period_of_report=payload["period_of_report"],
        accession_number=payload["accession_number"],
        sec_url=payload["sec_url"],
    )


# ── Retrieval ─────────────────────────────────────────────────────────────
def search(
    question: str,
    top_k: int = 5,
    ticker: str | None = None,
    fiscal_year: int | None = None,
    chunk_type: str | None = None,
) -> list[RetrievedChunk]:
    """Dense-only retrieval: embed the question, search Qdrant, return chunks.

    Kept available for direct testing and the eval harness's comparison runs.
    The user-facing /query endpoint uses hybrid_search instead.
    """
    qdrant = get_qdrant_client()
    query_vector = embed_query(question)
    query_filter = _build_filter(ticker, fiscal_year, chunk_type)

    response = qdrant.query_points(
        collection_name=COLLECTION_NAME,
        query=query_vector,
        query_filter=query_filter,
        limit=top_k,
        with_payload=True,
    )
    return [payload_to_chunk(p.payload, p.score) for p in response.points]


def retrieve_by_chunk_ids(chunk_ids: list[str]) -> dict[str, dict]:
    """Batch-fetch payloads by chunk_id (used by hybrid hydration).

    Returns a dict {chunk_id: payload}. Qdrant stores point IDs as uint64
    (the hex chunk_id converted), so we convert on the way in and dereference
    via the payload's own chunk_id field on the way out.

    Ids that aren't valid 16-hex chunk_ids (e.g. a value the agent hallucinated
    like 'chunk_5') are silently dropped rather than raising β€” a malformed id is
    just a miss, so callers see it as not-found, not a crash.
    """
    if not chunk_ids:
        return {}
    qdrant = get_qdrant_client()
    point_ids: list[int] = []
    for cid in chunk_ids:
        try:
            point_ids.append(int(cid, 16))
        except ValueError:
            continue  # not a hex chunk_id β†’ treat as not-found
    if not point_ids:
        return {}
    points = qdrant.retrieve(
        collection_name=COLLECTION_NAME,
        ids=point_ids,
        with_payload=True,
    )
    return {p.payload["chunk_id"]: p.payload for p in points}