Spaces:
Sleeping
Sleeping
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}
|