Coverage for src/local_deep_research/vector_stores/facade.py: 93%

162 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +0000

1"""High-level semantic-index facade: ``index`` / ``search`` / ``delete``. 

2 

3This is the single simple entry point to the vector subsystem. It is the ONE 

4place in the ``vector_stores`` package that bridges three things: 

5 

6 * the embedding model (a LangChain ``Embeddings``), 

7 * the encrypted DB (``DocumentChunk`` — the authoritative home of chunk text 

8 and metadata), and 

9 * a swappable :class:`BaseVectorStore` (ids + vectors only). 

10 

11``base.py`` and ``implementations/`` stay DB-agnostic; only this module imports 

12``DocumentChunk``. The vector store holds an int64 id per chunk 

13(``DocumentChunk.id``); text/metadata are rehydrated from the DB by that id on 

14search — the store never receives text (see the "SECURITY INVARIANT" in 

15:mod:`.base`). 

16 

17One row = one vector. Identical text in two documents produces two rows/vectors 

18(no cross-document sharing); this keeps provenance correct and deletes trivial. 

19Exact-duplicate *files* are an ingestion-time concern, handled upstream. 

20""" 

21 

22import hashlib 

23import uuid 

24from contextlib import contextmanager 

25from dataclasses import dataclass, field 

26from pathlib import Path 

27from typing import ( 

28 Iterator, 

29 List, 

30 Optional, 

31 Sequence, 

32 Tuple, 

33 TypedDict, 

34 cast, 

35) 

36 

37import numpy as np 

38from langchain_core.embeddings import Embeddings 

39from loguru import logger 

40from sqlalchemy.orm import Session 

41 

42from ..database.models.library import DocumentChunk 

43from ..database.session_context import get_user_db_session 

44from .base import IntegrityRecord, IntegrityVerify, WriteLock 

45from .config import get_vector_store_class 

46 

47 

48class _BindKwargs(TypedDict): 

49 """Keyword arguments forwarded to ``store_cls.create()``/``.load()``. 

50 

51 A plain ``dict`` would infer a uniform (``object``) value type, erasing 

52 each field's real type when unpacked via ``**bind`` — a ``TypedDict`` 

53 keeps each key's type through the ``**`` unpacking so mypy can check the 

54 call against ``BaseVectorStore.create``/``.load``. 

55 """ 

56 

57 dimension: int 

58 index_type: str 

59 metric: str 

60 normalize: bool 

61 path: Path 

62 lock: WriteLock 

63 integrity_record: IntegrityRecord 

64 integrity_verify: IntegrityVerify 

65 

66 

67@dataclass 

68class ChunkInput: 

69 """One chunk to index: its text plus optional positional/title metadata. 

70 

71 Kept backend- and framework-neutral (not a LangChain ``Document``) so the 

72 facade never couples to a specific splitter/vector library. 

73 """ 

74 

75 text: str 

76 metadata: dict = field(default_factory=dict) 

77 

78 

79@dataclass 

80class SearchResult: 

81 """A search hit, rehydrated from the encrypted DB by chunk id.""" 

82 

83 chunk_id: int 

84 text: str 

85 # Raw backend score; the relevance formula is the caller's choice. ``metric`` 

86 # says how to read it: for cosine/dot_product (inner product) HIGHER is more 

87 # similar; for l2 LOWER is closer. Branch on it or scores invert. 

88 distance: float 

89 metric: str 

90 metadata: dict 

91 document_title: Optional[str] 

92 source_id: Optional[str] 

93 source_type: Optional[str] 

94 

95 

96@dataclass 

97class IndexStats: 

98 """Result of :meth:`VectorIndex.index`.""" 

99 

100 chunks: int 

101 added: int 

102 removed: int 

103 

104 

105@dataclass 

106class DeleteStats: 

107 """Result of :meth:`VectorIndex.delete`.""" 

108 

109 removed: int 

110 rows_deleted: int 

111 

112 

113class VectorIndex: 

114 """Per-collection semantic index. Construct once per (collection, user). 

115 

116 Persistence resources (``path`` / ``lock`` / integrity hooks) are injected — 

117 the caller (service layer) owns per-user path resolution and the write-lock 

118 registry; this facade just wires them into the store. 

119 """ 

120 

121 def __init__( 

122 self, 

123 *, 

124 username: str, 

125 db_password: str, 

126 embeddings: Embeddings, 

127 embedding_model: str, 

128 embedding_model_type, # EmbeddingProvider enum (DocumentChunk column) 

129 collection_name: str, 

130 dimension: int, 

131 path: Path, 

132 lock: WriteLock, 

133 integrity_record: IntegrityRecord, 

134 integrity_verify: IntegrityVerify, 

135 index_type: str = "flat", 

136 metric: str = "cosine", 

137 normalize: bool = True, 

138 provider: str = "faiss", 

139 ) -> None: 

140 self.username = username 

141 self.db_password = db_password # gitleaks:allow 

142 self.embeddings = embeddings 

143 self.embedding_model = embedding_model 

144 self.embedding_model_type = embedding_model_type 

145 self.collection_name = collection_name 

146 # Canonicalize the metric so the physical index BUILD (faiss_store: 

147 # inner-product iff metric in ("cosine","dot_product")) and the search 

148 # engines' relevance formula (which branch on ``metric == "l2"``) agree. 

149 # A non-canonical value like "L2"/"Cosine"/" cosine " would otherwise 

150 # build one index type but be scored as the other, silently corrupting 

151 # relevance. Both the store (via ``bind`` below) and SearchResult.metric 

152 # then see the same canonical string. 

153 metric = (metric or "cosine").strip().lower() 

154 self.metric = metric 

155 

156 store_cls = get_vector_store_class(provider) 

157 path = Path(path) 

158 bind: _BindKwargs = { 

159 "dimension": dimension, 

160 "index_type": index_type, 

161 "metric": metric, 

162 "normalize": normalize, 

163 "path": path, 

164 "lock": lock, 

165 "integrity_record": integrity_record, 

166 "integrity_verify": integrity_verify, 

167 } 

168 # ``bind`` already carries ``path``; pass it only via kwargs (load()'s 

169 # first param is path) so we don't hand ``path`` twice. 

170 self._store = ( 

171 store_cls.load(**bind) 

172 if path.exists() 

173 else store_cls.create(**bind) 

174 ) 

175 

176 @contextmanager 

177 def _db(self, session: Optional[Session]) -> Iterator[Tuple[Session, bool]]: 

178 """Yield ``(session, owns)``. 

179 

180 ``get_user_db_session`` returns a THREAD-LOCAL, reused session, so this 

181 facade must not blindly ``commit()``/``rollback()`` — it would settle a 

182 caller's unrelated pending work. When the caller passes its own 

183 ``session``, we use it and yield ``owns=False`` (the caller owns the 

184 transaction boundary and the apply()-then-commit durability gap). Only 

185 when no session is passed do we open one and own commit/rollback. 

186 """ 

187 if session is not None: 

188 yield session, False 

189 else: 

190 with get_user_db_session(self.username, self.db_password) as s: 

191 yield s, True 

192 

193 def index_prepared( 

194 self, 

195 *, 

196 source_type: str, 

197 source_id: Optional[str], 

198 chunks: Sequence[ChunkInput], 

199 vectors: Optional[np.ndarray], 

200 replace: bool = True, 

201 session: Optional[Session] = None, 

202 ) -> IndexStats: 

203 """Persist pre-embedded chunks without invoking the embedding backend. 

204 

205 Bulk indexing uses this after parallel preparation. Database row/id 

206 allocation, vector application, prior-row removal, and commit semantics 

207 remain identical to :meth:`index`; only embedding is moved outside the 

208 collection writer critical section. 

209 """ 

210 chunks = list(chunks) 

211 if not chunks and (not replace or source_id is None): 211 ↛ 212line 211 didn't jump to line 212 because the condition on line 211 was never true

212 return IndexStats(chunks=0, added=0, removed=0) 

213 if chunks: 

214 if vectors is None: 

215 raise ValueError("chunks given without vectors") 

216 vectors = np.asarray(vectors, dtype="float32") 

217 if len(vectors) != len(chunks): 

218 raise ValueError("vector count must match chunk count") 

219 

220 sid = str(source_id) if source_id is not None else None 

221 with self._db(session) as (sess, owns): 

222 prior_ids: List[int] = [] 

223 if replace and sid is not None: 223 ↛ 235line 223 didn't jump to line 235 because the condition on line 223 was always true

224 prior_ids = [ 

225 row.id 

226 for row in sess.query(DocumentChunk.id).filter_by( 

227 source_type=source_type, 

228 source_id=sid, 

229 collection_name=self.collection_name, 

230 embedding_model=self.embedding_model, 

231 embedding_model_type=self.embedding_model_type, 

232 ) 

233 ] 

234 

235 rows = [self._build_row(c, source_type, sid) for c in chunks] 

236 if rows: 

237 sess.add_all(rows) 

238 sess.flush() 

239 new_ids = [cast(int, row.id) for row in rows] 

240 try: 

241 stats = self._store.apply( 

242 add_ids=new_ids, 

243 add_vectors=vectors, 

244 remove_ids=prior_ids, 

245 ) 

246 except Exception: 

247 if owns: 247 ↛ 249line 247 didn't jump to line 249 because the condition on line 247 was always true

248 sess.rollback() 

249 raise 

250 self._finalize_db( 

251 sess, owns, prior_ids, f"index prepared {source_type}/{sid}" 

252 ) 

253 

254 return IndexStats( 

255 chunks=len(chunks), 

256 added=stats["added"], 

257 removed=stats["removed"], 

258 ) 

259 

260 # ------------------------------------------------------------------ # 

261 # index 

262 # ------------------------------------------------------------------ # 

263 def index( 

264 self, 

265 *, 

266 source_type: str, 

267 source_id: Optional[str], 

268 chunks: Sequence[ChunkInput], 

269 replace: bool = True, 

270 session: Optional[Session] = None, 

271 ) -> IndexStats: 

272 """Embed + persist ``chunks`` for one source into this collection. 

273 

274 Ordering: write chunk rows and ``flush()`` for their int ids, then 

275 ``store.apply()`` (persists vectors atomically under its lock), then 

276 delete the source's prior rows, then ``commit()`` (only if we own the 

277 session). If ``store.apply()`` fails we roll back — the new rows never 

278 existed, so no DB row can point at a missing vector. 

279 

280 ``store.apply()`` persists vectors durably BEFORE the DB commit, so if 

281 the commit later fails the vectors outlive their (rolled-back) rows as 

282 orphans — searchable-by-nothing, cleaned on the next re-index of the 

283 source. When ``session`` is passed the caller owns that window. 

284 

285 ``replace=True`` removes this source's PRIOR chunks/vectors first; 

286 ``False`` appends. 

287 """ 

288 chunks = list(chunks) 

289 # An empty chunk list with replace=True still must remove this source's 

290 # PRIOR rows/vectors — e.g. a note edited down to whitespace yields zero 

291 # chunks, and skipping here would leave the user-cleared content 

292 # permanently searchable. Only a genuine no-op (nothing to add AND 

293 # nothing to replace) returns early. 

294 if not chunks and (not replace or source_id is None): 294 ↛ 295line 294 didn't jump to line 295 because the condition on line 294 was never true

295 return IndexStats(chunks=0, added=0, removed=0) 

296 

297 texts = [c.text for c in chunks] 

298 vectors = ( 

299 np.asarray(self.embeddings.embed_documents(texts), dtype="float32") 

300 if texts 

301 else None 

302 ) 

303 

304 sid = str(source_id) if source_id is not None else None 

305 with self._db(session) as (sess, owns): 

306 prior_ids: List[int] = [] 

307 if replace and sid is not None: 

308 # Scope the prior-chunk lookup to THIS store's embedding-model 

309 # config. collection_name is identical across every model config 

310 # for a collection (only RAGIndex.index_hash / the physical 

311 # .faiss path differ per model), so a lookup by 

312 # (source_type, source_id, collection_name) alone would also 

313 # match rows indexed under a DIFFERENT model. Those ids would 

314 # then be handed to THIS (physically separate, model-specific) 

315 # store as remove_ids — where faiss silently no-ops on unknown 

316 # ids — and _finalize_db would still DELETE the rows. Result: a 

317 # reindex under model B destroys model A's chunk text (its only 

318 # copy) while A's .faiss keeps the now-unresolvable vector, 

319 # even though A's index is deliberately kept for model revert. 

320 prior_ids = [ 

321 row.id 

322 for row in sess.query(DocumentChunk.id).filter_by( 

323 source_type=source_type, 

324 source_id=sid, 

325 collection_name=self.collection_name, 

326 embedding_model=self.embedding_model, 

327 embedding_model_type=self.embedding_model_type, 

328 ) 

329 ] 

330 

331 rows = [self._build_row(c, source_type, sid) for c in chunks] 

332 if rows: 

333 sess.add_all(rows) 

334 sess.flush() # assigns int PKs to every row in one round-trip 

335 # SQLAlchemy's legacy Column() declarative style types instance 

336 # attribute access as Column[int] rather than int; cast() is a 

337 # no-op at runtime (row.id is already an int post-flush()). 

338 new_ids = [cast(int, row.id) for row in rows] 

339 

340 try: 

341 stats = self._store.apply( 

342 add_ids=new_ids, 

343 add_vectors=vectors, 

344 remove_ids=prior_ids, 

345 ) 

346 except Exception: 

347 # apply() failed without a durable change (fail-closed reload / 

348 # add error / atomic-save) — a clean DB rollback keeps them 

349 # consistent. 

350 if owns: 350 ↛ 352line 350 didn't jump to line 352 because the condition on line 350 was always true

351 sess.rollback() 

352 raise 

353 # apply() is DURABLE from here; a DB-side failure below leaves the 

354 # store and DB possibly disagreeing (orphan vectors/rows). 

355 self._finalize_db( 

356 sess, owns, prior_ids, f"index {source_type}/{sid}" 

357 ) 

358 

359 return IndexStats( 

360 chunks=len(chunks), 

361 added=stats["added"], 

362 removed=stats["removed"], 

363 ) 

364 

365 @staticmethod 

366 def _finalize_db( 

367 session: Session, 

368 owns: bool, 

369 delete_ids: List[int], 

370 what: str, 

371 ) -> int: 

372 """Delete rows + commit (if owned), AFTER a durable ``store.apply()``. 

373 

374 Covers the whole post-apply DB window in one place: if either the 

375 prior-row delete OR the commit fails, the store has already persisted 

376 vectors that the DB won't reflect (orphan vectors / rows) — a 

377 known-inconsistent state that reconciles on the next re-index of the 

378 source. Log it loudly, roll back if we own the session, then re-raise. 

379 ``synchronize_session="fetch"`` keeps the reused thread-local session's 

380 identity map from serving stale, already-deleted objects. Returns the 

381 number of rows deleted. 

382 """ 

383 deleted = 0 

384 try: 

385 if delete_ids: 

386 deleted = ( 

387 session.query(DocumentChunk) 

388 .filter(DocumentChunk.id.in_(delete_ids)) 

389 .delete(synchronize_session="fetch") 

390 ) 

391 if owns: 

392 session.commit() 

393 except Exception: 

394 logger.exception( 

395 f"{what}: vectors were persisted but the DB update failed; " 

396 "store and DB will reconcile on the next re-index of the source" 

397 ) 

398 if owns: 398 ↛ 400line 398 didn't jump to line 400 because the condition on line 398 was always true

399 session.rollback() 

400 raise 

401 return deleted 

402 

403 def _build_row( 

404 self, chunk: ChunkInput, source_type: str, source_id: Optional[str] 

405 ) -> DocumentChunk: 

406 meta = chunk.metadata or {} 

407 return DocumentChunk( 

408 chunk_hash=hashlib.sha256(chunk.text.encode()).hexdigest(), 

409 source_type=source_type, 

410 source_id=source_id, 

411 collection_name=self.collection_name, 

412 chunk_text=chunk.text, 

413 chunk_index=meta.get("chunk_index", 0), 

414 start_char=meta.get("start_char", 0), 

415 end_char=meta.get("end_char", len(chunk.text)), 

416 word_count=len(chunk.text.split()), 

417 # embedding_id stays populated: it's the old-uuid -> new-int join key 

418 # the one-time index-format migration needs. (Legacy NOT NULL UNIQUE 

419 # column; drop it only in a later, separate migration.) 

420 embedding_id=uuid.uuid4().hex, 

421 embedding_model=self.embedding_model, 

422 embedding_model_type=self.embedding_model_type, 

423 embedding_dimension=self._store.dimension, 

424 document_title=meta.get("document_title") or meta.get("title"), 

425 document_metadata=meta or None, 

426 ) 

427 

428 # ------------------------------------------------------------------ # 

429 # search 

430 # ------------------------------------------------------------------ # 

431 def search( 

432 self, 

433 query: str, 

434 top_k: int, 

435 session: Optional[Session] = None, 

436 ) -> List[SearchResult]: 

437 """Embed ``query``, search the store, rehydrate hits from the DB by id. 

438 

439 Orphan hits (a vector whose DB row is gone) are skipped — there is no 

440 in-store text to fall back to, and skipping is the correct signal. 

441 """ 

442 if top_k <= 0: 

443 return [] 

444 query_vec = np.asarray( 

445 self.embeddings.embed_query(query), dtype="float32" 

446 ) 

447 hits = self._store.search(query_vec, top_k) # [(chunk_id, distance)] 

448 if not hits: 

449 return [] 

450 

451 ids = [cid for cid, _ in hits] 

452 with self._db(session) as (sess, _owns): 

453 # Scope rehydration to THIS collection. A hit id is a DocumentChunk.id 

454 # from this collection's index, but an orphan/stale vector (e.g. a 

455 # rolled-back insert left a vector whose id was later reused by a 

456 # chunk in a DIFFERENT collection) could otherwise match a row that 

457 # belongs elsewhere and leak that other document's text into this 

458 # collection's results. Filtering by collection_name makes such a 

459 # hit resolve to no row here and be skipped as an orphan. 

460 rows = { 

461 # cast(): row.id is Column[int] under the legacy Column() 

462 # declarative style; a no-op at runtime. 

463 cast(int, row.id): row 

464 for row in sess.query(DocumentChunk).filter( 

465 DocumentChunk.id.in_(ids), 

466 DocumentChunk.collection_name == self.collection_name, 

467 ) 

468 } 

469 

470 results: List[SearchResult] = [] 

471 for chunk_id, distance in hits: 

472 row = rows.get(chunk_id) 

473 if row is None: 

474 logger.debug( 

475 f"Search hit {chunk_id} has no DocumentChunk row; skipping" 

476 ) 

477 continue 

478 # SQLAlchemy's legacy Column() declarative style types instance 

479 # attribute access as Column[...] rather than the plain Python 

480 # value; cast() to the DB-verified runtime type (no conversion, 

481 # so a None stays None rather than becoming e.g. the "None" 

482 # string a str()/int() call would produce). 

483 results.append( 

484 SearchResult( 

485 chunk_id=chunk_id, 

486 text=cast(str, row.chunk_text), 

487 distance=distance, 

488 metric=self.metric, 

489 metadata=cast(Optional[dict], row.document_metadata) or {}, 

490 document_title=cast(Optional[str], row.document_title), 

491 source_id=cast(Optional[str], row.source_id), 

492 source_type=cast(Optional[str], row.source_type), 

493 ) 

494 ) 

495 return results 

496 

497 # ------------------------------------------------------------------ # 

498 # delete 

499 # ------------------------------------------------------------------ # 

500 def delete( 

501 self, 

502 *, 

503 source_type: str, 

504 source_id: str, 

505 session: Optional[Session] = None, 

506 ) -> DeleteStats: 

507 """Remove all of one source's chunks/vectors from this collection. 

508 

509 ``store.apply()`` (the durable side) runs before the DB delete + commit, 

510 matching :meth:`index`'s ordering. 

511 

512 Re-queries ``DocumentChunk`` by (source_type, source_id, 

513 collection_name) at call time to discover the ids to remove, so the 

514 rows must still exist when this is called. If a caller has already 

515 deleted those rows elsewhere, this finds nothing to do; use 

516 :meth:`delete_ids` instead with the ids captured before deletion. 

517 """ 

518 sid = str(source_id) 

519 with self._db(session) as (sess, owns): 

520 ids = [ 

521 row.id 

522 for row in sess.query(DocumentChunk.id).filter_by( 

523 source_type=source_type, 

524 source_id=sid, 

525 collection_name=self.collection_name, 

526 ) 

527 ] 

528 if not ids: 

529 return DeleteStats(removed=0, rows_deleted=0) 

530 

531 try: 

532 stats = self._store.apply( 

533 add_ids=[], add_vectors=None, remove_ids=ids 

534 ) 

535 except Exception: 

536 if owns: 

537 sess.rollback() 

538 raise 

539 # Guard the row-delete AND commit together (mirrors index()): a bare 

540 # query(...).delete() that raised after the durable apply() above 

541 # would leave orphan rows with no rollback and no diagnostic. 

542 rows_deleted = self._finalize_db( 

543 sess, owns, ids, f"delete {source_type}/{sid}" 

544 ) 

545 

546 return DeleteStats(removed=stats["removed"], rows_deleted=rows_deleted) 

547 

548 def delete_ids( 

549 self, 

550 ids: Sequence[int], 

551 *, 

552 session: Optional[Session] = None, 

553 ) -> DeleteStats: 

554 """Remove specific chunk ids' vectors from this collection's store. 

555 

556 For callers that already captured the ``DocumentChunk`` int ids 

557 BEFORE the matching rows were deleted elsewhere — e.g. a caller 

558 whose own per-item transaction must delete the rows eagerly (for 

559 its own atomicity/rollback semantics) ahead of a later BATCHED 

560 vector-store cleanup pass. Unlike :meth:`delete`, this does NOT 

561 re-query ``DocumentChunk`` by (source_type, source_id, 

562 collection_name) to discover the ids — by the time this runs the 

563 rows the ids came from may already be gone, and that lookup would 

564 find nothing (see the "rows must still exist" requirement noted on 

565 :meth:`delete`). Any rows matching the given ids that do still 

566 exist are cleaned up too (defensive; normally none are left). 

567 """ 

568 wanted = sorted({int(i) for i in ids if i is not None}) 

569 if not wanted: 

570 return DeleteStats(removed=0, rows_deleted=0) 

571 

572 with self._db(session) as (sess, owns): 

573 try: 

574 stats = self._store.apply( 

575 add_ids=[], add_vectors=None, remove_ids=wanted 

576 ) 

577 except Exception: 

578 if owns: 

579 sess.rollback() 

580 raise 

581 # Guard the row-delete AND commit together (mirrors index()). 

582 rows_deleted = self._finalize_db( 

583 sess, owns, wanted, f"delete_ids ({len(wanted)} ids)" 

584 ) 

585 

586 return DeleteStats(removed=stats["removed"], rows_deleted=rows_deleted) 

587 

588 def live_ids(self) -> List[int]: 

589 """Authoritative vector ids currently present in the store.""" 

590 return self._store.live_ids() 

591 

592 def count(self) -> int: 

593 """Number of vectors currently in the store.""" 

594 return self._store.count()