appQQQ commited on
Commit
574758a
·
verified ·
1 Parent(s): ead8137

chore: upload app/services/vector_store.py

Browse files
Files changed (1) hide show
  1. app/services/vector_store.py +372 -0
app/services/vector_store.py ADDED
@@ -0,0 +1,372 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """向量存储 + 混合检索.
2
+
3
+ ChromaDB v1.0 (Rust core) multi-vector 集合:
4
+ - 同一 collection 同时存 dense (1024d) + ColBERT (变长多向量)
5
+ - sparse 走旁路 BM25-style (用 BGE-M3 产出的 lexical weights 反序列化)
6
+
7
+ 混合检索 (三路 RRF):
8
+ - dense 走 ChromaDB HNSW
9
+ - sparse 走反序列化 + dot product
10
+ - colbert 走 late interaction max-sim
11
+ - 三路结果 RRF 融合
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import logging
17
+ import sqlite3
18
+ import threading
19
+ import time
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ import numpy as np
25
+
26
+ from app.config import settings
27
+ from app.core.paths import chroma_dir, sqlite_dir
28
+ from app.models import db
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ # ========== 检索结果 ==========
34
+ @dataclass
35
+ class RetrievalHit:
36
+ chunk_id: str
37
+ doc_id: str
38
+ text: str
39
+ score: float
40
+ page_no: int | None
41
+ heading: str | None
42
+ context_prefix: str | None
43
+ meta: dict[str, Any]
44
+ # 用于 CRAG evaluate
45
+ sparse_score: float = 0.0
46
+ dense_score: float = 0.0
47
+ colbert_score: float = 0.0
48
+ # rerank 后填充
49
+ rerank_score: float = 0.0
50
+ original_rank: int = 0
51
+
52
+
53
+ # ========== Sparse 旁路索引 (SQLite) ==========
54
+ class SparseSidecar:
55
+ """存 sparse lexical weights, 用 sqlite 反查 + 打分.
56
+
57
+ 表 schema: chunk_sparse(chunk_id, weights_json)
58
+ """
59
+
60
+ def __init__(self, db_path: Path) -> None:
61
+ self.db_path = db_path
62
+ self._lock = threading.Lock()
63
+ self._ensure_table()
64
+
65
+ def _ensure_table(self) -> None:
66
+ with db.transaction() as _:
67
+ db.get_conn().execute(
68
+ """
69
+ CREATE TABLE IF NOT EXISTS chunk_sparse (
70
+ chunk_id TEXT PRIMARY KEY,
71
+ weights_json TEXT NOT NULL
72
+ )
73
+ """
74
+ )
75
+
76
+ def upsert_bulk(self, items: list[tuple[str, dict[int, float]]]) -> None:
77
+ if not items:
78
+ return
79
+ with self._lock, db.transaction():
80
+ db.get_conn().executemany(
81
+ "INSERT OR REPLACE INTO chunk_sparse (chunk_id, weights_json) VALUES (?, ?)",
82
+ [(cid, json.dumps({str(k): v for k, v in w.items()})) for cid, w in items],
83
+ )
84
+
85
+ def upsert_colbert(self, items: list[tuple[str, np.ndarray]]) -> None:
86
+ """ColBERT 多向量太占地方, 暂存为 .npy 文件, 路径记到 chunk_sparse 旁表."""
87
+ if not items:
88
+ return
89
+ from app.core.paths import data_dir
90
+
91
+ colbert_dir = data_dir() / "colbert"
92
+ colbert_dir.mkdir(parents=True, exist_ok=True)
93
+ with self._lock, db.transaction():
94
+ for cid, vec in items:
95
+ path = colbert_dir / f"{cid}.npy"
96
+ np.save(path, vec)
97
+ db.get_conn().execute(
98
+ "INSERT OR REPLACE INTO chunk_sparse (chunk_id, weights_json) VALUES (?, ?)",
99
+ (cid, json.dumps({"colbert_path": str(path.relative_to(data_dir()))})),
100
+ )
101
+
102
+ def get_sparse(self, chunk_id: str) -> dict[int, float] | None:
103
+ row = db.get_conn().execute(
104
+ "SELECT weights_json FROM chunk_sparse WHERE chunk_id = ? AND weights_json NOT LIKE '%colbert_path%'",
105
+ (chunk_id,),
106
+ ).fetchone()
107
+ if not row:
108
+ return None
109
+ try:
110
+ d = json.loads(row["weights_json"])
111
+ return {int(k): float(v) for k, v in d.items()}
112
+ except (json.JSONDecodeError, ValueError):
113
+ return None
114
+
115
+ def get_colbert_path(self, chunk_id: str) -> str | None:
116
+ row = db.get_conn().execute(
117
+ "SELECT weights_json FROM chunk_sparse WHERE chunk_id = ? AND weights_json LIKE '%colbert_path%'",
118
+ (chunk_id,),
119
+ ).fetchone()
120
+ if not row:
121
+ return None
122
+ try:
123
+ d = json.loads(row["weights_json"])
124
+ return d.get("colbert_path")
125
+ except json.JSONDecodeError:
126
+ return None
127
+
128
+ def score_sparse(
129
+ self, query_weights: dict[int, float], candidate_ids: list[str]
130
+ ) -> dict[str, float]:
131
+ """对 candidate 计算 sparse 分数 (q·d 内积). 0 表示完全无重叠."""
132
+ out: dict[str, float] = {}
133
+ if not query_weights:
134
+ return out
135
+ for cid in candidate_ids:
136
+ doc_w = self.get_sparse(cid)
137
+ if not doc_w:
138
+ out[cid] = 0.0
139
+ continue
140
+ # 公共 token 上的内积
141
+ s = 0.0
142
+ for tid, qw in query_weights.items():
143
+ dw = doc_w.get(tid)
144
+ if dw is not None:
145
+ s += qw * dw
146
+ out[cid] = s
147
+ return out
148
+
149
+ def delete_by_doc(self, doc_id: str) -> int:
150
+ # 通过 doc chunks 关联删除
151
+ rows = db.get_conn().execute(
152
+ "SELECT id FROM chunks WHERE doc_id = ?", (doc_id,)
153
+ ).fetchall()
154
+ if not rows:
155
+ return 0
156
+ ids = [r["id"] for r in rows]
157
+ cur = db.get_conn().execute(
158
+ f"DELETE FROM chunk_sparse WHERE chunk_id IN ({','.join('?' * len(ids))})",
159
+ ids,
160
+ )
161
+ return cur.rowcount
162
+
163
+
164
+ # ========== ChromaDB 客户端 ==========
165
+ _chroma_client = None
166
+ _chroma_collection = None
167
+ _sparse_sidecar: SparseSidecar | None = None
168
+
169
+
170
+ def get_chroma():
171
+ global _chroma_client, _chroma_collection, _sparse_sidecar
172
+ if _chroma_client is None:
173
+ import chromadb
174
+ from chromadb.config import Settings as ChromaSettings
175
+
176
+ chroma_dir()
177
+ _chroma_client = chromadb.PersistentClient(
178
+ path=str(settings.chroma_dir),
179
+ settings=ChromaSettings(anonymized_telemetry=False, allow_reset=False),
180
+ )
181
+ # ChromaDB v1.0+ 支持 multi-vector; 不指定 embedding_function (我们自己 embed)
182
+ _chroma_collection = _chroma_client.get_or_create_collection(
183
+ name=settings.chroma_collection,
184
+ metadata={"hnsw:space": "cosine"},
185
+ embedding_function=None,
186
+ )
187
+ # 旁路 sparse 索引
188
+ _sparse_sidecar = SparseSidecar(settings.sqlite_db_path)
189
+ logger.info(
190
+ "ChromaDB ready: dir=%s collection=%s",
191
+ settings.chroma_dir, settings.chroma_collection,
192
+ )
193
+ return _chroma_client, _chroma_collection, _sparse_sidecar
194
+
195
+
196
+ # ========== Upsert ==========
197
+ def upsert_chunks(
198
+ *,
199
+ ids: list[str],
200
+ embeddings: np.ndarray, # (N, 1024) dense
201
+ documents: list[str], # 文本
202
+ metadatas: list[dict[str, Any]],
203
+ sparse_weights: list[dict[int, float]] | None = None,
204
+ colbert_vecs: list[np.ndarray] | None = None,
205
+ ) -> None:
206
+ """写入 ChromaDB + 旁路 sparse/colbert."""
207
+ if not ids:
208
+ return
209
+ _, coll, sidecar = get_chroma()
210
+ coll.upsert(
211
+ ids=ids,
212
+ embeddings=embeddings.tolist(),
213
+ documents=documents,
214
+ metadatas=metadatas,
215
+ )
216
+ if sparse_weights:
217
+ sidecar.upsert_bulk(list(zip(ids, sparse_weights)))
218
+ if colbert_vecs and settings.enable_colbert:
219
+ sidecar.upsert_colbert(list(zip(ids, colbert_vecs)))
220
+
221
+
222
+ # ========== Query ==========
223
+ def query_dense(
224
+ query_emb: np.ndarray, k: int = 20, where: dict | None = None
225
+ ) -> list[tuple[str, float, dict]]:
226
+ _, coll, _ = get_chroma()
227
+ res = coll.query(
228
+ query_embeddings=[query_emb.tolist()],
229
+ n_results=k,
230
+ where=where,
231
+ include=["metadatas", "distances", "documents"],
232
+ )
233
+ if not res["ids"]:
234
+ return []
235
+ out: list[tuple[str, float, dict]] = []
236
+ for i, cid in enumerate(res["ids"][0]):
237
+ # cosine distance -> 转为 similarity
238
+ dist = res["distances"][0][i] if res["distances"] else 0.0
239
+ sim = 1.0 - dist
240
+ out.append((cid, sim, {
241
+ "text": res["documents"][0][i] if res["documents"] else "",
242
+ "meta": res["metadatas"][0][i] if res["metadatas"] else {},
243
+ }))
244
+ return out
245
+
246
+
247
+ def rrf_fuse(
248
+ *ranked_lists: list[tuple[str, float, dict]],
249
+ k: int = 60,
250
+ ) -> list[tuple[str, float, dict]]:
251
+ """Reciprocal Rank Fusion.
252
+
253
+ 每个 list 是 [(id, score, payload), ...], 排名越靠前 (index 0) 权重越高.
254
+ score = sum 1/(k + rank_i)
255
+ """
256
+ scores: dict[str, float] = {}
257
+ payloads: dict[str, dict] = {}
258
+ for ranked in ranked_lists:
259
+ for rank, (cid, _score, payload) in enumerate(ranked):
260
+ scores[cid] = scores.get(cid, 0.0) + 1.0 / (k + rank + 1)
261
+ if cid not in payloads:
262
+ payloads[cid] = payload
263
+ elif payload and payload.get("text"):
264
+ payloads[cid] = payload
265
+ out = sorted(scores.items(), key=lambda x: -x[1])
266
+ return [(cid, sc, payloads[cid]) for cid, sc in out]
267
+
268
+
269
+ def hybrid_query(
270
+ *,
271
+ query_emb: np.ndarray,
272
+ query_sparse: dict[int, float] | None = None,
273
+ query_colbert_emb: np.ndarray | None = None,
274
+ k: int = 20,
275
+ where: dict | None = None,
276
+ over_retrieve: int = 50,
277
+ ) -> list[RetrievalHit]:
278
+ """三路混合检索 + RRF.
279
+
280
+ Args:
281
+ query_emb: dense 向量 (1024d)
282
+ query_sparse: 稀疏权重
283
+ query_colbert_emb: (T, 1024) 多向量
284
+ k: 最终返回 top-k
285
+ over_retrieve: 每路多取一些再融合
286
+ """
287
+ started = time.time()
288
+
289
+ # 路 1: dense (ChromaDB HNSW)
290
+ dense = query_dense(query_emb, k=over_retrieve, where=where)
291
+
292
+ # 路 2: sparse (旁路 + 反查)
293
+ sparse: list[tuple[str, float, dict]] = []
294
+ if query_sparse:
295
+ _, _, sidecar = get_chroma()
296
+ cand_ids = [cid for cid, _, _ in dense] # dense top-N 作为候选, 避免全表
297
+ sparse_scores = sidecar.score_sparse(query_sparse, cand_ids)
298
+ # 按 sparse score 排序
299
+ sparse = sorted(
300
+ [
301
+ (cid, sparse_scores.get(cid, 0.0), {"text": "", "meta": {}})
302
+ for cid in cand_ids
303
+ ],
304
+ key=lambda x: -x[1],
305
+ )
306
+
307
+ # 路 3: colbert (late interaction)
308
+ colbert_ranked: list[tuple[str, float, dict]] = []
309
+ if settings.enable_colbert and query_colbert_emb is not None and len(query_colbert_emb) > 0:
310
+ from app.core.paths import data_dir
311
+ # 只对 dense top-N 计算 colbert
312
+ _, _, sidecar = get_chroma()
313
+ cand_ids = [cid for cid, _, _ in dense[:30]]
314
+ scored: list[tuple[str, float]] = []
315
+ for cid in cand_ids:
316
+ rel_path = sidecar.get_colbert_path(cid)
317
+ if not rel_path:
318
+ continue
319
+ full = data_dir() / rel_path
320
+ if not full.exists():
321
+ continue
322
+ try:
323
+ doc_vec = np.load(full)
324
+ except Exception: # noqa: BLE001
325
+ continue
326
+ # max-sim
327
+ sims = doc_vec @ query_colbert_emb.T # (T_doc, T_q)
328
+ if sims.size == 0:
329
+ continue
330
+ max_per_doc = sims.max(axis=0).mean() # mean of per-query-token max
331
+ scored.append((cid, float(max_per_doc)))
332
+ colbert_ranked = sorted(scored, key=lambda x: -x[1])
333
+ colbert_ranked = [(cid, s, {"text": "", "meta": {}}) for cid, s in colbert_ranked]
334
+
335
+ # RRF 融合
336
+ fused = rrf_fuse(dense, sparse, colbert_ranked, k=60)[:k]
337
+
338
+ # 构造 RetrievalHit
339
+ hits: list[RetrievalHit] = []
340
+ for cid, rrf_score, payload in fused:
341
+ meta = payload.get("meta", {})
342
+ hits.append(RetrievalHit(
343
+ chunk_id=cid,
344
+ doc_id=meta.get("doc_id", ""),
345
+ text=payload.get("text", ""),
346
+ score=rrf_score,
347
+ page_no=meta.get("page_no"),
348
+ heading=meta.get("heading"),
349
+ context_prefix=meta.get("context_prefix"),
350
+ meta=meta,
351
+ ))
352
+
353
+ logger.debug("hybrid_query returned %d hits in %dms", len(hits), int((time.time() - started) * 1000))
354
+ return hits
355
+
356
+
357
+ # ========== Delete ==========
358
+ def delete_by_doc(doc_id: str) -> int:
359
+ """从 ChromaDB + sparse 旁路一并删除."""
360
+ _, coll, sidecar = get_chroma()
361
+ # 先列 id (ChromaDB v1.0 用 where 过滤删除)
362
+ try:
363
+ coll.delete(where={"doc_id": doc_id})
364
+ except Exception as e: # noqa: BLE001
365
+ logger.warning("ChromaDB delete by where failed (%s), falling back to per-chunk", e)
366
+ # fallback: 列出来删
367
+ rows = db.get_conn().execute("SELECT id FROM chunks WHERE doc_id = ?", (doc_id,)).fetchall()
368
+ ids = [r["id"] for r in rows]
369
+ if ids:
370
+ coll.delete(ids=ids)
371
+ n = sidecar.delete_by_doc(doc_id)
372
+ return n