Coverage for src/local_deep_research/research_library/services/library_rag_service.py: 87%

930 statements  

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

1""" 

2Library RAG Service 

3 

4Handles indexing and searching library documents using RAG: 

5- Index text documents into vector database 

6- Chunk documents for semantic search 

7- Generate embeddings using local models 

8- Manage FAISS indices per research 

9- Track RAG status in library 

10""" 

11 

12import threading 

13import time 

14import json 

15from contextlib import contextmanager 

16from dataclasses import dataclass 

17from datetime import datetime, UTC 

18 

19import numpy as np 

20from pathlib import Path 

21from typing import Any, Callable, Dict, List, Optional, Set, Tuple 

22from concurrent.futures import ThreadPoolExecutor, as_completed, CancelledError 

23 

24from langchain_core.documents import Document as LangchainDocument 

25from loguru import logger 

26from sqlalchemy import func 

27from sqlalchemy.exc import IntegrityError 

28from sqlalchemy.orm import Session 

29 

30from ...config.paths import get_cache_directory 

31from ...constants import ( 

32 DEFAULT_LOCAL_SEARCH_CHUNK_OVERLAP, 

33 DEFAULT_LOCAL_SEARCH_CHUNK_SIZE, 

34 DEFAULT_LOCAL_SEARCH_DISTANCE_METRIC, 

35 DEFAULT_LOCAL_SEARCH_INDEX_TYPE, 

36 DEFAULT_LOCAL_SEARCH_MODEL, 

37 DEFAULT_LOCAL_SEARCH_NORMALIZE_VECTORS, 

38 DEFAULT_LOCAL_SEARCH_PROVIDER, 

39 DEFAULT_LOCAL_SEARCH_SPLITTER_TYPE, 

40 DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS, 

41) 

42from ...database.models.library import ( 

43 Document, 

44 DocumentChunk, 

45 DocumentCollection, 

46 Collection, 

47 RAGIndex, 

48 RAGIndexStatus, 

49 RagDocumentStatus, 

50 EmbeddingProvider, 

51) 

52from ...database.session_context import get_user_db_session, safe_rollback 

53from ...utilities.type_utils import to_bool 

54from ..utils import ensure_in_collection 

55from ...embeddings.splitters import get_text_splitter 

56from ...web_search_engines.engines.local_embedding_manager import ( 

57 LocalEmbeddingManager, 

58) 

59from ...security.directory_creation import create_directory 

60from ...security.file_integrity import FileIntegrityManager, FAISSIndexVerifier 

61from ...security.file_integrity.integrity_manager import NO_INTEGRITY_RECORD 

62from ...vector_stores.facade import VectorIndex, ChunkInput, SearchResult 

63from langchain_core.embeddings import Embeddings 

64import hashlib 

65 

66 

67@dataclass 

68class _PreparedDocument: 

69 document_id: str 

70 collection_id: str 

71 chunk_inputs: List[ChunkInput] 

72 vectors: np.ndarray 

73 

74 

75class _LazyEmbeddings(Embeddings): 

76 """Embeddings proxy that defers backend construction until first use. 

77 

78 A ``VectorIndex`` built purely to ``delete()`` never touches ``embeddings`` 

79 (delete resolves rows by id and calls ``store.apply(remove_ids=...)``). 

80 ``LocalEmbeddingManager.embeddings`` is a lazy property that, on first 

81 access, synchronously loads the real backend (a SentenceTransformer costs 

82 seconds and hundreds of MB of RSS, an Ollama client opens live httpx 

83 connections). Handing that property straight to the delete path forces that 

84 cost once per (deleted-document x collection). This proxy pulls the real 

85 embeddings from the manager only if ``embed_*`` is genuinely called, so the 

86 delete path pays nothing while index/search paths still work unchanged. 

87 """ 

88 

89 def __init__(self, manager) -> None: 

90 self._manager = manager 

91 

92 def embed_documents(self, texts): 

93 return self._manager.embeddings.embed_documents(texts) 

94 

95 def embed_query(self, text): 

96 return self._manager.embeddings.embed_query(text) 

97 

98 

99class _TrackedRLock: 

100 """RLock proxy that records active keys even for direct acquire() calls. 

101 

102 The recursion depth is tracked locally (not via ``threading.RLock._is_owned``, 

103 which is a CPython-private API and is not part of the threading contract — 

104 absent on PyPy/other implementations). The key set membership is mutated 

105 atomically with respect to ``acquire`` and ``pop_faiss_locks_for_user``: the 

106 per-(user, index_path) entry is added when ownership transitions 0 → 1 and 

107 discarded when it transitions 1 → 0, and both transitions happen under 

108 ``_faiss_write_locks_lock``. 

109 

110 Without holding ``_faiss_write_locks_lock`` across release, there would be a 

111 race window: thread A could release → thread B (blocked in ``acquire``) unblock 

112 and add its key → thread A then observe depth 0 and discard B's key. With 

113 ``pop_faiss_locks_for_user`` evicting a "free" lock, the next writer would 

114 create a fresh lock for the same ``(username, index_path)`` and race B on the 

115 FAISS file. Holding ``_faiss_write_locks_lock`` across inner release + depth 

116 check + discard ensures no concurrent acquire's key-add can interleave with 

117 key-discard. 

118 """ 

119 

120 def __init__(self, key: Tuple[str, str]): 

121 self._key = key 

122 self._lock = threading.RLock() 

123 # Per-wrapper recursion depth (independent of the inner RLock so we 

124 # never need the CPython-private ``_is_owned`` accessor). ``_owner`` 

125 # records the outer holder's thread id, or ``None`` when free. 

126 # ``_tracks_active_key`` is True only when this wrapper added 

127 # ``self._key`` to ``_faiss_active_lock_keys`` on its outer acquire 

128 # (canonical wrappers only) so a non-canonical release cannot erase 

129 # another holder's active-key entry. 

130 self._depth = 0 

131 self._owner = None 

132 self._tracks_active_key = False 

133 

134 def acquire(self, *args, **kwargs): 

135 first_acquire = self._lock.acquire(*args, **kwargs) 

136 if first_acquire: 

137 # ``RLock.acquire`` returns True for the outer acquisition AND for 

138 # inner re-acquires. We detect outer-vs-inner via the wrapper's 

139 # tracked ``_owner``: if the current thread already holds the lock, 

140 # this is a re-entry; otherwise it's a fresh outer acquisition. 

141 # 

142 # Only the *canonical* wrapper for ``self._key`` (the one currently 

143 # stored in ``_faiss_write_locks``) may mark the key active. A 

144 # non-canonical wrapper can still be acquired briefly during the 

145 # pop-during-acquire TOCTOU window handled by 

146 # ``_hold_faiss_write_lock``; if it also added the key, its matching 

147 # ``release()`` would discard the key while the *canonical* holder 

148 # still owned the file — reopening the concurrent-FAISS-writer race. 

149 was_owner = self._owner == threading.get_ident() 

150 if not was_owner: 

151 with _faiss_write_locks_lock: 

152 if _faiss_write_locks.get(self._key) is self: 

153 _faiss_active_lock_keys.add(self._key) 

154 self._tracks_active_key = True 

155 else: 

156 self._tracks_active_key = False 

157 self._owner = threading.get_ident() 

158 self._depth += 1 

159 return first_acquire 

160 

161 def release(self): 

162 # Atomic with respect to acquire's add: hold the key-set lock across 

163 # the inner release and the depth check so a concurrent acquire's 

164 # key-add cannot interleave with our key-discard. ``_faiss_write_locks_lock`` 

165 # is taken BEFORE the inner release (the old bug was the reverse order). 

166 # 

167 # Only discard the active key if *this* wrapper registered it on 

168 # acquire. Non-canonical wrappers never register, so their release 

169 # cannot erase a concurrent canonical holder's active-key entry. 

170 with _faiss_write_locks_lock: 

171 self._lock.release() 

172 self._depth -= 1 

173 if self._depth == 0: 

174 if self._tracks_active_key: 

175 _faiss_active_lock_keys.discard(self._key) 

176 self._tracks_active_key = False 

177 self._owner = None 

178 

179 def locked(self) -> bool: 

180 """True iff the current thread holds the wrapper at depth >= 1.""" 

181 return self._owner == threading.get_ident() and self._depth > 0 

182 

183 def __enter__(self): 

184 self.acquire() 

185 return self 

186 

187 def __exit__(self, *args): 

188 self.release() 

189 

190 

191# load_or_create verify→quarantine→build sequence per (username, index_path). 

192# MUST be module-level: each auto-index / scheduler / search worker constructs 

193# its own LibraryRAGService, so an instance-scoped lock would coordinate 

194# nothing. Pattern is adapted from web/queue/processor_v2._user_critical_locks 

195# — instance-scoped→module-scoped, username-only-key→(username, path)-key. 

196# See #4197 for the race this guards (concurrent save_local interleaves bytes, 

197# producing checksum_mismatch → destructive unlink, lost data). 

198_faiss_write_locks: Dict[Tuple[str, str], _TrackedRLock] = {} 

199_faiss_write_locks_lock = threading.Lock() 

200_faiss_active_lock_keys: Set[Tuple[str, str]] = set() 

201 

202# Hard cap on suffix-increment retries when generating the .corrupt-<ns> path. 

203# Normal case is one attempt — same-ns collisions only happen if the user 

204# manually created such files. 32 is a safety bound that converts a deadlock 

205# into a loud OSError. 

206_QUARANTINE_SUFFIX_RETRY_CAP = 32 

207 

208# After a successful quarantine, keep at most this many older .corrupt-* 

209# files per base path (per side: .faiss.corrupt-* and .pkl.corrupt-* are 

210# counted independently). Prevents unbounded disk growth on systems that 

211# experience recurring corruption while preserving recent diagnostic 

212# artefacts. Keeping 5 means the user has the last 5 corruption events 

213# to inspect / submit with a bug report; anything older is dropped. 

214_QUARANTINE_KEEP_RECENT = 5 

215 

216 

217def _get_faiss_write_lock(username: str, index_path: str) -> _TrackedRLock: 

218 """Return the lock for ``(username, index_path)``, creating it on first 

219 access. Key is normalised via ``Path.resolve()`` to match 

220 ``FileIntegrityManager._normalize_path`` so writers/readers/quarantine 

221 all agree on identity. 

222 """ 

223 key = (username, str(Path(index_path).resolve())) 

224 with _faiss_write_locks_lock: 

225 lock = _faiss_write_locks.get(key) 

226 if lock is None: 

227 lock = _TrackedRLock(key) 

228 _faiss_write_locks[key] = lock 

229 return lock 

230 

231 

232@contextmanager 

233def _hold_faiss_write_lock(username: str, index_path: str): 

234 """Context manager that acquires the per-(username, index_path) FAISS 

235 write lock, retrying across a pop-during-acquire TOCTOU window. 

236 

237 ``_get_faiss_write_lock`` returns a wrapper reference, but 

238 ``pop_faiss_locks_for_user`` may run in the gap before our 

239 ``acquire()`` adds the key to ``_faiss_active_lock_keys`` and therefore 

240 before the key is "active" from ``pop``'s perspective. Without a re-check, 

241 the caller would hold a non-canonical wrapper while ``pop`` discarded the 

242 canonical entry and the next writer created a fresh lock for the same 

243 ``(username, index_path)`` — reintroducing the same-(user, path) 

244 concurrent-FAISS-writer race this module exists to eliminate. 

245 

246 The window is microseconds and requires a per-user lock cleanup to fire 

247 mid-acquire, so this is a defence-in-depth tightening rather than a 

248 regression fix — but it is cheap to close. After ``acquire()`` returns, 

249 we re-check under ``_faiss_write_locks_lock`` that ``self`` is still the 

250 canonical entry; if not, we release and retry with the new canonical 

251 lock. Re-entry on the same wrapper is impossible here because each 

252 iteration acquires a fresh wrapper reference. 

253 """ 

254 key = (username, str(Path(index_path).resolve())) 

255 while True: 

256 lock = _get_faiss_write_lock(username, index_path) 

257 lock.acquire() 

258 with _faiss_write_locks_lock: 

259 canonical = _faiss_write_locks.get(key) 

260 is_canonical = canonical is lock 

261 if is_canonical: 

262 try: 

263 yield lock 

264 finally: 

265 lock.release() 

266 return 

267 # Evicted between _get_faiss_write_lock's lookup and our acquire(). 

268 # Release our (non-canonical) wrapper — acquire only marks the key 

269 # active for the canonical dict entry, so this release cannot erase 

270 # a concurrent canonical holder's active-key entry — then retry 

271 # with the canonical lock. 

272 lock.release() 

273 

274 

275def pop_faiss_locks_for_user(username: str) -> None: 

276 """Remove inactive FAISS-write locks for a user.""" 

277 with _faiss_write_locks_lock: 

278 stale = [k for k in _faiss_write_locks if k[0] == username] 

279 for k in stale: 

280 if k not in _faiss_active_lock_keys: 

281 _faiss_write_locks.pop(k, None) 

282 

283 

284class LibraryRAGService: 

285 """Service for managing RAG indexing of library documents.""" 

286 

287 def __init__( 

288 self, 

289 username: str, 

290 embedding_model: str = DEFAULT_LOCAL_SEARCH_MODEL, 

291 embedding_provider: str = DEFAULT_LOCAL_SEARCH_PROVIDER, 

292 chunk_size: int = DEFAULT_LOCAL_SEARCH_CHUNK_SIZE, 

293 chunk_overlap: int = DEFAULT_LOCAL_SEARCH_CHUNK_OVERLAP, 

294 splitter_type: str = DEFAULT_LOCAL_SEARCH_SPLITTER_TYPE, 

295 text_separators: Optional[list] = None, 

296 distance_metric: str = DEFAULT_LOCAL_SEARCH_DISTANCE_METRIC, 

297 normalize_vectors: bool = DEFAULT_LOCAL_SEARCH_NORMALIZE_VECTORS, 

298 index_type: str = DEFAULT_LOCAL_SEARCH_INDEX_TYPE, 

299 embedding_manager: Optional["LocalEmbeddingManager"] = None, 

300 db_password: Optional[str] = None, 

301 ): 

302 """ 

303 Initialize library RAG service for a user. 

304 

305 Args: 

306 username: Username for database access 

307 embedding_model: Name of the embedding model to use 

308 embedding_provider: Provider type ('sentence_transformers' or 'ollama') 

309 chunk_size: Size of text chunks for splitting 

310 chunk_overlap: Overlap between consecutive chunks 

311 splitter_type: Type of splitter ('recursive', 'token', 'sentence', 'semantic') 

312 text_separators: List of text separators for chunking (default: ["\n\n", "\n", ". ", " ", ""]) 

313 distance_metric: Distance metric ('cosine', 'l2', or 'dot_product') 

314 normalize_vectors: Whether to normalize vectors with L2 

315 index_type: FAISS index type ('flat', 'hnsw', or 'ivf') 

316 embedding_manager: Optional pre-constructed LocalEmbeddingManager for testing/flexibility 

317 db_password: Optional database password for background thread access 

318 """ 

319 self.username = username 

320 self._db_password = db_password # Can be used for thread access 

321 # Initialize optional attributes to None before they're set below 

322 # This allows the db_password setter to check them without hasattr 

323 self.embedding_manager = None 

324 self.integrity_manager = None 

325 self.embedding_model = embedding_model 

326 self.embedding_provider = embedding_provider 

327 self.chunk_size = chunk_size 

328 self.chunk_overlap = chunk_overlap 

329 self.splitter_type = splitter_type 

330 self.text_separators = ( 

331 text_separators 

332 if text_separators is not None 

333 else list(DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS) 

334 ) 

335 self.distance_metric = distance_metric 

336 # Ensure normalize_vectors is always a proper boolean 

337 self.normalize_vectors = to_bool(normalize_vectors, default=True) 

338 self.index_type = index_type 

339 

340 # Emit the active configuration so users can confirm their 

341 # UI-configured embedding settings are being honored (regression 

342 # signal for #3453). 

343 logger.info( 

344 f"RAG service initialized for user={username}: " 

345 f"provider={embedding_provider} model={embedding_model} " 

346 f"chunk_size={chunk_size} chunk_overlap={chunk_overlap} " 

347 f"splitter={splitter_type} index_type={index_type}" 

348 ) 

349 

350 # Use provided embedding manager or create a new one 

351 # (Must be created before text splitter for semantic chunking) 

352 # Track ownership so close() only tears down the manager when we 

353 # constructed it — a caller-supplied manager stays under caller 

354 # control (test fixtures, multi-service callers reusing one manager). 

355 self._owns_embedding_manager = embedding_manager is None 

356 if embedding_manager is not None: 

357 self.embedding_manager = embedding_manager 

358 else: 

359 # Load the complete user settings snapshot from database using the 

360 # proper method. 

361 # 

362 # STRICT snapshot is mandatory at this seam: a non-strict read 

363 # silently falls back to the JSON defaults when the underlying 

364 # settings query fails (SQLAlchemyError / stale enum row). Those 

365 # defaults can lack an operator-selected cloud embedding 

366 # provider's classification inputs and admit a cloud embedder 

367 # under a local-only posture. We refuse before any 

368 # LocalEmbeddingManager construction instead. 

369 from ...settings.manager import SettingsManager 

370 from ...security.egress.policy import ( 

371 Decision, 

372 PolicyDeniedError, 

373 ) 

374 

375 # Use proper database session for SettingsManager 

376 # Note: using _db_password (backing field) directly here because the 

377 # db_password property setter propagates to embedding_manager/integrity_manager, 

378 # which are still None at this point in __init__. 

379 try: 

380 with get_user_db_session( 

381 username, self._db_password 

382 ) as session: 

383 settings_manager = SettingsManager(session) 

384 settings_snapshot = settings_manager.get_settings_snapshot( 

385 strict=True 

386 ) 

387 except PolicyDeniedError: 

388 raise 

389 except Exception as exc: 

390 raise PolicyDeniedError( 

391 Decision(False, "settings_unavailable"), 

392 target=embedding_provider, 

393 ) from exc 

394 

395 # Add the specific settings needed for this RAG service 

396 settings_snapshot.update( 

397 { 

398 "_username": username, 

399 "embeddings.provider": embedding_provider, 

400 f"embeddings.{embedding_provider}.model": embedding_model, 

401 "local_search_chunk_size": chunk_size, 

402 "local_search_chunk_overlap": chunk_overlap, 

403 } 

404 ) 

405 

406 # Egress policy pre-flight at the constructor boundary so 

407 # every direct ``LibraryRAGService(...)`` construction site 

408 # is covered, not just the factory. Skipped when an 

409 # ``embedding_manager`` is injected (tests / advanced flows) 

410 # — those callers vouch for the manager themselves. 

411 # 

412 # Build the context from the ACTUAL scope (not a hardcoded 

413 # BOTH) so PRIVATE_ONLY forces local embeddings even when the 

414 # raw embeddings.require_local flag is at its default False — 

415 # context_from_snapshot applies that coupling. Resolve the 

416 # primary via the shared helper (single source of truth) 

417 # instead of the old search.tool + searxng fallback, which 

418 # was a fail-OPEN: a missing primary defaulted to the public 

419 # searxng so the scope relaxed and a cloud embedder could be 

420 # admitted. A missing primary now raises -> fail closed via 

421 # the ValueError handler below. 

422 from ...security.egress.policy import ( 

423 context_from_snapshot, 

424 evaluate_embeddings, 

425 resolve_run_primary_engine, 

426 ) 

427 

428 try: 

429 primary = resolve_run_primary_engine(settings_snapshot) 

430 policy_ctx = context_from_snapshot( 

431 settings_snapshot, primary, username=username 

432 ) 

433 except PolicyDeniedError: 

434 raise 

435 except ValueError as exc: 

436 raise PolicyDeniedError( 

437 Decision(False, "invalid_policy_config"), 

438 target=embedding_provider, 

439 ) from exc 

440 if policy_ctx.require_local_embeddings: 440 ↛ 441line 440 didn't jump to line 441 because the condition on line 440 was never true

441 decision = evaluate_embeddings( 

442 embedding_provider, 

443 policy_ctx, 

444 settings_snapshot=settings_snapshot, 

445 ) 

446 if not decision.allowed: 

447 logger.bind(policy_audit=True).warning( 

448 "LibraryRAGService refused by egress policy", 

449 provider=embedding_provider, 

450 reason=decision.reason, 

451 ) 

452 raise PolicyDeniedError(decision, target=embedding_provider) 

453 

454 self.embedding_manager = LocalEmbeddingManager( 

455 embedding_model=embedding_model, 

456 embedding_model_type=embedding_provider, 

457 settings_snapshot=settings_snapshot, 

458 ) 

459 

460 # Initialize text splitter based on type 

461 # (Must be created AFTER embedding_manager for semantic chunking) 

462 self.text_splitter = get_text_splitter( 

463 splitter_type=self.splitter_type, 

464 chunk_size=self.chunk_size, 

465 chunk_overlap=self.chunk_overlap, 

466 text_separators=self.text_separators, 

467 embeddings=self.embedding_manager.embeddings 

468 if self.splitter_type == "semantic" 

469 else None, 

470 ) 

471 

472 self.rag_index_record = None 

473 

474 # Initialize file integrity manager for FAISS indexes 

475 self.integrity_manager = FileIntegrityManager( 

476 username, password=self._db_password 

477 ) 

478 self.integrity_manager.register_verifier(FAISSIndexVerifier()) 

479 

480 self._closed = False 

481 

482 def close(self): 

483 """Release embedding model and index resources.""" 

484 if self._closed: 

485 return 

486 self._closed = True 

487 

488 # Release embedding manager (which in turn closes the underlying 

489 # OllamaEmbeddings httpx clients — see LocalEmbeddingManager.close). 

490 # Only when we own it; caller-supplied managers stay under caller 

491 # control to avoid double-close / use-after-close. 

492 if self.embedding_manager is not None: 

493 if self._owns_embedding_manager: 

494 self.embedding_manager.close() 

495 self.embedding_manager = None 

496 

497 # Clear other resources 

498 self.rag_index_record = None 

499 self.integrity_manager = None 

500 self.text_splitter = None 

501 

502 def __enter__(self): 

503 """Enter context manager.""" 

504 return self 

505 

506 def __exit__(self, exc_type, exc_val, exc_tb): 

507 """Exit context manager, ensuring cleanup.""" 

508 self.close() 

509 return False 

510 

511 @property 

512 def db_password(self): 

513 """Get database password.""" 

514 return self._db_password 

515 

516 @db_password.setter 

517 def db_password(self, value): 

518 """Set database password and propagate to embedding manager and integrity manager.""" 

519 self._db_password = value 

520 if self.embedding_manager: 

521 self.embedding_manager.db_password = value 

522 if self.integrity_manager: 

523 self.integrity_manager.password = value 

524 

525 def _get_index_hash( 

526 self, 

527 collection_name: str, 

528 embedding_model: str, 

529 embedding_model_type: str, 

530 ) -> str: 

531 """Generate hash for index identification.""" 

532 identity = { 

533 "chunk_overlap": self.chunk_overlap, 

534 "chunk_size": self.chunk_size, 

535 "collection_name": collection_name, 

536 "distance_metric": self.distance_metric, 

537 "embedding_model": embedding_model, 

538 "embedding_provider": embedding_model_type, 

539 "index_type": self.index_type, 

540 "normalize_vectors": self.normalize_vectors, 

541 "splitter_type": self.splitter_type, 

542 "text_separators": self.text_separators, 

543 } 

544 hash_input = json.dumps( 

545 identity, ensure_ascii=False, separators=(",", ":"), sort_keys=True 

546 ) 

547 return hashlib.sha256(hash_input.encode()).hexdigest() 

548 

549 def _matches_current_index_configuration(self, rag_index: RAGIndex) -> bool: 

550 stored_provider = rag_index.embedding_model_type 

551 if isinstance(stored_provider, EmbeddingProvider): 551 ↛ 553line 551 didn't jump to line 553 because the condition on line 551 was always true

552 stored_provider = stored_provider.value 

553 stored_separators = rag_index.text_separators 

554 if stored_separators is None: 

555 stored_separators = DEFAULT_LOCAL_SEARCH_TEXT_SEPARATORS 

556 if not isinstance(stored_separators, list) or not all( 556 ↛ 559line 556 didn't jump to line 559 because the condition on line 556 was never true

557 isinstance(separator, str) for separator in stored_separators 

558 ): 

559 return False 

560 return ( 

561 rag_index.embedding_model == self.embedding_model 

562 and stored_provider == self.embedding_provider 

563 and rag_index.chunk_size == self.chunk_size 

564 and rag_index.chunk_overlap == self.chunk_overlap 

565 and (rag_index.splitter_type or "recursive") == self.splitter_type 

566 and stored_separators == self.text_separators 

567 and (rag_index.distance_metric or "cosine") == self.distance_metric 

568 and ( 

569 rag_index.normalize_vectors 

570 if rag_index.normalize_vectors is not None 

571 else True 

572 ) 

573 == self.normalize_vectors 

574 and (rag_index.index_type or "flat") == self.index_type 

575 ) 

576 

577 def _find_matching_rag_index( 

578 self, db_session: Session, collection_name: str, index_hash: str 

579 ) -> RAGIndex | None: 

580 rag_index = ( 

581 db_session.query(RAGIndex).filter_by(index_hash=index_hash).first() 

582 ) 

583 if rag_index is not None: 

584 return rag_index 

585 

586 candidates = ( 

587 db_session.query(RAGIndex) 

588 .filter_by( 

589 collection_name=collection_name, 

590 embedding_model=self.embedding_model, 

591 embedding_model_type=EmbeddingProvider(self.embedding_provider), 

592 ) 

593 .all() 

594 ) 

595 for candidate in candidates: 

596 if self._matches_current_index_configuration(candidate): 596 ↛ 595line 596 didn't jump to line 595 because the condition on line 596 was always true

597 return candidate 

598 return None 

599 

600 def _resolve_index_for_config( 

601 self, db_session: Session, collection_id: str 

602 ) -> RAGIndex | None: 

603 """The collection's index for this service's configuration, or None. 

604 

605 Read-only: unlike ``_get_or_create_rag_index`` it never creates a 

606 row or probes the embedding provider. 

607 """ 

608 collection_name = f"collection_{collection_id}" 

609 return self._find_matching_rag_index( 

610 db_session, 

611 collection_name, 

612 self._get_index_hash( 

613 collection_name, self.embedding_model, self.embedding_provider 

614 ), 

615 ) 

616 

617 def _get_index_path(self, index_hash: str) -> Path: 

618 """Get path for FAISS index file. 

619 

620 Files are scoped to a per-user subdirectory derived from a hash 

621 of the username so two users with identical collection name + 

622 embedding model don't share the same .faiss/.pkl files on disk. 

623 The previous layout used a shared `cache/rag_indices/` for all 

624 users, which would let one user's vectors land in another 

625 user's load path in any deployment running more than one 

626 account against the same data dir. 

627 

628 Raises: 

629 ValueError: If self.username is empty/None. sha256("") is a 

630 known constant, so silently hashing an empty username would 

631 let two different "no username" service instances collide 

632 into one shared cache directory instead of failing loudly. 

633 Defense-in-depth -- see #5481. 

634 """ 

635 if not self.username: 

636 raise ValueError( 

637 "Cannot compute a RAG index path for an empty username" 

638 ) 

639 # Store in centralized cache directory (respects LDR_DATA_DIR) 

640 user_scope = hashlib.sha256(self.username.encode("utf-8")).hexdigest()[ 

641 :16 

642 ] 

643 rag_root = get_cache_directory() / "rag_indices" 

644 cache_dir = rag_root / user_scope 

645 create_directory( 

646 cache_dir, context="per-user RAG index cache directory" 

647 ) 

648 # 0o700: filesystem-level defense-in-depth so other local OS accounts 

649 # can't browse another LDR user's vector caches. mkdir(parents=True) 

650 # silently creates the intermediate rag_indices/ root under the process 

651 # umask (typically 0o755, world-traversable), so harden the ROOT too — 

652 # chmod'ing only the per-user leaf leaves a 0o755 rag_indices/ that lets 

653 # another OS user traverse in and stat/enumerate every account's caches. 

654 for d in (rag_root, cache_dir): 

655 try: 

656 d.chmod(0o700) 

657 except OSError: 

658 # Best-effort: some filesystems (e.g. Windows shares) don't 

659 # honor chmod; the per-user path scope is still applied. 

660 pass 

661 return cache_dir / f"{index_hash}.faiss" 

662 

663 def _migrate_legacy_index_files( 

664 self, legacy_path: Path, index_path: Path, rag_index: RAGIndex 

665 ) -> None: 

666 """Relocate a pre-per-user-scoping index into the per-user directory. 

667 

668 Before the per-user path scoping, indexes lived directly in the 

669 shared ``cache/rag_indices/`` directory. ``_preflight_index_path`` 

670 recomputes the path and ignores the stored one, so without this 

671 one-time migration an upgrading user's existing index is never 

672 found again: semantic search silently returns nothing while 

673 ``DocumentCollection.indexed`` still claims everything is indexed, 

674 so a non-force re-index no-ops too. Only files directly inside the 

675 known legacy shared directory with the expected hash filename are 

676 moved — arbitrary stored paths are not followed. 

677 """ 

678 legacy_root = get_cache_directory() / "rag_indices" 

679 if ( 

680 legacy_path == index_path 

681 or legacy_path.parent != legacy_root 

682 or legacy_path.name != index_path.name 

683 or not legacy_path.exists() 

684 ): 

685 return 

686 with _hold_faiss_write_lock(self.username, str(index_path)): 

687 if index_path.exists(): 687 ↛ 688line 687 didn't jump to line 688 because the condition on line 687 was never true

688 return 

689 try: 

690 legacy_pkl = legacy_path.with_suffix(".pkl") 

691 # Relocate phase-1's text-free .idmap.json sidecar BEFORE the 

692 # .faiss, then move the .faiss LAST. phase-2 rekey looks for the 

693 # sidecar next to the .faiss (legacy_rekey._sidecar_for = 

694 # <stem>.idmap.json); leaving it at the legacy location orphans 

695 # it, and a not-yet-rekeyed index then has no findable sidecar → 

696 # rekey sees "no sidecar + old format" and destructively 

697 # quarantines a healthy index. Ordering matters for crash 

698 # recovery: the re-entry guard keys ONLY on index_path (the 

699 # .faiss), so moving the .faiss last makes this idempotent — a 

700 # crash after the sidecar move but before the .faiss move leaves 

701 # the .faiss at the legacy path (guard still False) and re-entry 

702 # simply retries (the sidecar move no-ops, new one exists). The 

703 # reverse order would strand index_path.exists()=True with the 

704 # sidecar orphaned at the legacy root, which the guard never 

705 # re-invokes this to fix. 

706 legacy_sidecar = legacy_path.with_name( 

707 legacy_path.stem + ".idmap.json" 

708 ) 

709 if legacy_sidecar.exists(): 

710 new_sidecar = index_path.with_name( 

711 index_path.stem + ".idmap.json" 

712 ) 

713 if not new_sidecar.exists(): 713 ↛ 715line 713 didn't jump to line 715 because the condition on line 713 was always true

714 legacy_sidecar.rename(new_sidecar) 

715 legacy_path.rename(index_path) 

716 # DELETE (don't relocate) any legacy plaintext .pkl companion: 

717 # the new store writes NO .pkl, so moving it would just carry the 

718 # plaintext chunk text to the new location. Its text lives in the 

719 # encrypted DB. 

720 if legacy_pkl.exists(): 

721 try: 

722 legacy_pkl.unlink() 

723 except OSError: 

724 logger.warning( 

725 f"Could not remove legacy plaintext .pkl " 

726 f"{legacy_pkl} — delete it manually" 

727 ) 

728 except OSError: 

729 logger.exception( 

730 f"Failed to migrate legacy FAISS index " 

731 f"{legacy_path} -> {index_path}" 

732 ) 

733 return 

734 # Persist the relocated path so the row stops pointing at the 

735 # now-empty legacy location. 

736 with get_user_db_session(self.username, self.db_password) as session: 

737 idx = session.query(RAGIndex).filter_by(id=rag_index.id).first() 

738 if idx: 738 ↛ 741line 738 didn't jump to line 741

739 idx.index_path = str(index_path) 

740 session.commit() 

741 try: 

742 # Refresh the integrity record under the new path (the old 

743 # record is keyed by the legacy path and no longer applies). 

744 self.integrity_manager.record_file( 

745 index_path, 

746 related_entity_type="rag_index", 

747 related_entity_id=rag_index.id, 

748 ) 

749 except Exception: 

750 # Non-fatal: verify_file() creates a record on demand for 

751 # unknown paths, so the subsequent load still succeeds. 

752 logger.exception( 

753 "Failed to refresh integrity record after index migration" 

754 ) 

755 logger.info( 

756 f"Migrated legacy shared FAISS index to per-user location: " 

757 f"{index_path}" 

758 ) 

759 

760 def _reset_index_state_for_rebuild( 

761 self, collection_id: str, rag_index: RAGIndex 

762 ) -> None: 

763 """Reset per-document indexed state when the index file is gone. 

764 

765 Reaching the fresh-build branch with DB rows that still claim 

766 indexed content means the on-disk index was lost (missing after an 

767 upgrade, quarantined, or deleted on dimension mismatch). Without 

768 this reset, ``index_document``/``index_collection`` skip every 

769 document (``DocumentCollection.indexed`` is still True), so the 

770 only repair is an undocumented force re-index while search 

771 silently returns nothing. Clearing the flags makes the regular 

772 re-index path rebuild for real and makes the UI show the honest 

773 unindexed state. Worst case of the narrow race with an in-flight 

774 indexer is re-submitting chunks the merge step dedups by id. 

775 """ 

776 with get_user_db_session(self.username, self.db_password) as session: 

777 idx = session.query(RAGIndex).filter_by(id=rag_index.id).first() 

778 claims_content = bool( 

779 idx and (idx.chunk_count or idx.total_documents) 

780 ) 

781 stale_status = ( 

782 session.query(RagDocumentStatus) 

783 .filter_by(rag_index_id=rag_index.id) 

784 .count() 

785 ) 

786 stale_memberships = ( 

787 session.query(DocumentCollection) 

788 .filter_by(collection_id=collection_id, indexed=True) 

789 .count() 

790 ) 

791 if not (claims_content or stale_status or stale_memberships): 

792 # Genuinely new index — nothing to reset. 

793 return 

794 if idx: 794 ↛ 797line 794 didn't jump to line 797 because the condition on line 794 was always true

795 idx.chunk_count = 0 

796 idx.total_documents = 0 

797 session.query(RagDocumentStatus).filter_by( 

798 rag_index_id=rag_index.id 

799 ).delete() 

800 session.query(DocumentCollection).filter_by( 

801 collection_id=collection_id, indexed=True 

802 ).update({"indexed": False, "chunk_count": 0}) 

803 session.commit() 

804 logger.warning( 

805 f"FAISS index file for collection {collection_id} is " 

806 f"missing; reset indexed state for {stale_memberships} " 

807 f"document(s) so the next index run rebuilds the index." 

808 ) 

809 

810 def _get_or_create_rag_index( 

811 self, 

812 collection_id: str, 

813 *, 

814 promote_current: bool = True, 

815 db_session: Session | None = None, 

816 commit: bool = True, 

817 ) -> RAGIndex: 

818 """Get or create RAGIndex record for the current configuration. 

819 

820 ``promote_current`` (default True) controls whether reusing an existing, 

821 not-current row re-promotes it to ``is_current`` (demoting others). Read 

822 paths (search) pass False: a mere search under a stale/different config 

823 must NOT silently flip which index the collection considers current — 

824 only an actual write (index) should. Creating a brand-new index still 

825 promotes it regardless (it's the collection's only index for that config). 

826 """ 

827 if db_session is None: 

828 with get_user_db_session( 

829 self.username, self.db_password 

830 ) as session: 

831 return self._get_or_create_rag_index_in_session( 

832 session, 

833 collection_id, 

834 promote_current=promote_current, 

835 commit=commit, 

836 ) 

837 return self._get_or_create_rag_index_in_session( 

838 db_session, 

839 collection_id, 

840 promote_current=promote_current, 

841 commit=commit, 

842 ) 

843 

844 def _get_or_create_rag_index_in_session( 

845 self, 

846 db_session: Session, 

847 collection_id: str, 

848 *, 

849 promote_current: bool, 

850 commit: bool, 

851 ) -> RAGIndex: 

852 collection_name = f"collection_{collection_id}" 

853 index_hash = self._get_index_hash( 

854 collection_name, self.embedding_model, self.embedding_provider 

855 ) 

856 rag_index = self._find_matching_rag_index( 

857 db_session, collection_name, index_hash 

858 ) 

859 created = False 

860 

861 if rag_index is None: 

862 index_path = self._get_index_path(index_hash) 

863 embedding_manager = self.embedding_manager 

864 if embedding_manager is None: 864 ↛ 865line 864 didn't jump to line 865 because the condition on line 864 was never true

865 raise RuntimeError( 

866 "Cannot create an index after closing the service" 

867 ) 

868 embedding_dim = len( 

869 embedding_manager.embeddings.embed_query("test") 

870 ) 

871 candidate = RAGIndex( 

872 collection_name=collection_name, 

873 embedding_model=self.embedding_model, 

874 embedding_model_type=EmbeddingProvider(self.embedding_provider), 

875 embedding_dimension=embedding_dim, 

876 index_path=str(index_path), 

877 index_hash=index_hash, 

878 chunk_size=self.chunk_size, 

879 chunk_overlap=self.chunk_overlap, 

880 splitter_type=self.splitter_type, 

881 text_separators=self.text_separators, 

882 distance_metric=self.distance_metric, 

883 normalize_vectors=self.normalize_vectors, 

884 index_type=self.index_type, 

885 chunk_count=0, 

886 total_documents=0, 

887 status=RAGIndexStatus.ACTIVE, 

888 is_current=False, 

889 ) 

890 try: 

891 with db_session.begin_nested(): 

892 db_session.add(candidate) 

893 db_session.flush() 

894 except IntegrityError: 

895 rag_index = self._find_matching_rag_index( 

896 db_session, collection_name, index_hash 

897 ) 

898 if rag_index is None: 898 ↛ 899line 898 didn't jump to line 899 because the condition on line 898 was never true

899 raise 

900 logger.info( 

901 f"Using concurrently created RAG index: {index_hash}" 

902 ) 

903 else: 

904 rag_index = candidate 

905 created = True 

906 logger.info(f"Created new RAG index: {index_hash}") 

907 

908 mutated = created or (promote_current and not rag_index.is_current) 

909 if mutated: 

910 db_session.query(RAGIndex).filter( 

911 RAGIndex.collection_name == collection_name, 

912 RAGIndex.is_current.is_(True), 

913 RAGIndex.id != rag_index.id, 

914 ).update({"is_current": False}) 

915 rag_index.is_current = True 

916 

917 # Only commit when this call actually created or promoted an index. 

918 # A pure read-through of an existing current index must stay a no-op 

919 # so it can't consume a caller's pending write (e.g. the fault the 

920 # index-finalize self-heal backstop guards against). 

921 if commit and mutated: 

922 db_session.commit() 

923 db_session.refresh(rag_index) 

924 return rag_index 

925 

926 def _quarantine_corrupt_index(self, index_path: Path, reason: str) -> None: 

927 """Rename a corrupted FAISS index to ``<path>.corrupt-<ns>`` 

928 instead of deleting it; any legacy plaintext ``.pkl`` companion 

929 is deleted outright rather than preserved. 

930 

931 Preserves user data for inspection/recovery. The 

932 dimension-mismatch branch elsewhere in this method intentionally 

933 *deletes* — that case rebuilds from scratch and the old bytes 

934 are unreadable with the new model. This helper is for the two 

935 "transient or unknown failure" branches (verify_file said no, 

936 loading the index into a ``VectorIndex``/faiss raised) where the 

937 on-disk bytes may still be usable by a human. 

938 

939 Raises ``OSError`` on rename failure (disk full, read-only fs, 

940 permission denied). Re-raising prevents silent data loss: if we 

941 swallowed the error, the next ``persist()``/``faiss.write_index`` 

942 would overwrite the corrupt bytes anyway. Caller paths log the 

943 exception via the broader try/except around their indexer call. 

944 

945 # TODO(#4197-followup): FileIntegrityRecord.consecutive_failures 

946 # is not reset by the next record_file call, leaking failure 

947 # counts across recovery cycles. Orthogonal to this fix. 

948 """ 

949 ns = time.time_ns() 

950 pkl_path = index_path.with_suffix(".pkl") 

951 

952 # Same-nanosecond collisions essentially can't happen between 

953 # concurrent threads (we hold the per-path lock), but a user 

954 # could manually create such files. Loop with a hard cap so a 

955 # weird state surfaces as a clean OSError rather than a hang. 

956 suffix_n = 0 

957 faiss_target = Path(f"{index_path}.corrupt-{ns}") 

958 pkl_target = Path(f"{pkl_path}.corrupt-{ns}") 

959 while faiss_target.exists() or pkl_target.exists(): 

960 suffix_n += 1 

961 if suffix_n > _QUARANTINE_SUFFIX_RETRY_CAP: 

962 raise OSError( 

963 f"Quarantine path collisions exceeded " 

964 f"{_QUARANTINE_SUFFIX_RETRY_CAP} retries for " 

965 f"{index_path}" 

966 ) 

967 faiss_target = Path(f"{index_path}.corrupt-{ns}-{suffix_n}") 

968 pkl_target = Path(f"{pkl_path}.corrupt-{ns}-{suffix_n}") 

969 

970 try: 

971 index_path.rename(faiss_target) 

972 logger.warning( 

973 f"Quarantined corrupted FAISS index to {faiss_target} " 

974 f"(reason: {reason}). Searches against this collection " 

975 f"will return empty results until the index is rebuilt. " 

976 f"Document chunks are preserved in the database — " 

977 f"trigger 'Re-index Collection' (or set " 

978 f"research_library.auto_index_enabled=true and re-run " 

979 f"indexing) to recover." 

980 ) 

981 if pkl_path.exists(): 

982 # DELETE (not rename) any .pkl companion. The new store writes 

983 # NO .pkl, so a .pkl next to a .faiss is a LEGACY plaintext 

984 # docstore — its chunk text lives in the (encrypted) DB, so 

985 # renaming it aside to .corrupt-<ns> would needlessly preserve 

986 # plaintext chunk text on disk. Plaintext removal is not optional. 

987 try: 

988 pkl_path.unlink() 

989 logger.info(f"Removed legacy plaintext PKL {pkl_path}") 

990 except OSError: 

991 logger.warning( 

992 f"Could not remove legacy plaintext PKL {pkl_path}" 

993 "delete it manually" 

994 ) 

995 else: 

996 # Missing .pkl is the normal case for the new store. Not 

997 # re-raised: the .faiss is already preserved. 

998 logger.debug(f"No PKL companion at {pkl_path}.") 

999 except OSError: 

1000 if not index_path.exists(): 

1001 # The source is already gone — most likely another worker 

1002 # PROCESS quarantined the same corrupt index concurrently (the 

1003 # per-path lock is a threading.Lock, so it serialises only 

1004 # threads within ONE process, not across gunicorn workers / 

1005 # the scheduler process). The objective — get the corrupt bytes 

1006 # off index_path so the caller rebuilds fresh — is already met, 

1007 # so don't abort a healthy rebuild with a spurious 

1008 # FileNotFoundError. Still purge any lingering plaintext .pkl: 

1009 # the no-plaintext-at-rest invariant is not optional even here. 

1010 if pkl_path.exists(): 1010 ↛ 1018line 1010 didn't jump to line 1018 because the condition on line 1010 was always true

1011 try: 

1012 pkl_path.unlink() 

1013 except OSError: 

1014 logger.warning( 

1015 f"Could not remove legacy plaintext PKL {pkl_path} " 

1016 "— delete it manually" 

1017 ) 

1018 logger.warning( 

1019 f"Corrupt index {index_path} was already quarantined by " 

1020 f"another worker (reason: {reason}); proceeding to rebuild." 

1021 ) 

1022 return 

1023 # A genuine failure (disk-full, read-only fs, permission denied) 

1024 # with the corrupt bytes STILL in place. Re-raise so the caller 

1025 # surfaces a real failure instead of letting the next 

1026 # persist()/write_index overwrite the corrupt bytes. 

1027 logger.exception( 

1028 f"Failed to quarantine corrupted index at {index_path}" 

1029 ) 

1030 raise 

1031 

1032 # Best-effort retention sweep. The quarantine itself succeeded 

1033 # above; failing to prune older files is not a correctness 

1034 # issue, just a disk-usage one — log and move on. 

1035 self._prune_old_quarantined_files(index_path) 

1036 

1037 @staticmethod 

1038 def _corrupt_sort_key(path: Path) -> Tuple[int, int]: 

1039 """Extract ``(ns, suffix_n)`` from a ``.corrupt-<ns>[-<n>]`` 

1040 filename so retention can sort by the monotonic nanosecond 

1041 suffix the quarantine path embeds — *not* by ``st_mtime``. 

1042 

1043 Filesystem timestamp granularity is sometimes 1s or 2s 

1044 (FAT32/ext3/SMB shares), making mtime ordering non-deterministic 

1045 when multiple quarantines happen within one tick. The 

1046 ``.corrupt-<ns>`` suffix carries the original ``time.time_ns()`` 

1047 from the quarantine and is reliable across all filesystems. 

1048 

1049 Returns ``(-1, -1)`` for malformed names (manually-placed 

1050 files) so they sort below any real entry under ``reverse=True`` 

1051 — i.e., they get pruned first. 

1052 """ 

1053 name = path.name 

1054 marker = ".corrupt-" 

1055 idx = name.rfind(marker) 

1056 if idx == -1: 1056 ↛ 1057line 1056 didn't jump to line 1057 because the condition on line 1056 was never true

1057 return (-1, -1) 

1058 tail = name[idx + len(marker) :] 

1059 parts = tail.split("-") 

1060 try: 

1061 ns = int(parts[0]) 

1062 except ValueError: 

1063 return (-1, -1) 

1064 suffix_n = 0 

1065 if len(parts) > 1: 

1066 try: 

1067 suffix_n = int(parts[-1]) 

1068 except ValueError: 

1069 # Unknown trailing component — keep the file but at the 

1070 # base ns ordering. 

1071 pass 

1072 return (ns, suffix_n) 

1073 

1074 def _prune_old_quarantined_files(self, index_path: Path) -> None: 

1075 """Keep only the ``_QUARANTINE_KEEP_RECENT`` most-recent 

1076 ``.corrupt-*`` files for ``index_path`` and its ``.pkl`` 

1077 companion. Sweeps the two sides independently — pairs share 

1078 the same ``-<ns>`` suffix so they're ordered identically. 

1079 

1080 Ordering uses the embedded ``-<ns>`` from the quarantine 

1081 filename, not ``st_mtime``: file systems with 1-2s timestamp 

1082 granularity can otherwise produce non-deterministic retention 

1083 on bursts. 

1084 

1085 Best-effort: logs and swallows any error so a sweep failure 

1086 never propagates back into the indexing path. 

1087 """ 

1088 parent = index_path.parent 

1089 pkl_path = index_path.with_suffix(".pkl") 

1090 

1091 for base in (index_path, pkl_path): 

1092 pattern = f"{base.name}.corrupt-*" 

1093 try: 

1094 # Sort newest-first by the embedded -<ns>; everything 

1095 # past the keep window is stale. 

1096 candidates = sorted( 

1097 parent.glob(pattern), 

1098 key=self._corrupt_sort_key, 

1099 reverse=True, 

1100 ) 

1101 except OSError: 

1102 logger.warning( 

1103 f"Failed to enumerate {pattern} in {parent} for " 

1104 f"quarantine retention sweep" 

1105 ) 

1106 continue 

1107 

1108 for stale in candidates[_QUARANTINE_KEEP_RECENT:]: 

1109 try: 

1110 stale.unlink() 

1111 logger.info(f"Pruned old quarantined file: {stale}") 

1112 except OSError: 

1113 logger.warning( 

1114 f"Failed to prune quarantined file {stale}; continuing" 

1115 ) 

1116 

1117 def _preflight_index_path( 

1118 self, 

1119 collection_id: str, 

1120 rag_index: RAGIndex, 

1121 *, 

1122 reset_stale_state: bool = False, 

1123 ) -> Path: 

1124 """Verify -> quarantine -> dimension-check pre-flight for ``rag_index``. 

1125 

1126 Extracted from the pre-cutover ``load_or_create_faiss_index`` 

1127 (deleted — the old LangChain-FAISS path is gone) so every 

1128 index/search/delete call site shares ONE recovery path instead of 

1129 duplicating it. Mutates ``rag_index.index_path`` / ``rag_index.embedding_dimension`` 

1130 in place — matching the pre-refactor behavior of stamping the 

1131 recomputed path/dimension back onto the in-memory record so callers 

1132 that read ``self.rag_index_record`` afterwards see the current 

1133 values — and returns the resolved on-disk path. 

1134 

1135 Deliberately does NOT load or construct a vector store: that stays 

1136 the caller's job (via ``VectorIndex(...)``), so a corrupt/foreign/ 

1137 dimension-mismatched file surfaces as an exception from THAT 

1138 construction, which the caller catches to quarantine + rebuild (see 

1139 ``_get_vector_index``). 

1140 """ 

1141 index_path = self._get_index_path(rag_index.index_hash) 

1142 stored_path = ( 

1143 Path(rag_index.index_path) if rag_index.index_path else None 

1144 ) 

1145 rag_index.index_path = str(index_path) 

1146 if not index_path.exists() and stored_path is not None: 

1147 self._migrate_legacy_index_files(stored_path, index_path, rag_index) 

1148 

1149 # Hold the per-(username, index_path) write lock across the entire 

1150 # verify -> quarantine -> dimension-check sequence — see #4197 (a 

1151 # narrower scope would let a concurrent writer's save race the 

1152 # verification). 

1153 if index_path.exists(): 

1154 lock = _get_faiss_write_lock(self.username, str(index_path)) 

1155 with lock: 

1156 verified, reason = self.integrity_manager.verify_file( 

1157 index_path 

1158 ) 

1159 if not verified: 

1160 if reason == NO_INTEGRITY_RECORD: 1160 ↛ 1179line 1160 didn't jump to line 1179 because the condition on line 1160 was never true

1161 # A MISSING record is not proven corruption, and unlike 

1162 # checksum_mismatch it cannot be attacker-induced: the 

1163 # record lives in the per-user ENCRYPTED DB, so removing 

1164 # it needs the DB password (which also decrypts 

1165 # everything). It only arises from our own crash between 

1166 # the index write and record_file (see legacy_rekey 

1167 # Phase B) or a pre-integrity-feature index. ADOPT the 

1168 # byte-present file by recording its integrity now — on 

1169 # BOTH read and write paths. Adoption is non-destructive 

1170 # (it only records a checksum), so a read path may do it: 

1171 # refusing here instead would hard-fail EVERY search on a 

1172 # byte-healthy, missing-record index forever (a read-only 

1173 # collection is never revisited by a write op that would 

1174 # otherwise adopt it). If the bytes are actually 

1175 # unreadable/foreign/wrong-dim, the VectorIndex load below 

1176 # still raises and the caller handles it — corruption is 

1177 # caught either way. Only checksum_mismatch (genuine 

1178 # tamper) is refused/quarantined below. 

1179 logger.warning( 

1180 f"No integrity record for {index_path}; adopting the " 

1181 "existing index (recording its integrity) rather than " 

1182 "quarantining — a missing record is not corruption." 

1183 ) 

1184 self.integrity_manager.record_file( 

1185 index_path, 

1186 related_entity_type="rag_index", 

1187 related_entity_id=rag_index.id, 

1188 ) 

1189 # Adopted = now trusted: fall through to the SAME 

1190 # embedding-dimension-drift probe the verified branch 

1191 # runs, so an adopted index whose model dimension changed 

1192 # is rebuilt rather than silently mismatched at add time. 

1193 verified = True 

1194 elif not reset_stale_state: 

1195 # Read path (search): refuse to serve an index that 

1196 # failed verification for a reason OTHER than a missing 

1197 # record (a checksum_mismatch = genuine tamper, or a 

1198 # transient read failure), but do NOT destroy it — 

1199 # quarantine is a WRITE-path recovery decision. A 

1200 # subsequent index/write operation quarantines + rebuilds. 

1201 raise RuntimeError( 

1202 f"Integrity verification failed for {index_path}: " 

1203 f"{reason} — refusing on a read path (a subsequent " 

1204 "index/write operation will quarantine + rebuild)" 

1205 ) 

1206 elif reason and reason.startswith( 1206 ↛ 1215line 1206 didn't jump to line 1215 because the condition on line 1206 was never true

1207 "checksum_calculation_failed" 

1208 ): 

1209 # Transient: could not READ the file to compute its 

1210 # checksum (disk hiccup, momentary lock). This says 

1211 # NOTHING about tampering — unlike checksum_mismatch — so 

1212 # quarantining would force a needless full re-embed of a 

1213 # healthy collection. Raise so the operation retries (like 

1214 # the read path) instead of destroying the index. 

1215 raise RuntimeError( 

1216 f"Integrity check could not read {index_path} " 

1217 f"({reason}) — refusing to quarantine on a transient " 

1218 "read failure; retry the operation." 

1219 ) 

1220 else: 

1221 logger.error( 

1222 f"Integrity verification failed for {index_path}: " 

1223 f"{reason}. Quarantining for recovery; creating " 

1224 f"new index." 

1225 ) 

1226 self._quarantine_corrupt_index(index_path, reason) 

1227 if verified: 

1228 # Probe the embedding model OUTSIDE the quarantine 

1229 # decision: embed_query fails when the provider itself 

1230 # is unreachable (e.g. Ollama down), which says nothing 

1231 # about the index file's health, and must propagate 

1232 # rather than trigger a destructive rebuild of a 

1233 # healthy index. 

1234 current_dim = len( 

1235 self.embedding_manager.embeddings.embed_query( 

1236 "dimension_check" 

1237 ) 

1238 ) 

1239 stored_dim = rag_index.embedding_dimension 

1240 if stored_dim and current_dim != stored_dim: 

1241 if not reset_stale_state: 

1242 # Read path: surface the mismatch instead of 

1243 # deleting the index + wiping DB state (which a mere 

1244 # search must never do). A write/index operation 

1245 # owns the destructive rebuild. 

1246 raise RuntimeError( 

1247 f"Embedding dimension mismatch for {index_path}: " 

1248 f"index dim={stored_dim}, model dim=" 

1249 f"{current_dim} — refusing on a read path" 

1250 ) 

1251 logger.warning( 

1252 f"Embedding dimension mismatch detected! " 

1253 f"Index created with dim={stored_dim}, " 

1254 f"current model returns dim={current_dim}. " 

1255 f"Deleting old index and rebuilding." 

1256 ) 

1257 try: 

1258 index_path.unlink() 

1259 pkl_path = index_path.with_suffix(".pkl") 

1260 if pkl_path.exists(): 1260 ↛ 1261line 1260 didn't jump to line 1261 because the condition on line 1260 was never true

1261 pkl_path.unlink() 

1262 logger.info( 

1263 f"Deleted old index files at {index_path}" 

1264 ) 

1265 except Exception: 

1266 logger.exception("Failed to delete old index files") 

1267 

1268 with get_user_db_session( 

1269 self.username, self.db_password 

1270 ) as session: 

1271 idx = ( 

1272 session.query(RAGIndex) 

1273 .filter_by(id=rag_index.id) 

1274 .first() 

1275 ) 

1276 if idx: 1276 ↛ 1288line 1276 didn't jump to line 1288

1277 idx.embedding_dimension = current_dim 

1278 session.commit() 

1279 logger.info( 

1280 f"Updated RAGIndex dimension to {current_dim}" 

1281 ) 

1282 # Reset RAGIndex counts, RagDocumentStatus, and 

1283 # DocumentCollection.indexed together — a hand-rolled 

1284 # partial reset here previously left 

1285 # DocumentCollection.indexed=True, which makes 

1286 # index_document/index_collection skip every document 

1287 # and search silently return nothing after the rebuild. 

1288 self._reset_index_state_for_rebuild( 

1289 collection_id, rag_index 

1290 ) 

1291 rag_index.embedding_dimension = current_dim 

1292 

1293 if not index_path.exists(): 

1294 # The new per-user path is missing — but that alone does NOT mean the 

1295 # index is gone. A one-time legacy-path relocation 

1296 # (_migrate_legacy_index_files above) can fail transiently (a rename 

1297 # hitting disk-full, an AV/backup file lock, a busy network mount), 

1298 # leaving the bytes fully intact at the stored legacy path. Treating 

1299 # that as "gone" would wipe a healthy collection's indexed bookkeeping 

1300 # — even on a mere search — and force a needless full re-embed. Only 

1301 # self-heal when the bytes are absent at BOTH the new and the legacy 

1302 # location; otherwise leave the state alone so the next write-path 

1303 # access retries the relocation. 

1304 legacy_bytes_present = bool( 

1305 stored_path is not None 

1306 and stored_path != index_path 

1307 and stored_path.exists() 

1308 ) 

1309 if not legacy_bytes_present: 

1310 stale_indexed = bool( 

1311 (rag_index.chunk_count or 0) > 0 

1312 or (rag_index.total_documents or 0) > 0 

1313 ) 

1314 if stale_indexed: 

1315 # DB says indexed, but the file is GONE at both locations 

1316 # (cache dir cleared, restored without the cache dir, lost 

1317 # volume, crash between quarantine and rebuild). Reset so it 

1318 # self-heals — even on a READ path: an absent file has 

1319 # nothing to preserve, and without this search silently 

1320 # returns [] forever (get_rag_stats still reports 100% 

1321 # indexed and a normal reindex short-circuits on 

1322 # indexed=True, so nothing ever recovers it). 

1323 logger.warning( 

1324 f"RAG index file for collection {collection_id} is " 

1325 f"MISSING at {index_path} while the collection is marked " 

1326 "indexed — resetting indexed state so it rebuilds " 

1327 "(search would otherwise return no results indefinitely)." 

1328 ) 

1329 self._reset_index_state_for_rebuild( 

1330 collection_id, rag_index 

1331 ) 

1332 elif reset_stale_state: 1332 ↛ 1337line 1332 didn't jump to line 1337 because the condition on line 1332 was always true

1333 self._reset_index_state_for_rebuild( 

1334 collection_id, rag_index 

1335 ) 

1336 

1337 return index_path 

1338 

1339 def _get_vector_index( 

1340 self, 

1341 collection_id: str, 

1342 collection_name: str, 

1343 *, 

1344 reset_stale_state: bool = False, 

1345 ) -> VectorIndex: 

1346 """Build a :class:`VectorIndex` for ``collection_id``. 

1347 

1348 Single call site for the index/search/delete paths: runs the 

1349 verify/quarantine/dimension-check pre-flight, then constructs the 

1350 facade. On a ``ValueError``/``RuntimeError`` from the underlying 

1351 store's ``load()`` (corrupt bytes, a foreign/pre-migration format 

1352 that isn't an ``IndexIDMap2``, or a dimension mismatch the 

1353 pre-flight didn't already catch) the corrupt file is quarantined, 

1354 stale per-document indexed state is reset, and construction is 

1355 retried once as a fresh, empty store. 

1356 

1357 NOTE: like ``_get_or_create_rag_index``, this CREATES a RAGIndex 

1358 row if none exists for the configured (collection, embedding model) 

1359 — callers on a delete-only path that must not create an index 

1360 (e.g. ``remove_documents_from_index``) guard with their own 

1361 existence check before calling this. 

1362 """ 

1363 # Only WRITE paths (reset_stale_state=True) may re-promote a reused 

1364 # index to is_current; a read-path search must not flip the pointer. 

1365 rag_index = self._get_or_create_rag_index( 

1366 collection_id, promote_current=reset_stale_state 

1367 ) 

1368 self.rag_index_record = rag_index 

1369 index_path = self._preflight_index_path( 

1370 collection_id, rag_index, reset_stale_state=reset_stale_state 

1371 ) 

1372 lock = _get_faiss_write_lock(self.username, str(index_path)) 

1373 

1374 # nested=True: these run reentrantly inside VectorIndex.apply(), which 

1375 # holds just-flushed, uncommitted DocumentChunk rows on this same 

1376 # thread-local session. The integrity write must run in a SAVEPOINT and 

1377 # let apply()'s caller commit — a plain commit/rollback here would 

1378 # settle or DISCARD those pending rows (silently losing indexed text 

1379 # while the vectors persist). See FileIntegrityManager._integrity_write. 

1380 def _record(path: Path) -> None: 

1381 self.integrity_manager.record_file( 

1382 path, 

1383 related_entity_type="rag_index", 

1384 related_entity_id=rag_index.id, 

1385 nested=True, 

1386 ) 

1387 

1388 def _verify(path: Path): 

1389 return self.integrity_manager.verify_file(path, nested=True) 

1390 

1391 kwargs = { 

1392 "username": self.username, 

1393 "db_password": self.db_password, 

1394 # Lazy: index()/search() resolve the real backend on first embed_* 

1395 # call (identical behaviour, one extra attribute hop), while the 

1396 # delete-only path (remove_documents_from_index) never materialises 

1397 # it at all. See _LazyEmbeddings. 

1398 "embeddings": _LazyEmbeddings(self.embedding_manager), 

1399 "embedding_model": self.embedding_model, 

1400 "embedding_model_type": EmbeddingProvider(self.embedding_provider), 

1401 "collection_name": collection_name, 

1402 "dimension": rag_index.embedding_dimension, 

1403 "path": index_path, 

1404 "lock": lock, 

1405 "integrity_record": _record, 

1406 "integrity_verify": _verify, 

1407 # Use the STORED index config, not this service instance's. The 

1408 # RAGIndex row is stamped with index_type/metric/normalize at 

1409 # creation (see _get_or_create_rag_index); the constructing 

1410 # LibraryRAGService may carry different CURRENT defaults (the 

1411 # factory falls back to global settings for a collection whose 

1412 # metadata was never stored). Building against self.* would load a 

1413 # real HNSW file while labelling it "flat" (or vice-versa), and 

1414 # FaissVectorStore.supports_delete trusts the label — routing a 

1415 # removal through remove_ids on an HNSW index (unsupported → raises) 

1416 # or silently rewriting a flat file as HNSW. This mirrors the fix 

1417 # already applied to purge_document_vectors below. 

1418 "index_type": rag_index.index_type or self.index_type, 

1419 "metric": rag_index.distance_metric or self.distance_metric, 

1420 "normalize": ( 

1421 rag_index.normalize_vectors 

1422 if rag_index.normalize_vectors is not None 

1423 else self.normalize_vectors 

1424 ), 

1425 } 

1426 try: 

1427 return VectorIndex(**kwargs) 

1428 except (ValueError, RuntimeError): 

1429 if not reset_stale_state: 

1430 # Read paths (search) must stay side-effect-free. A transient 

1431 # load failure (OOM, a disk hiccup, a momentarily-locked file) 

1432 # on an existing, byte-healthy index must NOT quarantine the 

1433 # file or wipe the collection's indexed state — that would turn 

1434 # a retryable blip into a forced full re-embed of the whole 

1435 # collection triggered by a mere search. Surface the error; 

1436 # quarantine + rebuild is a WRITE-path (reset_stale_state=True) 

1437 # decision. (A never-indexed collection never reaches here — no 

1438 # file exists, so VectorIndex.create() succeeds above.) 

1439 raise 

1440 # A load failure here — after _preflight_index_path's verify_file 

1441 # just PASSED on the same bytes — is ambiguous: it is either a 

1442 # genuine format/dimension mismatch (deterministic — needs a rebuild) 

1443 # OR a transient OS hiccup (EMFILE under concurrent multi-user load, 

1444 # a momentary AV-scan/backup file lock; the byte-healthy file loads 

1445 # fine moments later). faiss surfaces BOTH as the same RuntimeError, 

1446 # so quarantining unconditionally destroys a provably-healthy index 

1447 # and forces a needless full re-embed on a mere transient blip. 

1448 # Distinguish by RETRYING the load once: a transient failure clears, 

1449 # a deterministic one repeats. Only quarantine when the retry also 

1450 # fails. (Mirrors _preflight_index_path's transient-vs-tamper split.) 

1451 logger.warning( 

1452 f"Failed to load vector store at {index_path}; retrying once " 

1453 "before quarantining (may be a transient OS hiccup)" 

1454 ) 

1455 try: 

1456 return VectorIndex(**kwargs) 

1457 except (ValueError, RuntimeError): 

1458 pass 

1459 logger.warning( 

1460 f"Vector store at {index_path} failed to load twice; " 

1461 "quarantining and rebuilding fresh" 

1462 ) 

1463 with lock: 

1464 if index_path.exists(): 

1465 self._quarantine_corrupt_index( 

1466 index_path, "vector_index_load_failed" 

1467 ) 

1468 self._reset_index_state_for_rebuild(collection_id, rag_index) 

1469 return VectorIndex(**kwargs) 

1470 

1471 def search( 

1472 self, query: str, collection_id: str, top_k: int 

1473 ) -> List[SearchResult]: 

1474 """Semantic search within ``collection_id`` via the vector store. 

1475 

1476 Runs the same verify/quarantine/dimension pre-flight as indexing 

1477 (through ``_get_vector_index``), then delegates to 

1478 ``VectorIndex.search``. ``reset_stale_state`` is left at its 

1479 default False — this is a read path: a transient integrity hiccup 

1480 during a mere search must not wipe the collection's indexed state 

1481 and force a full re-embed. 

1482 

1483 Callers are expected to have already confirmed the collection has 

1484 indexed documents (e.g. via ``get_rag_stats``) before calling this 

1485 — this method will otherwise create an empty RAGIndex/store for a 

1486 never-indexed (collection, embedding model) pair and return no 

1487 results, rather than raising. 

1488 """ 

1489 collection_name = f"collection_{collection_id}" 

1490 vindex = self._get_vector_index(collection_id, collection_name) 

1491 return vindex.search(query, top_k) 

1492 

1493 def get_current_index_info( 

1494 self, collection_id: Optional[str] = None 

1495 ) -> Optional[Dict[str, Any]]: 

1496 """ 

1497 Get information about the current RAG index for a collection. 

1498 

1499 Args: 

1500 collection_id: UUID of collection (defaults to Library if None) 

1501 """ 

1502 with get_user_db_session(self.username, self.db_password) as session: 

1503 # Get collection name in the format stored in RAGIndex (collection_<uuid>) 

1504 if collection_id: 

1505 collection = ( 

1506 session.query(Collection) 

1507 .filter_by(id=collection_id) 

1508 .first() 

1509 ) 

1510 collection_name = ( 

1511 f"collection_{collection_id}" if collection else "unknown" 

1512 ) 

1513 else: 

1514 # Default to Library collection 

1515 from ...database.library_init import get_default_library_id 

1516 

1517 collection_id = get_default_library_id( 

1518 self.username, self.db_password 

1519 ) 

1520 collection_name = f"collection_{collection_id}" 

1521 

1522 rag_index = ( 

1523 session.query(RAGIndex) 

1524 .filter_by(collection_name=collection_name, is_current=True) 

1525 .first() 

1526 ) 

1527 

1528 if not rag_index: 

1529 # Debug: check all RAG indices for this collection 

1530 all_indices = session.query(RAGIndex).all() 

1531 logger.info( 

1532 f"No RAG index found for collection_name='{collection_name}'. All indices: {[(idx.collection_name, idx.is_current) for idx in all_indices]}" 

1533 ) 

1534 return None 

1535 

1536 # Calculate actual counts from rag_document_status table 

1537 from ...database.models.library import RagDocumentStatus 

1538 

1539 actual_chunk_count = ( 

1540 session.query(func.sum(RagDocumentStatus.chunk_count)) 

1541 .filter_by(collection_id=collection_id) 

1542 .scalar() 

1543 or 0 

1544 ) 

1545 

1546 actual_doc_count = ( 

1547 session.query(RagDocumentStatus) 

1548 .filter_by(collection_id=collection_id) 

1549 .count() 

1550 ) 

1551 

1552 return { 

1553 "embedding_model": rag_index.embedding_model, 

1554 "embedding_model_type": rag_index.embedding_model_type.value 

1555 if rag_index.embedding_model_type 

1556 else None, 

1557 "embedding_dimension": rag_index.embedding_dimension, 

1558 "chunk_size": rag_index.chunk_size, 

1559 "chunk_overlap": rag_index.chunk_overlap, 

1560 "chunk_count": actual_chunk_count, 

1561 "total_documents": actual_doc_count, 

1562 "created_at": rag_index.created_at.isoformat(), 

1563 "last_updated_at": rag_index.last_updated_at.isoformat(), 

1564 } 

1565 

1566 def _flag_document_for_reindex( 

1567 self, session, document_id: str, collection_id: str 

1568 ) -> None: 

1569 """Mark a document NOT indexed so the reconciler re-embeds it. 

1570 

1571 Used on index_document failure paths: ``VectorIndex.index()`` runs 

1572 ``store.apply()`` — DURABLY mutating the FAISS file — BEFORE the DB 

1573 commit, so a failure/rollback after that point can leave the source's 

1574 prior chunk rows resurrected with no vectors (silently unsearchable) 

1575 while ``DocumentCollection.indexed`` is reverted to its pre-attempt 

1576 state. The background reconciler only revisits ``indexed=False`` rows, 

1577 so without this flag the document stays permanently, silently 

1578 unsearchable. Best-effort + committed in its own statement; 

1579 index_document owns per-document commits (see index_all_documents), so 

1580 this clobbers no caller. 

1581 """ 

1582 try: 

1583 session.query(DocumentCollection).filter_by( 

1584 document_id=document_id, collection_id=collection_id 

1585 ).update( 

1586 {"indexed": False, "chunk_count": 0}, 

1587 synchronize_session=False, 

1588 ) 

1589 session.query(RagDocumentStatus).filter_by( 

1590 document_id=document_id, collection_id=collection_id 

1591 ).delete(synchronize_session=False) 

1592 session.commit() 

1593 except Exception: 

1594 safe_rollback( 

1595 session, "library_rag_service._flag_document_for_reindex" 

1596 ) 

1597 logger.warning( 

1598 f"Could not flag document {document_id} for re-index after a " 

1599 f"failed index of collection {collection_id}; a manual reindex " 

1600 "may be needed to restore its search." 

1601 ) 

1602 

1603 @contextmanager 

1604 def _collection_transaction_lock(self, collection_id: str): 

1605 """Serialize the DB+FAISS transaction for one collection. 

1606 

1607 FAISS already uses this same re-entrant lock for reload/apply/persist and 

1608 integrity recording. Acquiring it around the complete document operation 

1609 also closes the store-persist-before-SQLCipher-commit window to sibling 

1610 workers: no worker can verify/quarantine or mutate the file until the 

1611 preceding worker's database commit (or rollback/reindex flag) finishes. 

1612 """ 

1613 collection_name = f"collection_{collection_id}" 

1614 index_hash = self._get_index_hash( 

1615 collection_name, self.embedding_model, self.embedding_provider 

1616 ) 

1617 index_path = self._get_index_path(index_hash) 

1618 with _hold_faiss_write_lock(self.username, str(index_path)) as lock: 

1619 yield lock 

1620 

1621 def _prepare_document( 

1622 self, document_id: str, collection_id: str, force_reindex: bool 

1623 ) -> Dict[str, Any]: 

1624 """Load, split, and embed without mutating collection state.""" 

1625 with get_user_db_session(self.username, self.db_password) as session: 

1626 document = session.query(Document).filter_by(id=document_id).first() 

1627 if not document: 1627 ↛ 1628line 1627 didn't jump to line 1628 because the condition on line 1627 was never true

1628 return {"status": "error", "error": "Document not found"} 

1629 link = ( 

1630 session.query(DocumentCollection) 

1631 .filter_by(document_id=document_id, collection_id=collection_id) 

1632 .first() 

1633 ) 

1634 if link is not None and link.indexed and not force_reindex: 1634 ↛ 1635line 1634 didn't jump to line 1635 because the condition on line 1634 was never true

1635 return { 

1636 "status": "skipped", 

1637 "message": "Document already indexed for this collection", 

1638 "chunk_count": link.chunk_count, 

1639 } 

1640 if not document.text_content: 1640 ↛ 1641line 1640 didn't jump to line 1641 because the condition on line 1640 was never true

1641 return {"status": "needs_serial_write"} 

1642 

1643 title = document.title or document.filename or "Untitled" 

1644 metadata = { 

1645 "source": document.original_url, 

1646 "document_id": document_id, 

1647 "collection_id": collection_id, 

1648 "document_title": title, 

1649 "title": title, 

1650 "authors": document.authors, 

1651 "published_date": str(document.published_date) 

1652 if document.published_date 

1653 else None, 

1654 "doi": document.doi, 

1655 "arxiv_id": document.arxiv_id, 

1656 "pmid": document.pmid, 

1657 "pmcid": document.pmcid, 

1658 "extraction_method": document.extraction_method, 

1659 "word_count": document.word_count, 

1660 } 

1661 source_doc = LangchainDocument( 

1662 page_content=document.text_content, metadata=metadata 

1663 ) 

1664 

1665 chunks = self.text_splitter.split_documents([source_doc]) 

1666 chunk_inputs = [ 

1667 ChunkInput( 

1668 text=chunk.page_content, 

1669 metadata={**metadata, "chunk_index": i}, 

1670 ) 

1671 for i, chunk in enumerate(chunks) 

1672 ] 

1673 vectors = np.asarray( 

1674 self.embedding_manager.embeddings.embed_documents( 

1675 [chunk.text for chunk in chunk_inputs] 

1676 ), 

1677 dtype="float32", 

1678 ) 

1679 logger.info( 

1680 f"Prepared document {document_id}: {len(chunk_inputs)} chunks embedded" 

1681 ) 

1682 return { 

1683 "status": "prepared", 

1684 "prepared": _PreparedDocument( 

1685 document_id=document_id, 

1686 collection_id=collection_id, 

1687 chunk_inputs=chunk_inputs, 

1688 vectors=vectors, 

1689 ), 

1690 } 

1691 

1692 def _write_prepared_document( 

1693 self, prepared: _PreparedDocument 

1694 ) -> Dict[str, Any]: 

1695 """Serialize chunk rows, FAISS mutation, status updates, and commit.""" 

1696 document_id = prepared.document_id 

1697 collection_id = prepared.collection_id 

1698 chunks = prepared.chunk_inputs 

1699 index_hash = self._get_index_hash( 

1700 f"collection_{collection_id}", 

1701 self.embedding_model, 

1702 self.embedding_provider, 

1703 ) 

1704 index_path = self._get_index_path(index_hash) 

1705 with _hold_faiss_write_lock(self.username, str(index_path)): 

1706 with get_user_db_session( 

1707 self.username, self.db_password 

1708 ) as session: 

1709 try: 

1710 ensure_in_collection(session, document_id, collection_id) 

1711 collection_name = f"collection_{collection_id}" 

1712 vindex = self._get_vector_index( 

1713 collection_id, collection_name, reset_stale_state=True 

1714 ) 

1715 stats = vindex.index_prepared( 

1716 source_type="document", 

1717 source_id=document_id, 

1718 chunks=chunks, 

1719 vectors=prepared.vectors, 

1720 replace=True, 

1721 session=session, 

1722 ) 

1723 

1724 timestamp = datetime.now(UTC) 

1725 existing = ( 

1726 session.query(RagDocumentStatus) 

1727 .filter_by( 

1728 document_id=document_id, 

1729 collection_id=collection_id, 

1730 rag_index_id=self.rag_index_record.id, 

1731 ) 

1732 .first() 

1733 ) 

1734 old_chunks = existing.chunk_count if existing else 0 

1735 session.merge( 

1736 RagDocumentStatus( 

1737 document_id=document_id, 

1738 collection_id=collection_id, 

1739 rag_index_id=self.rag_index_record.id, 

1740 chunk_count=len(chunks), 

1741 indexed_at=timestamp, 

1742 ) 

1743 ) 

1744 session.query(DocumentCollection).filter_by( 

1745 document_id=document_id, collection_id=collection_id 

1746 ).update( 

1747 { 

1748 "indexed": True, 

1749 "chunk_count": len(chunks), 

1750 "last_indexed_at": timestamp, 

1751 } 

1752 ) 

1753 rag_index = ( 

1754 session.query(RAGIndex) 

1755 .filter_by(id=self.rag_index_record.id) 

1756 .first() 

1757 ) 

1758 if rag_index: 1758 ↛ 1763line 1758 didn't jump to line 1763 because the condition on line 1758 was always true

1759 rag_index.chunk_count += len(chunks) - old_chunks 

1760 if existing is None: 

1761 rag_index.total_documents += 1 

1762 rag_index.last_updated_at = timestamp 

1763 session.commit() 

1764 logger.info( 

1765 f"Persisted prepared document {document_id}: " 

1766 f"{stats.added} vectors added" 

1767 ) 

1768 return {"status": "success", "chunk_count": len(chunks)} 

1769 except Exception as exc: 

1770 safe_rollback( 

1771 session, 

1772 "library_rag_service._write_prepared_document", 

1773 ) 

1774 self._flag_document_for_reindex( 

1775 session, document_id, collection_id 

1776 ) 

1777 logger.exception( 

1778 f"Error writing prepared document {document_id}" 

1779 ) 

1780 return { 

1781 "status": "error", 

1782 "error": f"Operation failed: {type(exc).__name__}", 

1783 } 

1784 

1785 def index_document( 

1786 self, document_id: str, collection_id: str, force_reindex: bool = False 

1787 ) -> Dict[str, Any]: 

1788 collection_name = f"collection_{collection_id}" 

1789 index_hash = self._get_index_hash( 

1790 collection_name, self.embedding_model, self.embedding_provider 

1791 ) 

1792 index_path = self._get_index_path(index_hash) 

1793 with _hold_faiss_write_lock(self.username, str(index_path)): 

1794 return self._index_document_locked( 

1795 document_id, collection_id, force_reindex 

1796 ) 

1797 

1798 def _index_one( 

1799 self, document_id: str, collection_id: str, force_reindex: bool 

1800 ) -> Dict[str, Any]: 

1801 """Single-doc dispatch seam used by :meth:`index_documents_parallel`. 

1802 

1803 Default implementation delegates to :meth:`index_document` so production 

1804 behaviour is unchanged. Tests and subclasses override this method to 

1805 short-circuit the full per-doc pipeline (which would require mocking 

1806 every internal collaborator) without losing the prepared/serialized 

1807 pipeline in production. 

1808 

1809 The previous parallel runner detected overrides via 

1810 ``"index_document" in self.__dict__`` — that only honoured 

1811 instance-attribute patches and silently bypassed class-level subclass 

1812 overrides. The runner now compares the bound method against the base 

1813 class attribute so both instance patches and subclass overrides are 

1814 honoured. 

1815 """ 

1816 return self.index_document(document_id, collection_id, force_reindex) 

1817 

1818 def _index_document_locked( 

1819 self, document_id: str, collection_id: str, force_reindex: bool = False 

1820 ) -> Dict[str, Any]: 

1821 """ 

1822 Index a single document into RAG for a specific collection. 

1823 

1824 Args: 

1825 document_id: UUID of the Document to index 

1826 collection_id: UUID of the Collection to index for 

1827 force_reindex: Whether to force reindexing even if already indexed 

1828 

1829 Returns: 

1830 Dict with status, chunk_count, and any errors 

1831 """ 

1832 with get_user_db_session(self.username, self.db_password) as session: 

1833 # Get the document 

1834 document = session.query(Document).filter_by(id=document_id).first() 

1835 

1836 if not document: 

1837 return {"status": "error", "error": "Document not found"} 

1838 

1839 # Get or create DocumentCollection entry 

1840 doc_collection = ensure_in_collection( 

1841 session, document_id, collection_id 

1842 ) 

1843 

1844 # Check if already indexed for this collection 

1845 if doc_collection.indexed and not force_reindex: 

1846 return { 

1847 "status": "skipped", 

1848 "message": "Document already indexed for this collection", 

1849 "chunk_count": doc_collection.chunk_count, 

1850 } 

1851 

1852 # Validate text content 

1853 if not document.text_content: 

1854 # A previously-indexed document now cleared to empty text (e.g. 

1855 # a note edited down to whitespace) must have its stale 

1856 # chunks/vectors PURGED, not left searchable. The note-edit path 

1857 # already set indexed=False, so detect prior chunks directly and 

1858 # remove them via the facade's empty-replace. 

1859 collection_name = f"collection_{collection_id}" 

1860 has_prior = ( 

1861 session.query(DocumentChunk.id) 

1862 .filter_by( 

1863 source_type="document", 

1864 source_id=document_id, 

1865 collection_name=collection_name, 

1866 ) 

1867 .first() 

1868 is not None 

1869 ) 

1870 if has_prior: 1870 ↛ 1879line 1870 didn't jump to line 1879 because the condition on line 1870 was never true

1871 # Same store-durable-before-DB-commit hazard as the main 

1872 # reindex path below, but this branch sits OUTSIDE that 

1873 # path's try/except: the empty-replace apply() DURABLY 

1874 # removes the prior vectors before the commit here. On 

1875 # failure, roll back and flag the document for re-index so 

1876 # the reconciler re-runs the purge instead of leaving 

1877 # resurrected rows whose vectors are gone (silently 

1878 # unsearchable) marked indexed. 

1879 try: 

1880 vindex = self._get_vector_index( 

1881 collection_id, 

1882 collection_name, 

1883 reset_stale_state=True, 

1884 ) 

1885 vindex.index( 

1886 source_type="document", 

1887 source_id=document_id, 

1888 chunks=[], 

1889 replace=True, 

1890 session=session, 

1891 ) 

1892 doc_collection.indexed = False 

1893 doc_collection.chunk_count = 0 

1894 # Clear the canonical RagDocumentStatus row too. Row 

1895 # existence is the "indexed" marker get_rag_stats / the 

1896 # RAG status route read, so it MUST move with 

1897 # DocumentCollection.indexed — a leftover row would 

1898 # report this just-purged (now empty, unsearchable) 

1899 # document as still indexed with a stale chunk_count. 

1900 # Mirrors remove_document_from_rag and the three sibling 

1901 # not-indexed paths; the cleared branch was the lone 

1902 # exception. 

1903 session.query(RagDocumentStatus).filter_by( 

1904 document_id=document_id, collection_id=collection_id 

1905 ).delete(synchronize_session=False) 

1906 session.commit() 

1907 except Exception as e: 

1908 safe_rollback( 

1909 session, 

1910 "library_rag_service.index_document cleared", 

1911 ) 

1912 self._flag_document_for_reindex( 

1913 session, document_id, collection_id 

1914 ) 

1915 logger.exception( 

1916 f"Error purging cleared document {document_id} " 

1917 f"for collection {collection_id}" 

1918 ) 

1919 return { 

1920 "status": "error", 

1921 "error": f"Operation failed: {type(e).__name__}", 

1922 } 

1923 return { 

1924 "status": "cleared", 

1925 "message": ( 

1926 "Document has no text content; prior chunks/" 

1927 "vectors purged" 

1928 ), 

1929 "chunk_count": 0, 

1930 } 

1931 return { 

1932 "status": "error", 

1933 "error": "Document has no text content", 

1934 } 

1935 

1936 # Replace-on-reindex context: a document being re-indexed (e.g. 

1937 # a note whose content changed) has prior chunks with DIFFERENT 

1938 # content hashes from the new text. Left alone they accumulate 

1939 # in both the DB and the vector store forever — unbounded 

1940 # per-edit growth plus stale duplicate search hits. 

1941 # ``VectorIndex.index(replace=True)`` below handles this: it 

1942 # looks up the source's prior DocumentChunk rows (authoritative, 

1943 # queried fresh — not from an ephemeral pre-committed snapshot), 

1944 # applies the vector-store removal durably, then deletes those 

1945 # rows in the SAME transaction this method already holds open 

1946 # (so a failure rolls back together with everything else, never 

1947 # orphaning rows or poisoning the shared session). 

1948 

1949 try: 

1950 # Create LangChain Document from text 

1951 doc = LangchainDocument( 

1952 page_content=document.text_content, 

1953 metadata={ 

1954 "source": document.original_url, 

1955 "document_id": document_id, # Add document ID for source linking 

1956 "collection_id": collection_id, # Add collection ID 

1957 "title": document.title 

1958 or document.filename 

1959 or "Untitled", 

1960 "document_title": document.title 

1961 or document.filename 

1962 or "Untitled", # Add for compatibility 

1963 "authors": document.authors, 

1964 "published_date": str(document.published_date) 

1965 if document.published_date 

1966 else None, 

1967 "doi": document.doi, 

1968 "arxiv_id": document.arxiv_id, 

1969 "pmid": document.pmid, 

1970 "pmcid": document.pmcid, 

1971 "extraction_method": document.extraction_method, 

1972 "word_count": document.word_count, 

1973 }, 

1974 ) 

1975 

1976 # Split into chunks 

1977 chunks = self.text_splitter.split_documents([doc]) 

1978 logger.info( 

1979 f"Split document {document_id} into {len(chunks)} chunks" 

1980 ) 

1981 

1982 # Get collection name for chunk storage 

1983 collection = ( 

1984 session.query(Collection) 

1985 .filter_by(id=collection_id) 

1986 .first() 

1987 ) 

1988 # Use collection_<uuid> format for internal storage 

1989 collection_name = ( 

1990 f"collection_{collection_id}" if collection else "unknown" 

1991 ) 

1992 

1993 document_title = ( 

1994 document.title or document.filename or "Untitled" 

1995 ) 

1996 # Mirrors the parent doc's metadata dict built above (source, 

1997 # ids, bibliographic fields) so it survives into each 

1998 # chunk's ``DocumentChunk.document_metadata`` — this goes 

1999 # ONLY to the encrypted DB (never the vector store; see the 

2000 # SECURITY INVARIANT in vector_stores/base.py) and future- 

2001 # proofs citation/UI features that read per-chunk metadata. 

2002 shared_chunk_metadata = { 

2003 "source": document.original_url, 

2004 "document_id": document_id, 

2005 "collection_id": collection_id, 

2006 "document_title": document_title, 

2007 "title": document_title, 

2008 "authors": document.authors, 

2009 "published_date": str(document.published_date) 

2010 if document.published_date 

2011 else None, 

2012 "doi": document.doi, 

2013 "arxiv_id": document.arxiv_id, 

2014 "pmid": document.pmid, 

2015 "pmcid": document.pmcid, 

2016 "extraction_method": document.extraction_method, 

2017 "word_count": document.word_count, 

2018 } 

2019 chunk_inputs = [ 

2020 ChunkInput( 

2021 text=chunk.page_content, 

2022 metadata={ 

2023 **shared_chunk_metadata, 

2024 "chunk_index": i, 

2025 }, 

2026 ) 

2027 for i, chunk in enumerate(chunks) 

2028 ] 

2029 

2030 # Embed + persist via the new int-id-keyed vector store — 

2031 # this writes DocumentChunk rows (text only in the encrypted 

2032 # DB) and the vector-store's ids/vectors (never text; see 

2033 # the SECURITY INVARIANT in vector_stores/base.py). Passing 

2034 # ``session`` means the facade does not commit/rollback — 

2035 # this transaction still owns that (below). 

2036 # 

2037 # replace=True ALWAYS: a full-document (re)index owns all of a 

2038 # source's chunks, so it must drop the source's prior 

2039 # chunks/vectors before adding the new ones. force_reindex is 

2040 # NOT a safe proxy: a note edit clears the indexed STATUS 

2041 # (_mark_note_stale_for_reindex_in_session) but leaves the 

2042 # pre-edit chunk rows/vectors in place, so the subsequent 

2043 # non-forced reindex would reach here with force_reindex=False 

2044 # AND prior chunks present — appending would leave stale/deleted 

2045 # note text permanently searchable and duplicate every chunk. 

2046 # For a genuinely never-indexed source, prior_ids is empty and 

2047 # replace is a no-op, so this is always safe. 

2048 vindex = self._get_vector_index( 

2049 collection_id, collection_name, reset_stale_state=True 

2050 ) 

2051 index_stats = vindex.index( 

2052 source_type="document", 

2053 source_id=document_id, 

2054 chunks=chunk_inputs, 

2055 replace=True, 

2056 session=session, 

2057 ) 

2058 logger.info( 

2059 f"Vector index update for document {document_id}: " 

2060 f"{index_stats.added} added, {index_stats.removed} " 

2061 f"removed ({index_stats.chunks} chunks total)" 

2062 ) 

2063 

2064 # Check if document was already indexed (for stats update) 

2065 existing_status = ( 

2066 session.query(RagDocumentStatus) 

2067 .filter_by( 

2068 document_id=document_id, 

2069 collection_id=collection_id, 

2070 rag_index_id=self.rag_index_record.id, 

2071 ) 

2072 .first() 

2073 ) 

2074 was_already_indexed = existing_status is not None 

2075 old_chunk_count = ( 

2076 (existing_status.chunk_count or 0) if existing_status else 0 

2077 ) 

2078 

2079 # Mark document as indexed using rag_document_status table 

2080 # Row existence = indexed, simple and clean 

2081 timestamp = datetime.now(UTC) 

2082 

2083 # Create or update RagDocumentStatus using ORM merge (atomic upsert) 

2084 rag_status = RagDocumentStatus( 

2085 document_id=document_id, 

2086 collection_id=collection_id, 

2087 rag_index_id=self.rag_index_record.id, 

2088 chunk_count=len(chunks), 

2089 indexed_at=timestamp, 

2090 ) 

2091 session.merge(rag_status) 

2092 

2093 logger.info( 

2094 f"Marked document as indexed in rag_document_status: doc_id={document_id}, coll_id={collection_id}, chunks={len(chunks)}" 

2095 ) 

2096 

2097 # Also update DocumentCollection table for backward compatibility 

2098 session.query(DocumentCollection).filter_by( 

2099 document_id=document_id, collection_id=collection_id 

2100 ).update( 

2101 { 

2102 "indexed": True, 

2103 "chunk_count": len(chunks), 

2104 "last_indexed_at": timestamp, 

2105 } 

2106 ) 

2107 

2108 logger.info( 

2109 "Also updated DocumentCollection.indexed for backward compatibility" 

2110 ) 

2111 

2112 # Keep the aggregate synchronized on both first index and 

2113 # replacement reindex. A replacement changes the per-document 

2114 # row above, so apply its new-old delta instead of skipping the 

2115 # aggregate update entirely. 

2116 rag_index_obj = ( 

2117 session.query(RAGIndex) 

2118 .filter_by(id=self.rag_index_record.id) 

2119 .first() 

2120 ) 

2121 if rag_index_obj: 2121 ↛ 2140line 2121 didn't jump to line 2140 because the condition on line 2121 was always true

2122 chunk_delta = len(chunks) - old_chunk_count 

2123 rag_index_obj.chunk_count += chunk_delta 

2124 if not was_already_indexed: 

2125 rag_index_obj.total_documents += 1 

2126 rag_index_obj.last_updated_at = datetime.now(UTC) 

2127 logger.info( 

2128 "Updated RAGIndex stats: " 

2129 f"chunk_count {chunk_delta:+d}, " 

2130 f"total_documents {'+1' if not was_already_indexed else '+0'}" 

2131 ) 

2132 

2133 # Replace-on-reindex DB prune: no longer needed here — 

2134 # ``VectorIndex.index(replace=True)`` above already deletes 

2135 # this source's prior DocumentChunk rows (and their 

2136 # vectors) as part of the same transaction/lock when 

2137 # ``force_reindex`` is set. 

2138 

2139 # Flush ORM changes to database before commit 

2140 session.flush() 

2141 logger.info(f"Flushed ORM changes for document {document_id}") 

2142 

2143 # Commit the transaction. Durability is provided by 

2144 # synchronous=NORMAL (sqlcipher_utils.py); SQLite 

2145 # auto-checkpoints WAL at wal_autocheckpoint=250 frames. 

2146 # An explicit PRAGMA wal_checkpoint(FULL) here used to 

2147 # block other writers long enough to exhaust busy_timeout 

2148 # under bulk-download concurrency (#4197). 

2149 session.commit() 

2150 

2151 logger.info( 

2152 f"Successfully indexed document {document_id} for collection {collection_id} " 

2153 f"with {len(chunks)} chunks" 

2154 ) 

2155 

2156 return { 

2157 "status": "success", 

2158 "chunk_count": len(chunks), 

2159 } 

2160 

2161 except Exception as e: 

2162 # The session is shared (thread-local) with the caller. 

2163 # If session.flush() or session.commit() raised, the session 

2164 # is in PendingRollbackError state until rolled back — 

2165 # leaving subsequent operations to cascade. Roll back BEFORE 

2166 # returning the error dict so the caller sees a clean 

2167 # session. (Same pattern as the #3827 fix.) 

2168 safe_rollback(session, "library_rag_service.index_document") 

2169 # Backstop for the store-durable-before-DB-commit window: a 

2170 # REPLACE reindex ran store.apply() (DURABLY removing this 

2171 # source's prior vectors) before the DB commit, so the rollback 

2172 # above resurrects rows whose vectors are already gone while 

2173 # reverting indexed→its pre-attempt True. Flag for re-index so 

2174 # the reconciler re-embeds and store+DB reconverge. 

2175 self._flag_document_for_reindex( 

2176 session, document_id, collection_id 

2177 ) 

2178 logger.exception( 

2179 f"Error indexing document {document_id} for collection {collection_id}" 

2180 ) 

2181 return { 

2182 "status": "error", 

2183 "error": f"Operation failed: {type(e).__name__}", 

2184 } 

2185 

2186 def index_all_documents( 

2187 self, 

2188 collection_id: str, 

2189 force_reindex: bool = False, 

2190 progress_callback=None, 

2191 ) -> Dict[str, Any]: 

2192 """ 

2193 Index all documents in a collection into RAG. 

2194 

2195 Args: 

2196 collection_id: UUID of the collection to index 

2197 force_reindex: Whether to force reindexing already indexed documents 

2198 progress_callback: Optional callback function called after each document with (current, total, doc_title, status) 

2199 

2200 Returns: 

2201 Dict with counts of successful, skipped, and failed documents 

2202 """ 

2203 with get_user_db_session(self.username, self.db_password) as session: 

2204 # Get all DocumentCollection entries for this collection 

2205 query = session.query(DocumentCollection).filter_by( 

2206 collection_id=collection_id 

2207 ) 

2208 

2209 if not force_reindex: 

2210 # Only index documents that haven't been indexed yet 

2211 query = query.filter_by(indexed=False) 

2212 

2213 doc_collections = query.all() 

2214 

2215 if not doc_collections: 

2216 return { 

2217 "status": "info", 

2218 "message": "No documents to index", 

2219 "successful": 0, 

2220 "skipped": 0, 

2221 "failed": 0, 

2222 } 

2223 

2224 results = {"successful": 0, "skipped": 0, "failed": 0, "errors": []} 

2225 total = len(doc_collections) 

2226 

2227 for idx, doc_collection in enumerate(doc_collections, 1): 

2228 # Get the document for title info 

2229 document = ( 

2230 session.query(Document) 

2231 .filter_by(id=doc_collection.document_id) 

2232 .first() 

2233 ) 

2234 title = document.title if document else "Unknown" 

2235 

2236 result = self.index_document( 

2237 doc_collection.document_id, collection_id, force_reindex 

2238 ) 

2239 

2240 if result["status"] == "success": 

2241 results["successful"] += 1 

2242 elif result["status"] in ("skipped", "cleared"): 

2243 # "cleared" (a previously-indexed doc emptied to no text, 

2244 # its stale vectors purged) is a handled non-failure, not an 

2245 # error — counting it in "failed" would surface a blank-error 

2246 # failure for a correct purge. 

2247 results["skipped"] += 1 

2248 else: 

2249 results["failed"] += 1 

2250 results["errors"].append( 

2251 { 

2252 "doc_id": doc_collection.document_id, 

2253 "title": title, 

2254 "error": result.get("error"), 

2255 } 

2256 ) 

2257 

2258 # Call progress callback if provided 

2259 if progress_callback: 

2260 progress_callback(idx, total, title, result["status"]) 

2261 

2262 logger.info( 

2263 f"Indexed collection {collection_id}: " 

2264 f"{results['successful']} successful, " 

2265 f"{results['skipped']} skipped, " 

2266 f"{results['failed']} failed" 

2267 ) 

2268 

2269 return results 

2270 

2271 def remove_document_from_rag( 

2272 self, document_id: str, collection_id: str 

2273 ) -> Dict[str, Any]: 

2274 """ 

2275 Remove a document's chunks from RAG for a specific collection. 

2276 

2277 Args: 

2278 document_id: UUID of the Document to remove 

2279 collection_id: UUID of the Collection to remove from 

2280 

2281 Returns: 

2282 Dict with status and count of removed chunks 

2283 """ 

2284 with get_user_db_session(self.username, self.db_password) as session: 

2285 # Get the DocumentCollection entry 

2286 doc_collection = ( 

2287 session.query(DocumentCollection) 

2288 .filter_by(document_id=document_id, collection_id=collection_id) 

2289 .first() 

2290 ) 

2291 

2292 if not doc_collection: 

2293 return { 

2294 "status": "error", 

2295 "error": "Document not found in collection", 

2296 } 

2297 

2298 try: 

2299 # Get collection name in the format collection_<uuid> 

2300 collection = ( 

2301 session.query(Collection) 

2302 .filter_by(id=collection_id) 

2303 .first() 

2304 ) 

2305 # Use collection_<uuid> format for internal storage 

2306 collection_name = ( 

2307 f"collection_{collection_id}" if collection else "unknown" 

2308 ) 

2309 

2310 # Remove the document's vectors from the FAISS index FIRST. 

2311 # ``remove_documents_from_index`` -> ``VectorIndex.delete`` 

2312 # looks up the DocumentChunk rows by (source_type, source_id, 

2313 # collection_name) to learn which FAISS int ids to remove, 

2314 # and deletes those matching rows itself as part of the same 

2315 # operation. If the DB rows were deleted first, that lookup 

2316 # would find nothing and the vectors (and their persisted 

2317 # text) would be stranded in the FAISS store forever — never 

2318 # removed and never re-discoverable by a later cleanup. 

2319 # Best-effort: a FAISS hiccup must not fail the DB removal 

2320 # below. 

2321 # ``remove_documents_from_index`` deletes the matching chunk 

2322 # rows itself (one row per removed vector) and returns that 

2323 # count. Capture it so the reported ``deleted_count`` reflects 

2324 # the rows removed here, not just the backstop sweep below: in 

2325 # the normal path those rows are already gone by the time the 

2326 # backstop runs, so the backstop returns ~0 and — left 

2327 # uncounted — would under-report the removal (often as 0) to 

2328 # the caller/UI. 

2329 removed_vectors = 0 

2330 try: 

2331 removed_vectors = self.remove_documents_from_index( 

2332 [document_id], collection_id 

2333 ) 

2334 except Exception: 

2335 logger.exception( 

2336 f"Could not remove document {document_id} vectors " 

2337 f"from FAISS index for collection {collection_id}" 

2338 ) 

2339 

2340 # DB-only cleanup for any rows ``remove_documents_from_index`` 

2341 # did not match (e.g. no current FAISS index for this 

2342 # collection). Disjoint from ``removed_vectors`` above (those 

2343 # rows are already committed-deleted), so the two sum to the 

2344 # true total removed. 

2345 deleted_count = removed_vectors + ( 

2346 self.embedding_manager._delete_chunks_from_db( 

2347 collection_name=collection_name, 

2348 source_id=document_id, 

2349 ) 

2350 ) 

2351 

2352 # Update DocumentCollection RAG status 

2353 doc_collection.indexed = False 

2354 doc_collection.chunk_count = 0 

2355 doc_collection.last_indexed_at = None 

2356 # Clear the canonical RagDocumentStatus row too. Row-existence is 

2357 # the "indexed" marker get_rag_stats / the RAG status route read, 

2358 # so it MUST move together with DocumentCollection.indexed — a 

2359 # leftover row would report the just-removed document as still 

2360 # indexed. 

2361 session.query(RagDocumentStatus).filter_by( 

2362 document_id=document_id, collection_id=collection_id 

2363 ).delete(synchronize_session=False) 

2364 session.commit() 

2365 

2366 logger.info( 

2367 f"Removed {deleted_count} chunks for document {document_id} from collection {collection_id}" 

2368 ) 

2369 

2370 return {"status": "success", "deleted_count": deleted_count} 

2371 

2372 except Exception as e: 

2373 # session.commit() above can raise; without rollback the 

2374 # shared thread-local session stays poisoned for the 

2375 # caller's next operation (issue #3827 pattern). 

2376 safe_rollback( 

2377 session, "library_rag_service.remove_document_from_rag" 

2378 ) 

2379 # Backstop for the store-durable-before-DB-commit window: 

2380 # remove_documents_from_index -> VectorIndex.delete ran 

2381 # store.apply() (DURABLY removing this document's vectors) BEFORE 

2382 # the DB status work above committed, so the rollback can leave 

2383 # the row still marked indexed=True with its vectors already gone 

2384 # -- indexed-but-unsearchable, and over-reported as indexed by 

2385 # get_rag_stats with no auto-recovery. Durably COMPLETE the 

2386 # removal's status change so the DB matches the removed vectors: 

2387 # mark it not-indexed and drop the canonical RagDocumentStatus 

2388 # row. This is a REMOVAL backstop, not a re-index one (contrast 

2389 # index_document's _flag_document_for_reindex) -- the vectors are 

2390 # intentionally gone, so we finish the removal rather than 

2391 # resurrect the document. Best-effort: a retry of the removal 

2392 # reconciles it if this too fails. 

2393 try: 

2394 session.query(DocumentCollection).filter_by( 

2395 document_id=document_id, collection_id=collection_id 

2396 ).update( 

2397 { 

2398 "indexed": False, 

2399 "chunk_count": 0, 

2400 "last_indexed_at": None, 

2401 }, 

2402 synchronize_session=False, 

2403 ) 

2404 session.query(RagDocumentStatus).filter_by( 

2405 document_id=document_id, collection_id=collection_id 

2406 ).delete(synchronize_session=False) 

2407 session.commit() 

2408 except Exception: 

2409 safe_rollback( 

2410 session, 

2411 "library_rag_service.remove_document_from_rag backstop", 

2412 ) 

2413 logger.warning( 

2414 f"Could not finalize removal state for document " 

2415 f"{document_id} in collection {collection_id}; a retry " 

2416 "of the removal will reconcile it." 

2417 ) 

2418 logger.exception( 

2419 f"Error removing document {document_id} from collection {collection_id}" 

2420 ) 

2421 return { 

2422 "status": "error", 

2423 "error": f"Operation failed: {type(e).__name__}", 

2424 } 

2425 

2426 def purge_document_chunks( 

2427 self, document_id: str, collection_id: str 

2428 ) -> int: 

2429 """Delete a document's chunk rows from a collection's RAG store 

2430 WITHOUT requiring the ``DocumentCollection`` join row to still exist. 

2431 

2432 ``remove_document_from_rag`` short-circuits (returns "Document not 

2433 found in collection") when the join row is gone — which is exactly 

2434 the state after a ``Document`` is cascade-deleted. Callers that 

2435 delete the Document first (note deletion) must use this to actually 

2436 purge the now-orphaned ``DocumentChunk`` rows; otherwise the chunks 

2437 (and their embedding ids) linger and semantic search keeps 

2438 resolving hits to a Document row that no longer exists. 

2439 

2440 Mirrors the chunk-delete half of ``remove_document_from_rag``. 

2441 FAISS-vector pruning is NOT done here: pair this with 

2442 ``purge_document_vectors`` when the document is being deleted 

2443 outright — replace-on-reindex can never fire again for a deleted 

2444 document id. Returns the number of chunk rows deleted. 

2445 """ 

2446 collection_name = f"collection_{collection_id}" 

2447 return self.embedding_manager._delete_chunks_from_db( 

2448 collection_name=collection_name, 

2449 source_id=document_id, 

2450 ) 

2451 

2452 def purge_document_vectors( 

2453 self, document_id: str, collection_id: str 

2454 ) -> int: 

2455 """Remove a deleted document's chunk vectors from the collection's 

2456 vector store. 

2457 

2458 Companion to ``purge_document_chunks`` for callers that delete the 

2459 ``Document`` row itself (note deletion). Without this the vectors 

2460 lingered until a replace-on-reindex of the SAME document id — which 

2461 can never fire again once the document is deleted — so collection 

2462 search kept serving the deleted content indefinitely. 

2463 

2464 Routes to ``VectorIndex.delete(source_type="document", 

2465 source_id=document_id)``. Unlike the pre-cutover implementation, 

2466 no separate "shared chunk ownership" check is needed here: the new 

2467 store is one-row-per-source (see the "One row = one vector" note in 

2468 vector_stores/facade.py) — identical text in two documents gets two 

2469 independent rows/vectors, so deleting this document's rows can 

2470 never remove another document's vector. 

2471 

2472 Deliberately does NOT go through ``_get_vector_index`` / 

2473 ``_get_or_create_rag_index``: a deletion path must not create an 

2474 index record (or probe the embedding provider) just to delete from 

2475 it, and does not quarantine/rebuild on a failed load — an 

2476 unreadable index has nothing purgeable right now; quarantine/ 

2477 rebuild decisions stay with the indexing paths. 

2478 

2479 Returns the number of vectors removed. 

2480 """ 

2481 collection_name = f"collection_{collection_id}" 

2482 index_hash = self._get_index_hash( 

2483 collection_name, self.embedding_model, self.embedding_provider 

2484 ) 

2485 with get_user_db_session(self.username, self.db_password) as session: 

2486 rag_index = self._find_matching_rag_index( 

2487 session, collection_name, index_hash 

2488 ) 

2489 if rag_index is None: 

2490 # Collection was never indexed with this configuration — 

2491 # no vectors to purge. Deliberately NOT 

2492 # _get_or_create_rag_index: a deletion path must not 

2493 # create index records (or probe the embedding provider). 

2494 return 0 

2495 self.rag_index_record = rag_index 

2496 

2497 # Resolve where the file ACTUALLY lives, the way the index/search paths 

2498 # do — but without _preflight's side effects (this deletion path must 

2499 # not relocate/record/create). rag_index.index_path is the authoritative 

2500 # DB record (the legacy shared-cache path pre-migration, the per-user 

2501 # path after _preflight relocates it); _get_index_path(index_hash) only 

2502 # recomputes the POST-migration location. Checking only the recomputed 

2503 # path silently no-ops on an un-migrated install — the file is still at 

2504 # the legacy index_path, so the deleted document's real vector survives. 

2505 recomputed_path = self._get_index_path(rag_index.index_hash) 

2506 recorded_path = ( 

2507 Path(rag_index.index_path) if rag_index.index_path else None 

2508 ) 

2509 if recorded_path is not None and recorded_path.exists(): 2509 ↛ 2510line 2509 didn't jump to line 2510 because the condition on line 2509 was never true

2510 index_path = recorded_path 

2511 elif recomputed_path.exists(): 

2512 index_path = recomputed_path 

2513 else: 

2514 return 0 

2515 verified, reason = self.integrity_manager.verify_file(index_path) 

2516 if not verified: 2516 ↛ 2525line 2516 didn't jump to line 2525 because the condition on line 2516 was always true

2517 # Leave quarantine/rebuild decisions to the indexing paths; 

2518 # an unreadable index has nothing purgeable right now. 

2519 logger.warning( 

2520 f"Skipping vector purge for document {document_id}: index " 

2521 f"{index_path} fails verification ({reason})" 

2522 ) 

2523 return 0 

2524 

2525 lock = _get_faiss_write_lock(self.username, str(index_path)) 

2526 

2527 # nested=True: these run reentrantly inside VectorIndex.apply(), which 

2528 # holds just-flushed, uncommitted DocumentChunk rows on this same 

2529 # thread-local session. The integrity write must run in a SAVEPOINT and 

2530 # let apply()'s caller commit — a plain commit/rollback here would 

2531 # settle or DISCARD those pending rows (silently losing indexed text 

2532 # while the vectors persist). See FileIntegrityManager._integrity_write. 

2533 def _record(path: Path) -> None: 

2534 self.integrity_manager.record_file( 

2535 path, 

2536 related_entity_type="rag_index", 

2537 related_entity_id=rag_index.id, 

2538 nested=True, 

2539 ) 

2540 

2541 def _verify(path: Path): 

2542 return self.integrity_manager.verify_file(path, nested=True) 

2543 

2544 try: 

2545 vindex = VectorIndex( 

2546 username=self.username, 

2547 db_password=self.db_password, 

2548 # Delete-only path: never materialise the embedding backend. 

2549 # VectorIndex.delete() resolves rows by id and never calls 

2550 # embed_*; forcing the model here would cost seconds + hundreds 

2551 # of MB of RSS per deleted document for nothing. 

2552 embeddings=_LazyEmbeddings(self.embedding_manager), 

2553 embedding_model=self.embedding_model, 

2554 embedding_model_type=EmbeddingProvider(self.embedding_provider), 

2555 collection_name=collection_name, 

2556 dimension=rag_index.embedding_dimension, 

2557 path=index_path, 

2558 lock=lock, 

2559 integrity_record=_record, 

2560 integrity_verify=_verify, 

2561 # Use the STORED index config, not the service instance's — a 

2562 # service built with defaults (index_type="flat") would evaluate 

2563 # supports_delete wrongly for an HNSW collection and silently 

2564 # fail the removal. NULL (legacy rows) falls back to self.*. 

2565 index_type=rag_index.index_type or self.index_type, 

2566 metric=rag_index.distance_metric or self.distance_metric, 

2567 normalize=( 

2568 rag_index.normalize_vectors 

2569 if rag_index.normalize_vectors is not None 

2570 else self.normalize_vectors 

2571 ), 

2572 ) 

2573 stats = vindex.delete(source_type="document", source_id=document_id) 

2574 except (ValueError, RuntimeError, OSError): 

2575 # OSError too: this is a BEST-EFFORT purge whose caller goes on to 

2576 # delete the DocumentChunk rows. A disk error from the FAISS 

2577 # persist() (OSError) must not escape and abort that row deletion — 

2578 # it would strand the chunk rows while leaving the vectors, the 

2579 # opposite inconsistency. Skip the vector purge and let the caller 

2580 # proceed; the vectors reconcile on the next re-index. 

2581 logger.warning( 

2582 f"Skipping vector purge for document {document_id}: could " 

2583 f"not load or update index {index_path}" 

2584 ) 

2585 return 0 

2586 return stats.removed 

2587 

2588 def index_documents_batch( 

2589 self, 

2590 doc_info: List[tuple], 

2591 collection_id: str, 

2592 force_reindex: bool = False, 

2593 ) -> Dict[str, Dict[str, Any]]: 

2594 """ 

2595 Index multiple documents in a batch for a specific collection. 

2596 

2597 Args: 

2598 doc_info: List of (doc_id, title) tuples 

2599 collection_id: UUID of the collection to index for 

2600 force_reindex: Whether to force reindexing even if already indexed 

2601 

2602 Returns: 

2603 Dict mapping doc_id to individual result 

2604 """ 

2605 results = {} 

2606 doc_ids = [doc_id for doc_id, _ in doc_info] 

2607 

2608 # Use single database session for querying 

2609 with get_user_db_session(self.username, self.db_password) as session: 

2610 # Pre-load all documents for this batch 

2611 documents = ( 

2612 session.query(Document).filter(Document.id.in_(doc_ids)).all() 

2613 ) 

2614 

2615 # Create lookup for quick access 

2616 doc_lookup = {doc.id: doc for doc in documents} 

2617 

2618 # Pre-load DocumentCollection entries 

2619 doc_collections = ( 

2620 session.query(DocumentCollection) 

2621 .filter( 

2622 DocumentCollection.document_id.in_(doc_ids), 

2623 DocumentCollection.collection_id == collection_id, 

2624 ) 

2625 .all() 

2626 ) 

2627 doc_collection_lookup = { 

2628 dc.document_id: dc for dc in doc_collections 

2629 } 

2630 

2631 # Process each document in the batch 

2632 for doc_id, title in doc_info: 

2633 document = doc_lookup.get(doc_id) 

2634 

2635 if not document: 

2636 results[doc_id] = { 

2637 "status": "error", 

2638 "error": "Document not found", 

2639 } 

2640 continue 

2641 

2642 # Check if already indexed via DocumentCollection 

2643 doc_collection = doc_collection_lookup.get(doc_id) 

2644 if ( 

2645 doc_collection 

2646 and doc_collection.indexed 

2647 and not force_reindex 

2648 ): 

2649 results[doc_id] = { 

2650 "status": "skipped", 

2651 "message": "Document already indexed for this collection", 

2652 "chunk_count": doc_collection.chunk_count, 

2653 } 

2654 continue 

2655 

2656 # Do NOT short-circuit empty text_content here: a document/note 

2657 # edited down to empty still has its PRIOR chunks + vectors on 

2658 # disk, and only index_document's empty-content branch purges 

2659 # them (via an empty-replace). Returning an "error" here left 

2660 # that stale (plaintext) content permanently searchable through 

2661 # the bulk "Index All" path — and self-perpetuating, since the 

2662 # doc's indexed flag never flips. Route it through index_document 

2663 # so the batch path gets the same purge-on-clear as the SSE / 

2664 # single-document paths; it returns "cleared" (purged) or the 

2665 # same "error" when there were no prior chunks. 

2666 try: 

2667 result = self.index_document( 

2668 doc_id, collection_id, force_reindex 

2669 ) 

2670 results[doc_id] = result 

2671 except Exception as e: 

2672 logger.exception( 

2673 f"Error indexing document {doc_id} in batch" 

2674 ) 

2675 results[doc_id] = { 

2676 "status": "error", 

2677 "error": f"Indexing failed: {type(e).__name__}", 

2678 } 

2679 

2680 return results 

2681 

2682 def index_documents_parallel( 

2683 self, 

2684 doc_info: List[Tuple[str, str]], 

2685 collection_id: str, 

2686 force_reindex: bool = False, 

2687 max_workers: int = 4, 

2688 progress_callback: Optional[ 

2689 Callable[[int, int, str, str], None] 

2690 ] = None, 

2691 is_cancelled: Optional[Callable[[], bool]] = None, 

2692 ) -> Dict[str, Any]: 

2693 """ 

2694 Index many documents concurrently with bounded fan-out. 

2695 

2696 Each per-document task runs the prepared pipeline 

2697 (:meth:`_prepare_document` for load/split/embed, then the 

2698 coordinator's serialized durable write) unless a subclass overrides 

2699 :meth:`_index_one` or :meth:`index_document`, in which case that 

2700 per-doc path is invoked directly — the per-doc logic (split, embed, 

2701 persist chunks, FAISS-merge-under-lock, mark indexed, update stats) 

2702 is unchanged. The bounded fan-out only changes *which* sequential 

2703 loop dispatches the work: before this method, six call sites in the 

2704 RAG routes (main's ``research_library/routes/rag_routes.py``, now 

2705 :mod:`local_deep_research.web.routers.rag`) and the background 

2706 reconciler in :mod:`scheduler.background` each did this one doc at 

2707 a time. The slow embedding round-trip (outside the FAISS write 

2708 lock) is now overlapped across documents. 

2709 

2710 Concurrency invariants this method relies on (already true): 

2711 

2712 * :meth:`index_document` opens a fresh per-thread DB session via 

2713 :func:`get_user_db_session` — workers do not share sessions. 

2714 * All FAISS mutations go through ``VectorIndex.apply()`` in 

2715 :mod:`local_deep_research.vector_stores`, which is only 

2716 invoked under the per-(username, index_path) write lock 

2717 returned by :func:`_get_faiss_write_lock`. The service passes 

2718 that lock into the vector-store facade at the call sites 

2719 around L422, L879, L1097, and L2025, so concurrent 

2720 ``index_document`` workers serialise their FAISS appends on 

2721 the same lock without any extra coordination here. 

2722 * ``LocalEmbeddingManager.embeddings`` uses double-checked 

2723 locking for first-load. 

2724 * ``is_cancelled`` is polled between completions AND between 

2725 submissions. The bounded-submission loop admits new work into 

2726 the pool only when ``is_cancelled()`` returns False, and 

2727 each :func:`_run_one` worker re-checks ``is_cancelled()`` 

2728 immediately before delegating to ``index_document``. This 

2729 closes the false-poll-to-submit gap (the cancel signal can 

2730 be set between the main thread's poll and ``pool.submit()``) 

2731 so post-cancel starts are zero by construction. The final 

2732 ``is_cancelled()`` poll in the ``finally`` block also 

2733 preserves ``cancelled=True`` in the aggregate when the 

2734 signal lands while the last in-flight worker is running. 

2735 * In-flight embedding round-trips cannot be interrupted (Python 

2736 has no safe way to kill a SentenceTransformer forward pass 

2737 mid-array) — they finish naturally before the helper returns 

2738 via ``pool.shutdown(wait=True)``. 

2739 

2740 Args: 

2741 doc_info: List of ``(doc_id, title)`` pairs to index. 

2742 collection_id: UUID of the collection. 

2743 force_reindex: Forwarded to :meth:`index_document`. 

2744 max_workers: Bound on the size of the 

2745 ``ThreadPoolExecutor`` — i.e. how many documents can be 

2746 **preparing** concurrently (load + split + embed). It does 

2747 NOT bound the prepared-but-unwritten backlog: completed 

2748 futures hold their full vector batches in memory until the 

2749 coordinator's serial write stage picks them up, so for very 

2750 large batches the resident memory footprint can exceed what 

2751 ``max_workers`` alone suggests. Embedding is the slow stage 

2752 in practice, so this is acceptable for the default 4-worker 

2753 fan-out; a caller with many large documents should either 

2754 shrink ``max_workers`` or chunk the submission. ``1`` falls 

2755 back to fully sequential behaviour (still useful when a 

2756 flaky network makes concurrent httpx calls worse than 

2757 serial). Default 4. 

2758 progress_callback: Optional hook called once per completed 

2759 document with ``(completed, total, title, status)`` — 

2760 matches the legacy shape used by 

2761 :meth:`index_all_documents` so existing UI progress 

2762 paths keep working. 

2763 is_cancelled: Optional zero-arg callable polled between 

2764 completions AND between submissions. When it returns 

2765 ``True``, no further work is admitted into the pool; 

2766 in-flight futures finish naturally. The executor is 

2767 always shut down with ``wait=True`` so no worker can 

2768 outlive the caller's service context. 

2769 

2770 Returns: 

2771 ``{"successful": int, "skipped": int, "failed": int, 

2772 "errors": [...], "results": {doc_id: {...}}, 

2773 "cancelled": bool, "total": int}``. 

2774 """ 

2775 if max_workers < 1: 

2776 max_workers = 1 

2777 

2778 total = len(doc_info) 

2779 if total == 0: 

2780 return { 

2781 "successful": 0, 

2782 "skipped": 0, 

2783 "failed": 0, 

2784 "errors": [], 

2785 "results": {}, 

2786 "cancelled": False, 

2787 "total": 0, 

2788 } 

2789 

2790 # Title-lookup keyed by doc_id — futures return in ``as_completed`` 

2791 # order, not submission order, so we cannot rely on positional 

2792 # iteration when emitting progress. 

2793 title_lookup = dict(doc_info) 

2794 

2795 results: Dict[str, Dict[str, Any]] = {} 

2796 counters = {"successful": 0, "skipped": 0, "failed": 0} 

2797 errors: List[Dict[str, Any]] = [] 

2798 cancelled = False 

2799 

2800 def _run_one(doc_id: str) -> Dict[str, Any]: 

2801 """Single-doc worker — never raises; returns a status dict.""" 

2802 try: 

2803 # Three seams tell us to bypass the prepared/serialized 

2804 # pipeline and call the per-doc method directly: 

2805 # 

2806 # 1. A class-level subclass override of ``_index_one`` — 

2807 # detected via 

2808 # ``type(self)._index_one is not LibraryRAGService._index_one`` 

2809 # so it works regardless of whether the override 

2810 # has ``__func__`` (the old 

2811 # ``self._index_one.__func__ is not LibraryRAGService._index_one`` 

2812 # check raised AttributeError on instance-attribute 

2813 # patches that don't expose ``__func__``). 

2814 # 2. A class-level subclass override of the legacy 

2815 # ``index_document`` extension point this seam 

2816 # replaced — callers that override ``index_document`` 

2817 # at class level (not just instance-patch it for 

2818 # tests) still get the per-doc bypass, otherwise they 

2819 # would silently fall through to the prepared 

2820 # pipeline and lose the override. 

2821 # 3. An instance-level patch of ``_index_one`` or 

2822 # ``index_document`` — the legacy seams used by the 

2823 # existing parallel-runner tests that mock the 

2824 # per-doc method directly. 

2825 # 

2826 # If none is present, run the prepared pipeline: split 

2827 # and embed concurrently, then hand the prepared document 

2828 # off to the coordinator for serialized durable writes. 

2829 cls = type(self) 

2830 if cls._index_one is not LibraryRAGService._index_one: 

2831 return self._index_one(doc_id, collection_id, force_reindex) 

2832 if cls.index_document is not LibraryRAGService.index_document: 

2833 return self.index_document( 

2834 doc_id, collection_id, force_reindex 

2835 ) 

2836 if ( 

2837 "_index_one" in self.__dict__ 

2838 or "index_document" in self.__dict__ 

2839 ): 

2840 return self._index_one(doc_id, collection_id, force_reindex) 

2841 prepared_result = self._prepare_document( 

2842 doc_id, collection_id, force_reindex 

2843 ) 

2844 if prepared_result.get("status") == "needs_serial_write": 2844 ↛ 2845line 2844 didn't jump to line 2845 because the condition on line 2844 was never true

2845 return self.index_document( 

2846 doc_id, collection_id, force_reindex 

2847 ) 

2848 return prepared_result 

2849 except Exception as exc: 

2850 # ``index_document`` already returns an error dict on its 

2851 # own internal failures; this catches only bugs / hook 

2852 # breaks outside that contract. 

2853 logger.exception(f"Parallel index worker for {doc_id} crashed") 

2854 return { 

2855 "status": "error", 

2856 "error": f"Indexing failed: {type(exc).__name__}", 

2857 } 

2858 

2859 # De-duplicated submission queue preserving input order. The 

2860 # underlying ``index_document`` is not idempotent at the DB level 

2861 # beyond its own checks; without this guard a buggy caller could 

2862 # double-insert chunks from the same doc. 

2863 pending_doc_ids: List[str] = [] 

2864 seen: Set[str] = set() 

2865 for doc_id, _title in doc_info: 

2866 if doc_id in seen: 

2867 continue 

2868 seen.add(doc_id) 

2869 pending_doc_ids.append(doc_id) 

2870 

2871 pool = ThreadPoolExecutor( 

2872 max_workers=max_workers, 

2873 thread_name_prefix="ldr-index-doc-", 

2874 ) 

2875 try: 

2876 future_to_doc: Dict[Any, str] = {} 

2877 submit_idx = 0 

2878 completed = 0 

2879 # Bounded-submission loop: poll ``is_cancelled`` BEFORE 

2880 # admitting any new work, then submit up to ``max_workers`` 

2881 # futures, then wait for one completion. 

2882 # 

2883 # This closes the post-cancel submission leak in the 

2884 # previous all-upfront submission model: with the old 

2885 # loop, all futures were submitted before the first 

2886 # ``is_cancelled()`` poll, so queued futures could be 

2887 # picked up by idle workers between the caller's cancel 

2888 # signal (e.g. ``_sse_cancel.set()`` in 

2889 # ``web/routers/rag.py::index_collection``) and the helper's 

2890 # ``pool.shutdown(cancel_futures=True)``. Here, new work 

2891 # is admitted only when ``is_cancelled()`` returns False, 

2892 # so post-cancel starts are zero by construction. 

2893 while future_to_doc or submit_idx < len(pending_doc_ids): 

2894 # 1. Cancellation fence: stop submitting new work if 

2895 # cancelled. Once we observe cancellation we don't 

2896 # re-poll — the gate stays closed until the loop 

2897 # drains. 

2898 if ( 

2899 not cancelled 

2900 and is_cancelled is not None 

2901 and is_cancelled() 

2902 ): 

2903 cancelled = True 

2904 logger.info( 

2905 f"Parallel indexing cancelled at {completed}/{total}" 

2906 ) 

2907 for f in future_to_doc: 2907 ↛ 2908line 2907 didn't jump to line 2908 because the loop on line 2907 never started

2908 f.cancel() 

2909 

2910 # 2. Admit new work up to ``max_workers`` in flight 

2911 # (skipped once cancelled). 

2912 if not cancelled: 

2913 while len(future_to_doc) < max_workers and submit_idx < len( 

2914 pending_doc_ids 

2915 ): 

2916 if is_cancelled is not None and is_cancelled(): 

2917 cancelled = True 

2918 logger.info( 

2919 f"Parallel indexing cancelled during submission at {completed}/{total}" 

2920 ) 

2921 for f in future_to_doc: 

2922 f.cancel() 

2923 break 

2924 doc_id = pending_doc_ids[submit_idx] 

2925 submit_idx += 1 

2926 future_to_doc[pool.submit(_run_one, doc_id)] = doc_id 

2927 

2928 # 3. Nothing in flight and nothing left to submit → done. 

2929 if not future_to_doc: 

2930 break 

2931 

2932 # 4. Wait for the next completion, then process it. 

2933 try: 

2934 done_iter = as_completed(list(future_to_doc.keys())) 

2935 fut = next(done_iter, None) 

2936 except Exception: 

2937 logger.exception( 

2938 "as_completed failed unexpectedly during parallel indexing" 

2939 ) 

2940 fut = None 

2941 if fut is None: 2941 ↛ 2945line 2941 didn't jump to line 2945 because the condition on line 2941 was never true

2942 # Iterator short-circuited without yielding — loop 

2943 # back. The next iteration's cancel poll / submit 

2944 # check will either submit more work or exit. 

2945 continue 

2946 

2947 doc_id = future_to_doc.pop(fut) 

2948 try: 

2949 result = fut.result() 

2950 except CancelledError: 

2951 result = { 

2952 "status": "skipped", 

2953 "message": "Cancelled before submission", 

2954 } 

2955 except Exception as exc: 

2956 logger.exception( 

2957 f"Future for doc {doc_id} raised unexpectedly" 

2958 ) 

2959 result = { 

2960 "status": "error", 

2961 "error": f"Worker crashed: {type(exc).__name__}", 

2962 } 

2963 status = result.get("status", "error") 

2964 prepared = result.get("prepared") 

2965 if status == "prepared" and isinstance( 

2966 prepared, _PreparedDocument 

2967 ): 

2968 result = self._write_prepared_document(prepared) 

2969 status = result.get("status", "error") 

2970 results[doc_id] = result 

2971 if status == "success": 

2972 counters["successful"] += 1 

2973 elif status in ("skipped", "cleared"): 

2974 counters["skipped"] += 1 

2975 else: 

2976 counters["failed"] += 1 

2977 errors.append( 

2978 { 

2979 "doc_id": doc_id, 

2980 "title": title_lookup.get(doc_id, "Unknown"), 

2981 "error": result.get("error"), 

2982 } 

2983 ) 

2984 completed += 1 

2985 if progress_callback is not None: 

2986 try: 

2987 progress_callback( 

2988 completed, 

2989 total, 

2990 title_lookup.get(doc_id, "Unknown"), 

2991 status, 

2992 ) 

2993 except Exception: 

2994 # A buggy callback must not poison the indexing 

2995 # run — log and continue. 

2996 logger.exception( 

2997 "Parallel index progress_callback raised" 

2998 ) 

2999 finally: 

3000 if not cancelled and is_cancelled is not None and is_cancelled(): 

3001 cancelled = True 

3002 # Always ``wait=True`` so in-flight workers finish before the 

3003 # helper returns. With bounded submission no new work is 

3004 # admitted after cancel is observed, so the only in-flight 

3005 # work at shutdown is what was already running — these finish 

3006 # naturally. ``cancel_futures=cancelled`` is a no-op here 

3007 # (no queued futures by construction) but is kept for 

3008 # symmetry with other shutdown sites in the codebase and as 

3009 # defense-in-depth if a future change relaxes the bounded- 

3010 # submission invariant. 

3011 pool.shutdown(wait=True, cancel_futures=cancelled) 

3012 

3013 if cancelled: 

3014 # Mark docs that were never submitted (still in the 

3015 # pending queue past ``submit_idx``) as skipped. Submitted 

3016 # docs all have results — either from processing in the 

3017 # loop above or from the ``wait=True`` shutdown drain — 

3018 # so we don't need to touch them here. 

3019 for doc_id in pending_doc_ids[submit_idx:]: 

3020 if doc_id not in results: 3020 ↛ 3019line 3020 didn't jump to line 3019 because the condition on line 3020 was always true

3021 results[doc_id] = { 

3022 "status": "skipped", 

3023 "message": "Cancelled before submission", 

3024 } 

3025 counters["skipped"] += 1 

3026 

3027 logger.info( 

3028 f"Parallel indexing complete: " 

3029 f"{counters['successful']} successful, " 

3030 f"{counters['skipped']} skipped, " 

3031 f"{counters['failed']} failed" 

3032 f"{' (cancelled)' if cancelled else ''}" 

3033 ) 

3034 

3035 return { 

3036 "successful": counters["successful"], 

3037 "skipped": counters["skipped"], 

3038 "failed": counters["failed"], 

3039 "errors": errors, 

3040 "results": results, 

3041 "cancelled": cancelled, 

3042 "total": total, 

3043 } 

3044 

3045 def reconcile_collection_index(self, collection_id: str) -> Dict[str, Any]: 

3046 """Reconcile indexed flags and stats against durable FAISS membership. 

3047 

3048 A document is indexed only when it has at least one current-model chunk 

3049 row and every such chunk id exists in the live FAISS index. Stale status 

3050 rows are removed and compatibility flags/stats are rebuilt atomically. 

3051 

3052 Safety invariant: if the live FAISS index reports zero ids (transient 

3053 load error, missing file, on-disk corruption the verifier just 

3054 quarantined) but the DB still has DocumentChunk rows for the collection, 

3055 we REFUSE to shrink state. The function returns 

3056 ``{"reconciliation_skipped": True, "reason": "..."}`` and the worker 

3057 surfaces this as a task failure rather than clearing every indexed flag 

3058 and RagDocumentStatus row. A mass-clear from a transient store fault 

3059 would orphan every search hit and require a full re-index to recover. 

3060 

3061 ``RagDocumentStatus.indexed_at`` is preserved for durable docs that 

3062 already have a row — only newly durable docs get the current timestamp. 

3063 """ 

3064 collection_name = f"collection_{collection_id}" 

3065 with self._collection_transaction_lock(collection_id): 

3066 vindex = self._get_vector_index( 

3067 collection_id, collection_name, reset_stale_state=False 

3068 ) 

3069 live_ids = set(vindex.live_ids()) 

3070 rag_index_id = self.rag_index_record.id 

3071 with get_user_db_session( 

3072 self.username, self.db_password 

3073 ) as session: 

3074 rows = ( 

3075 session.query(DocumentChunk.id, DocumentChunk.source_id) 

3076 .filter_by( 

3077 source_type="document", 

3078 collection_name=collection_name, 

3079 embedding_model=self.embedding_model, 

3080 embedding_model_type=EmbeddingProvider( 

3081 self.embedding_provider 

3082 ), 

3083 ) 

3084 .all() 

3085 ) 

3086 ids_by_document: Dict[str, Set[int]] = {} 

3087 for chunk_id, source_id in rows: 

3088 if source_id is not None: 3088 ↛ 3087line 3088 didn't jump to line 3087 because the condition on line 3088 was always true

3089 ids_by_document.setdefault(str(source_id), set()).add( 

3090 int(chunk_id) 

3091 ) 

3092 

3093 # Refuse-to-shrink guard: a transient empty live_ids while 

3094 # chunk rows exist would otherwise mass-clear every indexed 

3095 # flag and RagDocumentStatus row, silently killing search 

3096 # for every document in the collection. Treat as a 

3097 # reconciliation failure; the worker surfaces the skip. 

3098 if not live_ids and ids_by_document: 

3099 logger.warning( 

3100 "reconcile_collection_index: live_ids empty but " 

3101 f"{len(ids_by_document)} document(s) have chunk rows; " 

3102 "refusing to clear indexed flags" 

3103 ) 

3104 return { 

3105 "reconciliation_skipped": True, 

3106 "reason": ( 

3107 "live vector store reported zero ids while " 

3108 "DocumentChunk rows exist; refusing to clear " 

3109 "indexed flags" 

3110 ), 

3111 "indexed_documents": 0, 

3112 "indexed_chunks": 0, 

3113 "live_vectors": 0, 

3114 "orphan_vectors": 0, 

3115 } 

3116 

3117 durable = { 

3118 doc_id: chunk_ids 

3119 for doc_id, chunk_ids in ids_by_document.items() 

3120 if chunk_ids and chunk_ids.issubset(live_ids) 

3121 } 

3122 

3123 # Snapshot existing indexed_at BEFORE the bulk delete so 

3124 # durable docs that already had a status row keep their 

3125 # original timestamp. Only docs newly promoted to durable 

3126 # get ``now``. 

3127 prior_indexed_at: Dict[str, Any] = { 

3128 str(row.document_id): row.indexed_at 

3129 for row in session.query( 

3130 RagDocumentStatus.document_id, 

3131 RagDocumentStatus.indexed_at, 

3132 ) 

3133 .filter(RagDocumentStatus.collection_id == collection_id) 

3134 .all() 

3135 } 

3136 

3137 links = ( 

3138 session.query(DocumentCollection) 

3139 .filter_by(collection_id=collection_id) 

3140 .all() 

3141 ) 

3142 now = datetime.now(UTC) 

3143 for link in links: 

3144 chunk_ids = durable.get(str(link.document_id), set()) 

3145 link.indexed = bool(chunk_ids) 

3146 link.chunk_count = len(chunk_ids) 

3147 if chunk_ids: 3147 ↛ 3143line 3147 didn't jump to line 3143 because the condition on line 3147 was always true

3148 link.last_indexed_at = link.last_indexed_at or now 

3149 

3150 session.query(RagDocumentStatus).filter_by( 

3151 collection_id=collection_id 

3152 ).delete(synchronize_session=False) 

3153 for doc_id, chunk_ids in durable.items(): 

3154 session.add( 

3155 RagDocumentStatus( 

3156 document_id=doc_id, 

3157 collection_id=collection_id, 

3158 rag_index_id=rag_index_id, 

3159 chunk_count=len(chunk_ids), 

3160 indexed_at=prior_indexed_at.get(doc_id, now), 

3161 ) 

3162 ) 

3163 

3164 rag_index = ( 

3165 session.query(RAGIndex).filter_by(id=rag_index_id).first() 

3166 ) 

3167 if rag_index: 3167 ↛ 3171line 3167 didn't jump to line 3171 because the condition on line 3167 was always true

3168 rag_index.chunk_count = sum(map(len, durable.values())) 

3169 rag_index.total_documents = len(durable) 

3170 rag_index.last_updated_at = now 

3171 session.commit() 

3172 

3173 durable_ids = {i for ids in durable.values() for i in ids} 

3174 return { 

3175 "indexed_documents": len(durable), 

3176 "indexed_chunks": len(durable_ids), 

3177 "live_vectors": len(live_ids), 

3178 "orphan_vectors": len(live_ids - durable_ids), 

3179 } 

3180 

3181 def get_rag_stats( 

3182 self, collection_id: Optional[str] = None 

3183 ) -> Dict[str, Any]: 

3184 """ 

3185 Get RAG statistics for a collection. 

3186 

3187 Args: 

3188 collection_id: UUID of the collection (defaults to Library) 

3189 

3190 Returns: 

3191 Dict with counts and metadata about indexed documents 

3192 """ 

3193 with get_user_db_session(self.username, self.db_password) as session: 

3194 # Get collection ID (default to Library) 

3195 if not collection_id: 3195 ↛ 3196line 3195 didn't jump to line 3196 because the condition on line 3195 was never true

3196 from ...database.library_init import get_default_library_id 

3197 

3198 collection_id = get_default_library_id( 

3199 self.username, self.db_password 

3200 ) 

3201 

3202 # Count total documents in collection 

3203 total_docs = ( 

3204 session.query(DocumentCollection) 

3205 .filter_by(collection_id=collection_id) 

3206 .count() 

3207 ) 

3208 

3209 # Count indexed documents from rag_document_status table 

3210 from ...database.models.library import RagDocumentStatus 

3211 

3212 # One collection can hold status rows from several indexes at 

3213 # once, so scope the counts to the index this configuration 

3214 # resolves to. No such index means nothing is indexed for it. 

3215 rag_index = self._resolve_index_for_config(session, collection_id) 

3216 

3217 if rag_index is None: 

3218 indexed_docs = 0 

3219 total_chunks = 0 

3220 foreign_rows = ( 

3221 session.query(RagDocumentStatus) 

3222 .filter_by(collection_id=collection_id) 

3223 .count() 

3224 ) 

3225 if foreign_rows: 

3226 logger.warning( 

3227 f"Collection {collection_id} has no index for the " 

3228 f"active embedding configuration " 

3229 f"({self.embedding_provider}/{self.embedding_model}), " 

3230 f"so it reports zero indexed documents. " 

3231 f"{foreign_rows} document(s) are indexed under other " 

3232 f"configurations and are not searchable from this one." 

3233 ) 

3234 else: 

3235 indexed_docs = ( 

3236 session.query(RagDocumentStatus) 

3237 .filter_by( 

3238 collection_id=collection_id, 

3239 rag_index_id=rag_index.id, 

3240 ) 

3241 .count() 

3242 ) 

3243 

3244 # Count total chunks from rag_document_status table 

3245 total_chunks = ( 

3246 session.query(func.sum(RagDocumentStatus.chunk_count)) 

3247 .filter_by( 

3248 collection_id=collection_id, 

3249 rag_index_id=rag_index.id, 

3250 ) 

3251 .scalar() 

3252 or 0 

3253 ) 

3254 

3255 # Describe the index the counts above came from. Sampling any 

3256 # chunk of the collection would describe whichever index wrote 

3257 # first, which is the same mismatch as the counts. 

3258 embedding_info = {} 

3259 if rag_index is not None: 

3260 stored_provider = rag_index.embedding_model_type 

3261 if isinstance(stored_provider, EmbeddingProvider): 

3262 stored_provider = stored_provider.value 

3263 embedding_info = { 

3264 "model": rag_index.embedding_model, 

3265 "model_type": stored_provider, 

3266 "dimension": rag_index.embedding_dimension, 

3267 } 

3268 

3269 return { 

3270 "total_documents": total_docs, 

3271 "indexed_documents": indexed_docs, 

3272 "unindexed_documents": total_docs - indexed_docs, 

3273 "total_chunks": total_chunks, 

3274 "embedding_info": embedding_info, 

3275 "chunk_size": self.chunk_size, 

3276 "chunk_overlap": self.chunk_overlap, 

3277 } 

3278 

3279 def remove_documents_from_index( 

3280 self, 

3281 document_ids: List[str], 

3282 collection_id: str, 

3283 *, 

3284 chunk_ids_by_document: Optional[Dict[str, List[int]]] = None, 

3285 ) -> int: 

3286 """Delete given documents' vectors from a collection's vector store. 

3287 

3288 Vector-store-only: the DB chunk rows are removed separately by the 

3289 caller. This closes the gap where deleting a document's DB chunks 

3290 left its vectors (and their persisted text — now impossible, see 

3291 vector_stores/base.py's SECURITY INVARIANT) in the store, so the 

3292 document kept surfacing in semantic search until a full reindex. 

3293 

3294 For each id, calls ``VectorIndex.delete(source_type="document", 

3295 source_id=...)`` — which looks up the matching ``DocumentChunk`` 

3296 rows by (source_type, source_id, collection_name) — UNLESS 

3297 ``chunk_ids_by_document`` supplies that document's chunk ids 

3298 directly, in which case ``VectorIndex.delete_ids(ids)`` is used 

3299 instead. Callers whose own per-item transactions must delete the DB 

3300 rows eagerly (for their own atomicity/rollback semantics) BEFORE 

3301 this batched cleanup runs need the latter: the source_id lookup 

3302 would find nothing once the rows are already gone (see the Zotero 

3303 sync caller). Hardcodes ``source_type="document"``: both current 

3304 callers (Zotero sync cleanup, ``remove_document_from_rag``) only 

3305 ever pass library-document ids (the pre-cutover implementation 

3306 matched a mixed ``document_id``/``source_id`` metadata key 

3307 generically across "document" and "user_document" sources, but 

3308 nothing ever called it with anything but library documents). 

3309 

3310 Returns the total number of vectors removed across all ids. No-ops 

3311 (returns 0) when the collection has no current vector index — this 

3312 guard runs BEFORE building a ``VectorIndex`` so a delete call never 

3313 creates one (``_get_vector_index`` would otherwise create the 

3314 RAGIndex row via ``_get_or_create_rag_index``). 

3315 """ 

3316 from ...database.models.library import RAGIndex 

3317 from ...database.session_context import get_user_db_session 

3318 

3319 wanted = [d for d in document_ids if d] 

3320 if not wanted: 

3321 return 0 

3322 

3323 collection_name = f"collection_{collection_id}" 

3324 # Don't create an index just to delete from it — only act if one 

3325 # already exists for this collection. 

3326 with get_user_db_session(self.username, self.db_password) as session: 

3327 has_index = ( 

3328 session.query(RAGIndex.id) 

3329 .filter_by(collection_name=collection_name, is_current=True) 

3330 .first() 

3331 is not None 

3332 ) 

3333 if not has_index: 

3334 return 0 

3335 

3336 try: 

3337 # reset_stale_state=True is REQUIRED on this write path. If the 

3338 # pre-flight finds the index corrupt it quarantines the file 

3339 # (renames it aside); a fresh EMPTY store is then built and the 

3340 # delete loop below persists that empty store over the real path. 

3341 # Without resetting the per-document indexed state, the DB would 

3342 # still claim every other document is indexed while their vectors 

3343 # are gone — silently wiping the whole collection's search with no 

3344 # recovery but a manual full reindex. The reset only fires when the 

3345 # index was actually quarantined (guarded on the file no longer 

3346 # existing), so a healthy remove is unaffected. search() omits this 

3347 # flag safely because it never persists an empty store. 

3348 vindex = self._get_vector_index( 

3349 collection_id, collection_name, reset_stale_state=True 

3350 ) 

3351 except (ValueError, RuntimeError): 

3352 logger.warning( 

3353 "Could not load vector index for " 

3354 f"{collection_name} while removing document vectors" 

3355 ) 

3356 return 0 

3357 

3358 # Collect ALL removable chunk ids across every document, then remove 

3359 # them in ONE apply() (vindex.delete_ids). For an HNSW collection each 

3360 # apply() rebuilds the whole index, so a per-document loop is 

3361 # O(documents x collection_size); a single batched call rebuilds once. 

3362 # Captured ids are used directly; documents without captured ids have 

3363 # their chunk ids resolved by (source_type, source_id, collection_name) 

3364 # — those rows still exist at this point. 

3365 all_ids: List[int] = [] 

3366 unresolved: List[str] = [] 

3367 for document_id in wanted: 

3368 captured = ( 

3369 chunk_ids_by_document.get(document_id) 

3370 if chunk_ids_by_document 

3371 else None 

3372 ) 

3373 if captured: 3373 ↛ 3374line 3373 didn't jump to line 3374 because the condition on line 3373 was never true

3374 all_ids.extend(int(i) for i in captured if i is not None) 

3375 else: 

3376 unresolved.append(document_id) 

3377 

3378 if unresolved: 3378 ↛ 3394line 3378 didn't jump to line 3394 because the condition on line 3378 was always true

3379 in_chunk = 500 # keep below SQLITE_MAX_VARIABLE_NUMBER (999) 

3380 with get_user_db_session( 

3381 self.username, self.db_password 

3382 ) as session: 

3383 for i in range(0, len(unresolved), in_chunk): 

3384 batch = unresolved[i : i + in_chunk] 

3385 all_ids.extend( 

3386 row.id 

3387 for row in session.query(DocumentChunk.id).filter( 

3388 DocumentChunk.source_type == "document", 

3389 DocumentChunk.collection_name == collection_name, 

3390 DocumentChunk.source_id.in_(batch), 

3391 ) 

3392 ) 

3393 

3394 removed = 0 

3395 if all_ids: 3395 ↛ 3405line 3395 didn't jump to line 3405 because the condition on line 3395 was always true

3396 try: 

3397 stats = vindex.delete_ids(sorted(set(all_ids))) 

3398 removed = stats.removed 

3399 except Exception: 

3400 logger.warning( 

3401 f"Could not remove {len(set(all_ids))} vectors for " 

3402 f"{len(wanted)} document(s) from {collection_name}" 

3403 ) 

3404 

3405 if removed: 3405 ↛ 3410line 3405 didn't jump to line 3410 because the condition on line 3405 was always true

3406 logger.info( 

3407 f"Removed {removed} vectors for " 

3408 f"{len(wanted)} document(s) from {collection_name}" 

3409 ) 

3410 return removed