Coverage for src/local_deep_research/web_search_engines/engines/search_engine_library.py: 95%

151 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-20 01:24 +0000

1""" 

2Library RAG Search Engine 

3 

4Provides semantic search over the user's personal research library using RAG. 

5""" 

6 

7import os 

8from typing import List, Dict, Any, Optional 

9 

10from ..search_engine_base import BaseSearchEngine, Exposure, Sensitivity 

11from ...security.secure_logging import logger 

12from ...constants import SNIPPET_LENGTH_LONG 

13from ...research_library.services.library_rag_service import LibraryRAGService 

14from ...research_library.services.library_service import LibraryService 

15from ...config.thread_settings import get_setting_from_snapshot 

16from ...utilities.llm_utils import get_server_url 

17from ...utilities.type_utils import to_bool 

18from ...database.models.library import RAGIndex, Document 

19from ...research_library.services.pdf_storage_manager import PDFStorageManager 

20from ...database.session_context import get_user_db_session 

21from ...config.paths import get_library_directory 

22 

23 

24class LibraryRAGSearchEngine(BaseSearchEngine): 

25 """ 

26 Search engine that queries the user's research library using RAG/semantic search. 

27 """ 

28 

29 # Mark as local RAG engine 

30 is_local = True 

31 

32 # Egress (ADR-0007): a local document store — sensitive data, kept on-box. 

33 egress_sensitivity = Sensitivity.SENSITIVE 

34 egress_exposure = Exposure.CONTAINED 

35 

36 def __init__( 

37 self, 

38 llm: Optional[Any] = None, 

39 max_filtered_results: Optional[int] = None, 

40 max_results: int = 10, 

41 settings_snapshot: Optional[Dict[str, Any]] = None, 

42 **kwargs, 

43 ): 

44 """ 

45 Initialize the Library RAG search engine. 

46 

47 Args: 

48 llm: Language model for relevance filtering 

49 max_filtered_results: Maximum number of results to keep after filtering 

50 max_results: Maximum number of search results 

51 settings_snapshot: Settings snapshot from thread context 

52 **kwargs: Additional engine-specific parameters 

53 """ 

54 super().__init__( 

55 llm=llm, 

56 max_filtered_results=max_filtered_results, 

57 max_results=max_results, 

58 settings_snapshot=settings_snapshot, 

59 **kwargs, 

60 ) 

61 self.username = ( 

62 settings_snapshot.get("_username") if settings_snapshot else None 

63 ) 

64 # Self-stamp the policy registry name so direct instantiations get 

65 # the runtime egress backstop without caller cooperation (the 

66 # factory stamps registry engines, but routes/services may build 

67 # this engine directly). CollectionSearchEngine overrides this 

68 # with its collection key after calling super().__init__. 

69 self._engine_name = "library" 

70 

71 if not self.username: 

72 logger.warning( 

73 "Library RAG search engine initialized without username" 

74 ) 

75 

76 # Get RAG configuration from settings. NB: keyword arguments — the 

77 # signature is (key, default, username, settings_snapshot); these 

78 # calls used to pass (key, settings_snapshot, default) positionally, 

79 # so the snapshot was never consulted and, outside a thread settings 

80 # context, each setting silently resolved to the snapshot DICT 

81 # itself (the misplaced "default"). 

82 self.embedding_model = get_setting_from_snapshot( 

83 "local_search_embedding_model", 

84 default="all-MiniLM-L6-v2", 

85 settings_snapshot=settings_snapshot, 

86 ) 

87 self.embedding_provider = get_setting_from_snapshot( 

88 "local_search_embedding_provider", 

89 default="sentence_transformers", 

90 settings_snapshot=settings_snapshot, 

91 ) 

92 self.chunk_size = get_setting_from_snapshot( 

93 "local_search_chunk_size", 

94 default=1000, 

95 settings_snapshot=settings_snapshot, 

96 ) 

97 self.chunk_overlap = get_setting_from_snapshot( 

98 "local_search_chunk_overlap", 

99 default=200, 

100 settings_snapshot=settings_snapshot, 

101 ) 

102 

103 # Extract server URL from settings snapshot for link generation 

104 self.server_url = get_server_url(settings_snapshot) 

105 

106 # Local RAG retrieval is vector-similarity ranked and can return many 

107 # near-duplicate chunks per query — enough to flood an agent's context 

108 # across research iterations. Apply a RAG-specific cap (default 20), 

109 # independent of the global ``search.max_results`` (which targets web 

110 # engines). This OVERRIDES the inherited max_results for local search. 

111 # Default to the inherited max_results so an ABSENT setting preserves 

112 # the caller's value (programmatic callers, tests); real web/CLI runs 

113 # carry the shipped default of 20 via default_settings.json, so they ARE 

114 # capped. A malformed value also falls back to no change. 

115 rag_max_results = get_setting_from_snapshot( 

116 "search.rag.max_results", 

117 default=self.max_results, 

118 settings_snapshot=settings_snapshot, 

119 ) 

120 try: 

121 rag_max_results = int(rag_max_results) 

122 except (TypeError, ValueError): 

123 rag_max_results = self.max_results 

124 if rag_max_results > 0: 124 ↛ 136line 124 didn't jump to line 136 because the condition on line 124 was always true

125 self.max_results = rag_max_results 

126 

127 # Embeddings already rank by cosine similarity, so the LLM relevance 

128 # filter is OPT-IN here (unlike keyword engines that hardcode 

129 # needs_llm_relevance_filter=True). Default on: prune 

130 # semantically-near-but-irrelevant chunks. The factory only ever SETS 

131 # enable_llm_relevance_filter=True (for should_filter engines) and never 

132 # unsets it — and RAG engines are not should_filter (no 

133 # needs_llm_relevance_filter; dynamic ``collection_<uuid>`` name) — so 

134 # this assignment is authoritative. The base filter is a no-op without 

135 # an llm, so it degrades gracefully. 

136 self.enable_llm_relevance_filter = to_bool( 

137 get_setting_from_snapshot( 

138 "search.rag.enable_relevance_filter", 

139 default=True, 

140 settings_snapshot=settings_snapshot, 

141 ), 

142 default=True, 

143 ) 

144 

145 def search( 

146 self, 

147 query: str, 

148 limit: int = 10, 

149 llm_callback=None, 

150 extra_params: Optional[Dict[str, Any]] = None, 

151 ) -> List[Dict[str, Any]]: 

152 """ 

153 Search the library using semantic search. 

154 

155 Args: 

156 query: Search query 

157 limit: Maximum number of results to return 

158 llm_callback: Optional LLM callback for processing results 

159 extra_params: Additional search parameters 

160 

161 Returns: 

162 List of search results with title, url, snippet, etc. 

163 """ 

164 if not self.username: 

165 logger.error("Cannot search library without username") 

166 return [] 

167 

168 # search() can be called directly (bypassing run()), so apply the 

169 # runtime egress backstop here too. Memoized per snapshot, so the 

170 # run() -> _get_previews() -> search() path re-checks for free. 

171 # Raises PolicyDeniedError on denial — deliberately BEFORE the 

172 # broad try/except below. 

173 self._verify_egress_scope() 

174 

175 try: 

176 # Initialize services 

177 library_service = LibraryService(username=self.username) 

178 

179 # Get all collections for this user 

180 collections = library_service.get_all_collections() 

181 if not collections: 

182 logger.info("No collections found for user") 

183 return [] 

184 

185 # Search across all collections and merge results. Each 

186 # (SearchResult, relevance, metadata) tuple is pre-scored per 

187 # its OWN collection's metric before merging, so a collection 

188 # indexed with cosine/dot_product doesn't get compared on the 

189 # wrong scale against one indexed with l2 (see the 

190 # relevance-formula comment below). 

191 all_results = [] 

192 failed_collections = [] 

193 for collection in collections: 

194 collection_id = collection.get("id") 

195 if not collection_id: 

196 continue 

197 

198 try: 

199 # Get the RAG index for this collection to find embedding settings 

200 with get_user_db_session(self.username) as session: 

201 collection_name = f"collection_{collection_id}" 

202 rag_index = ( 

203 session.query(RAGIndex) 

204 .filter_by( 

205 collection_name=collection_name, 

206 is_current=True, 

207 ) 

208 .first() 

209 ) 

210 

211 if not rag_index: 

212 logger.debug( 

213 f"No RAG index found for collection {collection_id}" 

214 ) 

215 continue 

216 

217 # Get embedding settings from the RAG index 

218 embedding_model = rag_index.embedding_model 

219 embedding_provider = ( 

220 rag_index.embedding_model_type.value 

221 ) 

222 chunk_size = rag_index.chunk_size or self.chunk_size 

223 chunk_overlap = ( 

224 rag_index.chunk_overlap or self.chunk_overlap 

225 ) 

226 # Thread the stored normalization flag AND distance 

227 # metric through, exactly like CollectionSearchEngine. 

228 # Without normalize_vectors, LibraryRAGService defaults 

229 # True and would L2-normalize the query against a 

230 # raw-vector collection, flipping the top hit. Without 

231 # distance_metric, every collection is labeled "cosine" 

232 # and l2/dot_product hits get the wrong [0,1] mapping in 

233 # the cross-collection merge below. NULL → prior default. 

234 normalize_vectors = to_bool( 

235 rag_index.normalize_vectors, default=True 

236 ) 

237 distance_metric = rag_index.distance_metric or "cosine" 

238 # Thread the stored index_type too (like metric/ 

239 # normalize): a legacy NULL-index_type row that is 

240 # physically HNSW would otherwise fall back to "flat", 

241 # mislabelling the store. NULL → "flat". 

242 index_type = rag_index.index_type or "flat" 

243 

244 # Create RAG service with the collection's embedding settings 

245 with LibraryRAGService( 

246 username=self.username, 

247 embedding_model=embedding_model, 

248 embedding_provider=embedding_provider, 

249 chunk_size=chunk_size, 

250 chunk_overlap=chunk_overlap, 

251 normalize_vectors=normalize_vectors, 

252 distance_metric=distance_metric, 

253 index_type=index_type, 

254 ) as rag_service: 

255 # Get RAG stats to check if there are any indexed documents 

256 stats = rag_service.get_rag_stats(collection_id) 

257 if stats.get("indexed_documents", 0) == 0: 

258 logger.debug( 

259 f"No documents indexed in collection {collection_id}" 

260 ) 

261 continue 

262 

263 # Search this collection's vector index via the new 

264 # int-id-keyed store (chunk text is rehydrated from 

265 # the encrypted DB by id — never read from the 

266 # vector store itself; see the SECURITY INVARIANT 

267 # in vector_stores/base.py). 

268 search_results = rag_service.search( 

269 query, collection_id, limit 

270 ) 

271 

272 # Score per THIS collection's own metric, add 

273 # collection info to metadata, and append to the 

274 # cross-collection merge list. 

275 for r in search_results: 

276 metadata = dict(r.metadata or {}) 

277 metadata["collection_id"] = collection_id 

278 metadata["collection_name"] = collection.get( 

279 "name", "Unknown" 

280 ) 

281 # Map r.distance to a [0,1] relevance growing with 

282 # similarity: cosine/dot_product is an inner product 

283 # in [-1,1] (higher=nearer) -> (d+1)/2 clamped; l2 (or 

284 # any non-standard metric) is a distance >=0 (lower= 

285 # nearer) -> 1/(1+d) in (0,1]. Raw IP would give 

286 # negative relevance and break [0,1] consumers. 

287 # Scoring here (per-collection, before the merge) 

288 # keeps different-metric collections comparable once 

289 # merged. The IP test MUST match faiss_store. 

290 # _build_base_index (inner-product iff cosine/ 

291 # dot_product, else L2): a bare `metric == "l2"` would 

292 # score a non-standard metric — which builds an L2 

293 # index — with the IP formula and invert the ranking. 

294 relevance = ( 

295 max(0.0, min(1.0, (r.distance + 1.0) / 2.0)) 

296 if r.metric in ("cosine", "dot_product") 

297 else 1.0 / (1.0 + r.distance) 

298 ) 

299 all_results.append((r, relevance, metadata)) 

300 

301 except Exception as e: 

302 # One broken collection must not abort the others, but 

303 # record the failure so it is not silently equated with 

304 # "no matching documents" below. 

305 safe_msg = self._scrub_error(e) 

306 logger.exception( 

307 f"Error searching collection {collection_id} ({type(e).__name__}): {safe_msg}" 

308 ) 

309 failed_collections.append( 

310 collection.get("name") or str(collection_id) 

311 ) 

312 continue 

313 

314 # Sort all results by relevance, descending (higher is always 

315 # better post-transform, regardless of the source collection's 

316 # metric — see the per-result relevance comment above). 

317 all_results.sort(key=lambda item: item[1], reverse=True) 

318 

319 # Take top results across all collections 

320 top_results = all_results[:limit] 

321 

322 if not top_results: 

323 if failed_collections: 

324 # Zero results AND at least one collection errored: 

325 # reporting "no results" here would hide the failure. 

326 self._raise_collections_failed(failed_collections) 

327 logger.info("No results found across any collections") 

328 return [] 

329 

330 if failed_collections: 

331 # Partial success: results from the healthy collections are 

332 # returned, but tell the user they are incomplete. WARNING 

333 # level streams to the research-log UI via 

334 # frontend_progress_sink. 

335 logger.warning( 

336 f"Library search results are incomplete: " 

337 f"{len(failed_collections)} of {len(collections)} " 

338 f"collection(s) failed: {failed_collections}" 

339 ) 

340 

341 # Convert to the search-result dict format 

342 results = [] 

343 for r, relevance, metadata in top_results: 

344 # Try both source_id and document_id for compatibility 

345 doc_id = ( 

346 r.source_id 

347 or metadata.get("source_id") 

348 or metadata.get("document_id") 

349 ) 

350 

351 # Get title from the result, with fallbacks 

352 title = ( 

353 r.document_title 

354 or metadata.get("document_title") 

355 or metadata.get("title") 

356 or (f"Document {doc_id}" if doc_id else "Untitled") 

357 ) 

358 

359 # Content is rehydrated from the encrypted DB by chunk id 

360 snippet = ( 

361 r.text[:SNIPPET_LENGTH_LONG] + "..." 

362 if len(r.text) > SNIPPET_LENGTH_LONG 

363 else r.text 

364 ) 

365 

366 # Generate URL to document content 

367 # Default to root document page (shows all options: PDF, Text, Chunks, etc.) 

368 document_url = f"/library/document/{doc_id}" if doc_id else "#" 

369 

370 if doc_id: 

371 try: 

372 with get_user_db_session(self.username) as session: 

373 document = ( 

374 session.query(Document) 

375 .filter_by(id=doc_id) 

376 .first() 

377 ) 

378 if document: 378 ↛ 400line 378 didn't jump to line 400

379 from pathlib import Path 

380 

381 library_root = get_setting_from_snapshot( 

382 "research_library.storage_path", 

383 default=str(get_library_directory()), 

384 settings_snapshot=self.settings_snapshot, 

385 ) 

386 library_root = ( 

387 Path(os.path.expandvars(library_root)) 

388 .expanduser() 

389 .resolve() 

390 ) 

391 if PDFStorageManager.pdf_exists( 

392 library_root, document, session 

393 ): 

394 document_url = ( 

395 f"/library/document/{doc_id}/pdf" 

396 ) 

397 except Exception: 

398 logger.warning(f"Error querying document {doc_id}") 

399 

400 result = { 

401 "title": title, 

402 "snippet": snippet, 

403 "url": document_url, 

404 "link": document_url, # Add "link" for source extraction 

405 "source": "library", 

406 "source_type": "library", 

407 "relevance_score": float(relevance), 

408 "metadata": metadata, 

409 } 

410 

411 results.append(result) 

412 

413 logger.info( 

414 f"Library RAG search returned {len(results)} results for query: {query}" 

415 ) 

416 return results 

417 

418 except Exception as e: 

419 # Re-raise so run() records the failure in metrics instead of 

420 # treating a failed search as "no matching documents". 

421 safe_msg = self._scrub_error(e) 

422 logger.exception( 

423 f"Error searching library RAG ({type(e).__name__}): {safe_msg}" 

424 ) 

425 raise 

426 

427 @staticmethod 

428 def _raise_collections_failed(failed_collections: List[str]) -> None: 

429 """Signal zero results caused (at least partly) by search failures.""" 

430 raise RuntimeError( 

431 f"Library search returned no results and failed for " 

432 f"{len(failed_collections)} collection(s): {failed_collections}" 

433 ) 

434 

435 def _get_previews( 

436 self, 

437 query: str, 

438 limit: Optional[int] = None, 

439 llm_callback=None, 

440 extra_params: Optional[Dict[str, Any]] = None, 

441 ) -> List[Dict[str, Any]]: 

442 """ 

443 Get preview results for the query. 

444 Delegates to the search method, using the configured max_results 

445 when no explicit limit is provided. 

446 """ 

447 if limit is None: 

448 limit = self.max_results 

449 return self.search(query, limit, llm_callback, extra_params) 

450 

451 def _get_full_content( 

452 self, relevant_items: List[Dict[str, Any]] 

453 ) -> List[Dict[str, Any]]: 

454 """ 

455 Get full content for relevant library documents. 

456 Retrieves complete document text instead of just snippets. 

457 """ 

458 if not self.username: 

459 logger.error("Cannot retrieve full content without username") 

460 return relevant_items 

461 

462 try: 

463 from ...database.models.library import Document 

464 from ...database.session_context import get_user_db_session 

465 

466 # Retrieve full content for each document 

467 for item in relevant_items: 

468 doc_id = item.get("metadata", {}).get("document_id") 

469 if not doc_id: 

470 continue 

471 

472 # Get full document text from database 

473 with get_user_db_session(self.username) as db_session: 

474 document = ( 

475 db_session.query(Document).filter_by(id=doc_id).first() 

476 ) 

477 

478 if document and document.text_content: 

479 # Replace snippet with full content 

480 item["content"] = document.text_content 

481 item["snippet"] = ( 

482 document.text_content[:SNIPPET_LENGTH_LONG] + "..." 

483 if len(document.text_content) > SNIPPET_LENGTH_LONG 

484 else document.text_content 

485 ) 

486 logger.debug( 

487 f"Retrieved full content for document {doc_id}" 

488 ) 

489 

490 return relevant_items 

491 

492 except Exception as e: 

493 safe_msg = self._scrub_error(e) 

494 logger.exception( 

495 f"Error retrieving full content from library ({type(e).__name__}): {safe_msg}" 

496 ) 

497 return relevant_items 

498 

499 def close(self): 

500 """Clean up resources.""" 

501 pass