File size: 12,532 Bytes
574758a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
364
365
366
367
368
369
370
371
372
373
"""向量存储 + 混合检索.

ChromaDB v1.0 (Rust core) multi-vector 集合:
- 同一 collection 同时存 dense (1024d) + ColBERT (变长多向量)
- sparse 走旁路 BM25-style (用 BGE-M3 产出的 lexical weights 反序列化)

混合检索 (三路 RRF):
- dense 走 ChromaDB HNSW
- sparse 走反序列化 + dot product
- colbert 走 late interaction max-sim
- 三路结果 RRF 融合
"""
from __future__ import annotations

import json
import logging
import sqlite3
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any

import numpy as np

from app.config import settings
from app.core.paths import chroma_dir, sqlite_dir
from app.models import db

logger = logging.getLogger(__name__)


# ========== 检索结果 ==========
@dataclass
class RetrievalHit:
    chunk_id: str
    doc_id: str
    text: str
    score: float
    page_no: int | None
    heading: str | None
    context_prefix: str | None
    meta: dict[str, Any]
    # 用于 CRAG evaluate
    sparse_score: float = 0.0
    dense_score: float = 0.0
    colbert_score: float = 0.0
    # rerank 后填充
    rerank_score: float = 0.0
    original_rank: int = 0


# ========== Sparse 旁路索引 (SQLite) ==========
class SparseSidecar:
    """存 sparse lexical weights, 用 sqlite 反查 + 打分.

    表 schema: chunk_sparse(chunk_id, weights_json)
    """

    def __init__(self, db_path: Path) -> None:
        self.db_path = db_path
        self._lock = threading.Lock()
        self._ensure_table()

    def _ensure_table(self) -> None:
        with db.transaction() as _:
            db.get_conn().execute(
                """
                CREATE TABLE IF NOT EXISTS chunk_sparse (
                    chunk_id     TEXT PRIMARY KEY,
                    weights_json TEXT NOT NULL
                )
                """
            )

    def upsert_bulk(self, items: list[tuple[str, dict[int, float]]]) -> None:
        if not items:
            return
        with self._lock, db.transaction():
            db.get_conn().executemany(
                "INSERT OR REPLACE INTO chunk_sparse (chunk_id, weights_json) VALUES (?, ?)",
                [(cid, json.dumps({str(k): v for k, v in w.items()})) for cid, w in items],
            )

    def upsert_colbert(self, items: list[tuple[str, np.ndarray]]) -> None:
        """ColBERT 多向量太占地方, 暂存为 .npy 文件, 路径记到 chunk_sparse 旁表."""
        if not items:
            return
        from app.core.paths import data_dir

        colbert_dir = data_dir() / "colbert"
        colbert_dir.mkdir(parents=True, exist_ok=True)
        with self._lock, db.transaction():
            for cid, vec in items:
                path = colbert_dir / f"{cid}.npy"
                np.save(path, vec)
                db.get_conn().execute(
                    "INSERT OR REPLACE INTO chunk_sparse (chunk_id, weights_json) VALUES (?, ?)",
                    (cid, json.dumps({"colbert_path": str(path.relative_to(data_dir()))})),
                )

    def get_sparse(self, chunk_id: str) -> dict[int, float] | None:
        row = db.get_conn().execute(
            "SELECT weights_json FROM chunk_sparse WHERE chunk_id = ? AND weights_json NOT LIKE '%colbert_path%'",
            (chunk_id,),
        ).fetchone()
        if not row:
            return None
        try:
            d = json.loads(row["weights_json"])
            return {int(k): float(v) for k, v in d.items()}
        except (json.JSONDecodeError, ValueError):
            return None

    def get_colbert_path(self, chunk_id: str) -> str | None:
        row = db.get_conn().execute(
            "SELECT weights_json FROM chunk_sparse WHERE chunk_id = ? AND weights_json LIKE '%colbert_path%'",
            (chunk_id,),
        ).fetchone()
        if not row:
            return None
        try:
            d = json.loads(row["weights_json"])
            return d.get("colbert_path")
        except json.JSONDecodeError:
            return None

    def score_sparse(
        self, query_weights: dict[int, float], candidate_ids: list[str]
    ) -> dict[str, float]:
        """对 candidate 计算 sparse 分数 (q·d 内积). 0 表示完全无重叠."""
        out: dict[str, float] = {}
        if not query_weights:
            return out
        for cid in candidate_ids:
            doc_w = self.get_sparse(cid)
            if not doc_w:
                out[cid] = 0.0
                continue
            # 公共 token 上的内积
            s = 0.0
            for tid, qw in query_weights.items():
                dw = doc_w.get(tid)
                if dw is not None:
                    s += qw * dw
            out[cid] = s
        return out

    def delete_by_doc(self, doc_id: str) -> int:
        # 通过 doc chunks 关联删除
        rows = db.get_conn().execute(
            "SELECT id FROM chunks WHERE doc_id = ?", (doc_id,)
        ).fetchall()
        if not rows:
            return 0
        ids = [r["id"] for r in rows]
        cur = db.get_conn().execute(
            f"DELETE FROM chunk_sparse WHERE chunk_id IN ({','.join('?' * len(ids))})",
            ids,
        )
        return cur.rowcount


# ========== ChromaDB 客户端 ==========
_chroma_client = None
_chroma_collection = None
_sparse_sidecar: SparseSidecar | None = None


def get_chroma():
    global _chroma_client, _chroma_collection, _sparse_sidecar
    if _chroma_client is None:
        import chromadb
        from chromadb.config import Settings as ChromaSettings

        chroma_dir()
        _chroma_client = chromadb.PersistentClient(
            path=str(settings.chroma_dir),
            settings=ChromaSettings(anonymized_telemetry=False, allow_reset=False),
        )
        # ChromaDB v1.0+ 支持 multi-vector; 不指定 embedding_function (我们自己 embed)
        _chroma_collection = _chroma_client.get_or_create_collection(
            name=settings.chroma_collection,
            metadata={"hnsw:space": "cosine"},
            embedding_function=None,
        )
        # 旁路 sparse 索引
        _sparse_sidecar = SparseSidecar(settings.sqlite_db_path)
        logger.info(
            "ChromaDB ready: dir=%s collection=%s",
            settings.chroma_dir, settings.chroma_collection,
        )
    return _chroma_client, _chroma_collection, _sparse_sidecar


# ========== Upsert ==========
def upsert_chunks(
    *,
    ids: list[str],
    embeddings: np.ndarray,             # (N, 1024) dense
    documents: list[str],               # 文本
    metadatas: list[dict[str, Any]],
    sparse_weights: list[dict[int, float]] | None = None,
    colbert_vecs: list[np.ndarray] | None = None,
) -> None:
    """写入 ChromaDB + 旁路 sparse/colbert."""
    if not ids:
        return
    _, coll, sidecar = get_chroma()
    coll.upsert(
        ids=ids,
        embeddings=embeddings.tolist(),
        documents=documents,
        metadatas=metadatas,
    )
    if sparse_weights:
        sidecar.upsert_bulk(list(zip(ids, sparse_weights)))
    if colbert_vecs and settings.enable_colbert:
        sidecar.upsert_colbert(list(zip(ids, colbert_vecs)))


# ========== Query ==========
def query_dense(
    query_emb: np.ndarray, k: int = 20, where: dict | None = None
) -> list[tuple[str, float, dict]]:
    _, coll, _ = get_chroma()
    res = coll.query(
        query_embeddings=[query_emb.tolist()],
        n_results=k,
        where=where,
        include=["metadatas", "distances", "documents"],
    )
    if not res["ids"]:
        return []
    out: list[tuple[str, float, dict]] = []
    for i, cid in enumerate(res["ids"][0]):
        # cosine distance -> 转为 similarity
        dist = res["distances"][0][i] if res["distances"] else 0.0
        sim = 1.0 - dist
        out.append((cid, sim, {
            "text": res["documents"][0][i] if res["documents"] else "",
            "meta": res["metadatas"][0][i] if res["metadatas"] else {},
        }))
    return out


def rrf_fuse(
    *ranked_lists: list[tuple[str, float, dict]],
    k: int = 60,
) -> list[tuple[str, float, dict]]:
    """Reciprocal Rank Fusion.

    每个 list 是 [(id, score, payload), ...], 排名越靠前 (index 0) 权重越高.
    score = sum 1/(k + rank_i)
    """
    scores: dict[str, float] = {}
    payloads: dict[str, dict] = {}
    for ranked in ranked_lists:
        for rank, (cid, _score, payload) in enumerate(ranked):
            scores[cid] = scores.get(cid, 0.0) + 1.0 / (k + rank + 1)
            if cid not in payloads:
                payloads[cid] = payload
            elif payload and payload.get("text"):
                payloads[cid] = payload
    out = sorted(scores.items(), key=lambda x: -x[1])
    return [(cid, sc, payloads[cid]) for cid, sc in out]


def hybrid_query(
    *,
    query_emb: np.ndarray,
    query_sparse: dict[int, float] | None = None,
    query_colbert_emb: np.ndarray | None = None,
    k: int = 20,
    where: dict | None = None,
    over_retrieve: int = 50,
) -> list[RetrievalHit]:
    """三路混合检索 + RRF.

    Args:
        query_emb: dense 向量 (1024d)
        query_sparse: 稀疏权重
        query_colbert_emb: (T, 1024) 多向量
        k: 最终返回 top-k
        over_retrieve: 每路多取一些再融合
    """
    started = time.time()

    # 路 1: dense (ChromaDB HNSW)
    dense = query_dense(query_emb, k=over_retrieve, where=where)

    # 路 2: sparse (旁路 + 反查)
    sparse: list[tuple[str, float, dict]] = []
    if query_sparse:
        _, _, sidecar = get_chroma()
        cand_ids = [cid for cid, _, _ in dense]  # dense top-N 作为候选, 避免全表
        sparse_scores = sidecar.score_sparse(query_sparse, cand_ids)
        # 按 sparse score 排序
        sparse = sorted(
            [
                (cid, sparse_scores.get(cid, 0.0), {"text": "", "meta": {}})
                for cid in cand_ids
            ],
            key=lambda x: -x[1],
        )

    # 路 3: colbert (late interaction)
    colbert_ranked: list[tuple[str, float, dict]] = []
    if settings.enable_colbert and query_colbert_emb is not None and len(query_colbert_emb) > 0:
        from app.core.paths import data_dir
        # 只对 dense top-N 计算 colbert
        _, _, sidecar = get_chroma()
        cand_ids = [cid for cid, _, _ in dense[:30]]
        scored: list[tuple[str, float]] = []
        for cid in cand_ids:
            rel_path = sidecar.get_colbert_path(cid)
            if not rel_path:
                continue
            full = data_dir() / rel_path
            if not full.exists():
                continue
            try:
                doc_vec = np.load(full)
            except Exception:  # noqa: BLE001
                continue
            # max-sim
            sims = doc_vec @ query_colbert_emb.T  # (T_doc, T_q)
            if sims.size == 0:
                continue
            max_per_doc = sims.max(axis=0).mean()  # mean of per-query-token max
            scored.append((cid, float(max_per_doc)))
        colbert_ranked = sorted(scored, key=lambda x: -x[1])
        colbert_ranked = [(cid, s, {"text": "", "meta": {}}) for cid, s in colbert_ranked]

    # RRF 融合
    fused = rrf_fuse(dense, sparse, colbert_ranked, k=60)[:k]

    # 构造 RetrievalHit
    hits: list[RetrievalHit] = []
    for cid, rrf_score, payload in fused:
        meta = payload.get("meta", {})
        hits.append(RetrievalHit(
            chunk_id=cid,
            doc_id=meta.get("doc_id", ""),
            text=payload.get("text", ""),
            score=rrf_score,
            page_no=meta.get("page_no"),
            heading=meta.get("heading"),
            context_prefix=meta.get("context_prefix"),
            meta=meta,
        ))

    logger.debug("hybrid_query returned %d hits in %dms", len(hits), int((time.time() - started) * 1000))
    return hits


# ========== Delete ==========
def delete_by_doc(doc_id: str) -> int:
    """从 ChromaDB + sparse 旁路一并删除."""
    _, coll, sidecar = get_chroma()
    # 先列 id (ChromaDB v1.0 用 where 过滤删除)
    try:
        coll.delete(where={"doc_id": doc_id})
    except Exception as e:  # noqa: BLE001
        logger.warning("ChromaDB delete by where failed (%s), falling back to per-chunk", e)
        # fallback: 列出来删
        rows = db.get_conn().execute("SELECT id FROM chunks WHERE doc_id = ?", (doc_id,)).fetchall()
        ids = [r["id"] for r in rows]
        if ids:
            coll.delete(ids=ids)
    n = sidecar.delete_by_doc(doc_id)
    return n