File size: 4,755 Bytes
61c746f
52999bc
61c746f
 
 
 
7124acf
 
 
 
 
 
6bff5d9
 
 
c93ec90
 
8802920
 
52999bc
8802920
 
52999bc
6bff5d9
52999bc
 
 
8802920
61c746f
 
8802920
 
61c746f
8802920
61c746f
 
 
 
 
 
 
 
 
 
7124acf
 
61c746f
 
 
8802920
 
 
 
 
 
 
 
 
 
 
7124acf
 
8802920
 
 
52999bc
 
61c746f
 
 
 
 
 
 
 
 
52999bc
61c746f
52999bc
7124acf
 
 
 
 
8802920
7124acf
 
 
 
 
6bff5d9
c93ec90
 
8802920
61c746f
 
 
 
 
8802920
 
61c746f
8802920
 
 
7124acf
8802920
 
 
 
61c746f
 
8802920
 
 
 
 
 
 
 
 
 
 
 
 
61c746f
8802920
52999bc
 
 
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
"""DocumentRetriever — dense similarity over prose chunks.

For unstructured sources only (PDF / DOCX / TXT). Backed by PGVector via
raw SQL to avoid LangChain ORM / asyncpg type-mapping issues (id UUID vs
String mismatch, jsonb_path_match asyncpg binding quirks).
Collection `document_embeddings`. Methods: cosine | manhattan.

Scoped and v2-only since 2026-09-23 (UNSTRUCTURED_V2_PLAN U5, U-D3 / U-D6):
only rows written by the v2 ingestion (`parser_version = 'v2'`) of documents
bound to the caller's analysis are searched. Both filters run IN SQL, before the
top-k — filtering an already-top-k'd result set would return too few rows, or
none, whenever unbound documents rank higher.
"""

import functools
import math

from langchain_openai import AzureOpenAIEmbeddings
from sqlalchemy import text

from src.config.settings import settings
from src.db.postgres.connection import _pgvector_engine
from src.middlewares.logging import get_logger
from src.retrieval.base import BaseRetriever, RetrievalResult

logger = get_logger("document_retriever")

# Change this one line to switch retrieval method
# Options: "cosine" | "manhattan"
_RETRIEVAL_METHOD = "cosine"

_TABULAR_TYPES = {"csv", "xlsx"}
_COLLECTION_NAME = "documents"

_COSINE_SQL = text("""
    SELECT
        lpe.document,
        lpe.cmetadata,
        lpe.embedding <=> CAST(:embedding AS vector) AS distance
    FROM langchain_pg_embedding lpe
    JOIN langchain_pg_collection lpc ON lpe.collection_id = lpc.uuid
    WHERE lpc.name = :collection
      AND lpe.cmetadata->>'user_id' = :user_id
      AND lpe.cmetadata->>'source_type' = 'document'
      AND lpe.cmetadata->>'parser_version' = 'v2'
      AND lpe.cmetadata->'data'->>'document_id' = ANY(:document_ids)
    ORDER BY distance ASC
    LIMIT :k
""")

_MANHATTAN_SQL = text("""
    SELECT
        lpe.document,
        lpe.cmetadata,
        lpe.embedding <+> CAST(:embedding AS vector) AS distance
    FROM langchain_pg_embedding lpe
    JOIN langchain_pg_collection lpc ON lpe.collection_id = lpc.uuid
    WHERE lpc.name = :collection
      AND lpe.cmetadata->>'user_id' = :user_id
      AND lpe.cmetadata->>'source_type' = 'document'
      AND lpe.cmetadata->>'parser_version' = 'v2'
      AND lpe.cmetadata->'data'->>'document_id' = ANY(:document_ids)
    ORDER BY distance ASC
    LIMIT :k
""")


@functools.cache
def _get_embeddings() -> AzureOpenAIEmbeddings:
    return AzureOpenAIEmbeddings(
        azure_deployment=settings.azureai_deployment_name_embedding,
        openai_api_version=settings.azureai_api_version_embedding,
        azure_endpoint=settings.azureai_endpoint_url_embedding,
        api_key=settings.azureai_api_key_embedding,
    )


class DocumentRetriever(BaseRetriever):
    async def retrieve(
        self,
        query: str,
        user_id: str,
        k: int = 5,
        document_ids: list[str] | None = None,
    ) -> list[RetrievalResult]:
        # No scope → nothing to search (U-D3). `None` is refused rather than
        # read as "everything": an unscoped caller is a bug, not a request.
        if not document_ids:
            logger.info("retrieve skipped — no documents in scope", user_id=user_id)
            return []
        query_vector = await _get_embeddings().aembed_query(query)
        if not all(math.isfinite(v) for v in query_vector):
            raise ValueError("Embedding vector contains NaN or Infinity values.")
        vector_str = "[" + ",".join(str(v) for v in query_vector) + "]"
        fetch_k = k + len(_TABULAR_TYPES)

        sql = _COSINE_SQL if _RETRIEVAL_METHOD == "cosine" else _MANHATTAN_SQL

        logger.info("retrieve called", user_id=user_id, collection=_COLLECTION_NAME, fetch_k=fetch_k)

        async with _pgvector_engine.connect() as conn:
            result = await conn.execute(sql, {
                "embedding": vector_str,
                "collection": _COLLECTION_NAME,
                "user_id": user_id,
                "document_ids": list(document_ids),
                "k": fetch_k,
            })
            rows = result.fetchall()

        logger.info("raw rows from db", row_count=len(rows))

        results = []
        for row in rows:
            file_type = row.cmetadata.get("data", {}).get("file_type", "")
            if file_type not in _TABULAR_TYPES:
                results.append(RetrievalResult(
                    content=row.document,
                    metadata=row.cmetadata,
                    score=float(row.distance),
                    source_type="document",
                ))
            if len(results) == k:
                break

        logger.info("retrieved chunks", method=_RETRIEVAL_METHOD, count=len(results))
        return results


document_retriever = DocumentRetriever()