File size: 13,020 Bytes
454f118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
"""
src/vectorstore/qdrant_store.py
================================
Qdrant vector store operations β€” collection management, upsert, search.

Running in LOCAL mode (no Docker/server needed):
    QdrantClient(path="./data/qdrant_store") stores everything to disk.
    This is perfectly fine for our scale (~15K chunks).

HNSW Index:
    m=16 β€” controls graph connectivity (higher = better recall, more memory)
    ef_construct=200 β€” build-time search depth (higher = better index quality)
    These values are production defaults used by major RAG systems.

Payload Filtering:
    Every chunk is stored with its metadata as a Qdrant "payload".
    This allows queries like:
        "only search in TCS documents"
        "only search FY2025 data"
        "only search table chunks"
    Without payload filtering, multi-company RAG would mix up contexts.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Optional

import numpy as np

from qdrant_client import QdrantClient
from qdrant_client.models import (
    Distance,
    VectorParams,
    HnswConfigDiff,
    PointStruct,
    Filter,
    FieldCondition,
    MatchValue,
    MatchAny,
    ScoredPoint,
)

from config.settings import settings
from src.utils.logger import logger
from src.chunking.hierarchical_chunker import Chunk


@dataclass
class SearchResult:
    """A single result from vector search."""
    chunk_id: str
    content: str
    score: float
    company: str
    ticker: str
    source_file: str
    fiscal_year: str
    page_number: int
    section: str
    subsection: str
    content_type: str
    chunk_level: str
    parent_chunk_id: Optional[str] = None


class QdrantStore:
    """
    Manages the Qdrant vector store for the financial RAG system.
    Handles collection creation, chunk upsert, and similarity search.
    """

    def __init__(self):
        # Connect to Cloud if URL provided, else fallback to Local disk
        if settings.qdrant_url and settings.qdrant_api_key:
            self.client = QdrantClient(
                url=settings.qdrant_url,
                port=443,
                api_key=settings.qdrant_api_key,
                timeout=60,
            )
            mode_msg = f"Cloud | url: {settings.qdrant_url}"
        else:
            self.client = QdrantClient(
                path=str(settings.qdrant_path),
                timeout=60,
            )
            mode_msg = f"Local | path: {settings.qdrant_path}"
            
        self.collection_name = settings.qdrant_collection
        self.dim = settings.embedding_dim
        logger.info(f"QdrantStore initialized | collection: {self.collection_name} | {mode_msg}")

    # ---------------------------------------------------------------- #
    # Collection Management
    # ---------------------------------------------------------------- #

    def create_collection(self, recreate: bool = False) -> None:
        """
        Create the Qdrant collection with HNSW index.

        Args:
            recreate: If True, drop and recreate the collection (fresh start).
                      Use this if you change embedding dims or want to re-index.
        """
        existing = [c.name for c in self.client.get_collections().collections]

        if self.collection_name in existing:
            if recreate:
                logger.warning(f"Dropping existing collection: {self.collection_name}")
                self.client.delete_collection(self.collection_name)
            else:
                logger.info(f"Collection '{self.collection_name}' already exists β€” skipping creation")
                return

        logger.info(f"Creating collection '{self.collection_name}' | dim={self.dim} | HNSW")

        self.client.create_collection(
            collection_name=self.collection_name,
            vectors_config=VectorParams(
                size=self.dim,
                distance=Distance.COSINE,   # cosine similarity (L2 normalized β†’ same as dot product)
            ),
            hnsw_config=HnswConfigDiff(
                m=settings.hnsw_m,
                ef_construct=settings.hnsw_ef_construct,
                full_scan_threshold=10000,   # use exact search below this count
                on_disk=False,               # keep index in RAM for speed
            ),
        )

        logger.info(f"Collection created successfully | HNSW m={settings.hnsw_m}, ef_construct={settings.hnsw_ef_construct}")

    def collection_exists(self) -> bool:
        existing = [c.name for c in self.client.get_collections().collections]
        return self.collection_name in existing

    def get_collection_info(self) -> dict:
        """Get collection stats (useful for debugging)."""
        if not self.collection_exists():
            return {"status": "does not exist", "vectors_count": 0}
        info = self.client.get_collection(self.collection_name)
        # qdrant-client API varies by version β€” handle gracefully
        try:
            # Newer qdrant-client uses points_count
            points = info.points_count or 0
        except AttributeError:
            points = 0
        try:
            vectors = info.vectors_count or points
        except AttributeError:
            vectors = points
        return {
            "vectors_count": vectors,
            "points_count": points,
            "status": str(info.status),
        }

    # ---------------------------------------------------------------- #
    # Upsert
    # ---------------------------------------------------------------- #

    def upsert_chunks(self, chunks: list[Chunk], embeddings: np.ndarray) -> None:
        """
        Store chunks with their embeddings in Qdrant.

        Args:
            chunks: List of Chunk objects
            embeddings: np.ndarray of shape (len(chunks), 768)
        """
        if len(chunks) != len(embeddings):
            raise ValueError(f"Chunk count ({len(chunks)}) != embedding count ({len(embeddings)})")

        points = []
        for i, (chunk, vector) in enumerate(zip(chunks, embeddings)):
            # Qdrant requires a numeric or UUID point ID
            # We use a hash of chunk_id converted to int
            point_id = int(chunk.chunk_id, 16) % (2**63)   # SHA256 hex β†’ int

            payload = {
                "chunk_id": chunk.chunk_id,
                "content": chunk.content,
                "content_type": chunk.content_type,
                "chunk_level": chunk.chunk_level,
                "parent_chunk_id": chunk.parent_chunk_id or "",
                "company": chunk.company,
                "ticker": chunk.ticker,
                "source_file": chunk.source_file,
                "fiscal_year": chunk.fiscal_year,
                "page_number": chunk.page_number,
                "section": chunk.section,
                "subsection": chunk.subsection,
                "token_count": chunk.token_count,
                "char_count": chunk.char_count,
            }

            points.append(PointStruct(
                id=point_id,
                vector=vector.tolist(),
                payload=payload,
            ))

        # Batch upsert in groups of 100 (Qdrant recommended batch size)
        batch_size = 100
        for i in range(0, len(points), batch_size):
            batch = points[i: i + batch_size]
            self.client.upsert(
                collection_name=self.collection_name,
                points=batch,
                wait=True,
            )

        logger.info(f"Upserted {len(points)} chunks into Qdrant")

    # ---------------------------------------------------------------- #
    # Search
    # ---------------------------------------------------------------- #

    def search(
        self,
        query_vector: np.ndarray,
        top_k: int = 30,
        company_filter: Optional[str | list[str]] = None,
        fiscal_year_filter: Optional[str] = None,
        content_type_filter: Optional[str] = None,
        chunk_level_filter: Optional[str] = "child",   # default: search child chunks
    ) -> list[SearchResult]:
        """
        Dense vector similarity search with optional payload filters.

        Args:
            query_vector: L2-normalized query embedding (768-dim)
            top_k: Number of results to return
            company_filter: Filter by company name (str) or list of companies
            fiscal_year_filter: Filter by fiscal year (e.g. "FY2025")
            content_type_filter: Filter by "text", "table", or "ocr_text"
            chunk_level_filter: "child" | "atomic" | "parent" | None (no filter)

        Returns:
            List of SearchResult objects sorted by score descending
        """
        must_conditions = []

        # Company filter
        if company_filter:
            if isinstance(company_filter, list):
                must_conditions.append(FieldCondition(
                    key="company",
                    match=MatchAny(any=company_filter),
                ))
            else:
                must_conditions.append(FieldCondition(
                    key="company",
                    match=MatchValue(value=company_filter),
                ))

        # Fiscal year filter
        if fiscal_year_filter:
            must_conditions.append(FieldCondition(
                key="fiscal_year",
                match=MatchValue(value=fiscal_year_filter),
            ))

        # Content type filter
        if content_type_filter:
            must_conditions.append(FieldCondition(
                key="content_type",
                match=MatchValue(value=content_type_filter),
            ))

        # Chunk level filter (default: child chunks only for retrieval)
        if chunk_level_filter:
            must_conditions.append(FieldCondition(
                key="chunk_level",
                match=MatchValue(value=chunk_level_filter),
            ))

        query_filter = Filter(must=must_conditions) if must_conditions else None

        # qdrant-client >= 1.7: use query_points() instead of deprecated search()
        response = self.client.query_points(
            collection_name=self.collection_name,
            query=query_vector.tolist(),
            limit=top_k,
            query_filter=query_filter,
            score_threshold=settings.score_threshold,
            with_payload=True,
        )
        hits = response.points  # list[ScoredPoint]

        results = []
        for hit in hits:
            p = hit.payload
            results.append(SearchResult(
                chunk_id=p.get("chunk_id", ""),
                content=p.get("content", ""),
                score=hit.score,
                company=p.get("company", ""),
                ticker=p.get("ticker", ""),
                source_file=p.get("source_file", ""),
                fiscal_year=p.get("fiscal_year", ""),
                page_number=p.get("page_number", 0),
                section=p.get("section", ""),
                subsection=p.get("subsection", ""),
                content_type=p.get("content_type", ""),
                chunk_level=p.get("chunk_level", ""),
                parent_chunk_id=p.get("parent_chunk_id") or None,
            ))

        return results

    def search_tables_only(
        self,
        query_vector: np.ndarray,
        top_k: int = 10,
        company_filter: Optional[str | list[str]] = None,
    ) -> list[SearchResult]:
        """Convenience: search only table (atomic) chunks."""
        return self.search(
            query_vector=query_vector,
            top_k=top_k,
            company_filter=company_filter,
            chunk_level_filter="atomic",
        )

    def fetch_parent_chunk(self, parent_chunk_id: str) -> Optional[str]:
        """
        Fetch a parent chunk's content given a parent_chunk_id.
        Used in parent-child retrieval: find child β†’ return parent content to LLM.
        """
        # qdrant-client >= 1.7: scroll() uses query_filter (not scroll_filter)
        try:
            results = self.client.scroll(
                collection_name=self.collection_name,
                scroll_filter=Filter(
                    must=[FieldCondition(
                        key="chunk_id",
                        match=MatchValue(value=parent_chunk_id),
                    )]
                ),
                limit=1,
                with_payload=True,
            )
            points, _ = results
        except TypeError:
            # Newer API uses query_filter instead of scroll_filter
            results = self.client.scroll(
                collection_name=self.collection_name,
                query_filter=Filter(
                    must=[FieldCondition(
                        key="chunk_id",
                        match=MatchValue(value=parent_chunk_id),
                    )]
                ),
                limit=1,
                with_payload=True,
            )
            points, _ = results

        if points:
            return points[0].payload.get("content", "")
        return None