Coverage for src/local_deep_research/web_search_engines/engines/search_engine_collection.py: 99%

106 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1""" 

2Collection-specific RAG Search Engine 

3 

4Provides semantic search within a specific document collection using RAG. 

5""" 

6 

7from typing import List, Dict, Any, Optional 

8 

9from ...security.secure_logging import logger 

10from .search_engine_library import LibraryRAGSearchEngine 

11from ...constants import ( 

12 DEFAULT_LOCAL_SEARCH_CHUNK_OVERLAP, 

13 DEFAULT_LOCAL_SEARCH_CHUNK_SIZE, 

14 DEFAULT_LOCAL_SEARCH_DISTANCE_METRIC, 

15 DEFAULT_LOCAL_SEARCH_INDEX_TYPE, 

16 DEFAULT_LOCAL_SEARCH_NORMALIZE_VECTORS, 

17 DEFAULT_LOCAL_SEARCH_SPLITTER_TYPE, 

18 SNIPPET_LENGTH_LONG, 

19) 

20from ...research_library.services.library_rag_service import LibraryRAGService 

21from ...database.models.library import Collection 

22from ...database.session_context import get_user_db_session 

23from ...utilities.chunk_anchor import extract_chunk_index, extract_document_id 

24from ...utilities.type_utils import to_bool 

25 

26 

27class CollectionSearchEngine(LibraryRAGSearchEngine): 

28 """ 

29 Search engine for a specific document collection using RAG. 

30 Directly searches only the specified collection's FAISS index. 

31 Each collection uses its own embedding model that was used during indexing. 

32 """ 

33 

34 # Mark as local RAG engine 

35 is_local = True 

36 

37 def __init__( 

38 self, 

39 collection_id: str, 

40 collection_name: str, 

41 llm: Optional[Any] = None, 

42 max_filtered_results: Optional[int] = None, 

43 max_results: int = 10, 

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

45 search_snippets_only: bool = True, 

46 **kwargs, 

47 ): 

48 """ 

49 Initialize the collection-specific search engine. 

50 

51 Args: 

52 collection_id: UUID of the collection to search within 

53 collection_name: Name of the collection for display 

54 llm: Language model for relevance filtering 

55 max_filtered_results: Maximum number of results to keep after filtering 

56 max_results: Maximum number of search results 

57 settings_snapshot: Settings snapshot from thread context 

58 search_snippets_only: named explicitly because create_search_engine 

59 filters its params against this signature and drops the rest 

60 **kwargs: Additional engine-specific parameters 

61 """ 

62 super().__init__( 

63 llm=llm, 

64 max_filtered_results=max_filtered_results, 

65 max_results=max_results, 

66 settings_snapshot=settings_snapshot, 

67 search_snippets_only=search_snippets_only, 

68 **kwargs, 

69 ) 

70 self.collection_id = collection_id 

71 self.collection_name = collection_name 

72 self.collection_key = f"collection_{collection_id}" 

73 # The policy registry name for this engine is known at construction 

74 # (the factory only stamps registry engines), so set it here — 

75 # direct instantiations (e.g. the library search route) get the 

76 # runtime egress backstop without caller cooperation. NB: between 

77 # super().__init__ (which stamps "library") and this line the 

78 # instance briefly carries the parent's name — nothing may call 

79 # _verify_egress_scope() in that window. 

80 self._engine_name = self.collection_key 

81 

82 # Load collection-specific embedding settings 

83 self._load_collection_embedding_settings() 

84 

85 def _load_collection_embedding_settings(self): 

86 """ 

87 Load frozen embedding settings directly from the Collection record. 

88 Uses the same embedding model that was configured when the collection was created. 

89 """ 

90 if not self.username: 

91 logger.warning("Cannot load collection settings without username") 

92 return 

93 

94 try: 

95 with get_user_db_session(self.username) as db_session: 

96 collection = ( 

97 db_session.query(Collection) 

98 .filter_by(id=self.collection_id) 

99 .first() 

100 ) 

101 

102 if not collection or not collection.embedding_model: 

103 logger.warning( 

104 f"No stored embedding settings found for collection {self.collection_id}" 

105 ) 

106 return 

107 

108 # Use embedding settings directly from the Collection 

109 self.embedding_model = collection.embedding_model 

110 provider = collection.embedding_model_type 

111 if provider is not None: 

112 self.embedding_provider = ( 

113 provider.value 

114 if hasattr(provider, "value") 

115 else str(provider) 

116 ) 

117 coll_chunk_size = getattr(collection, "chunk_size", None) 

118 if coll_chunk_size is not None: 118 ↛ 120line 118 didn't jump to line 120 because the condition on line 118 was always true

119 self.chunk_size = int(coll_chunk_size) 

120 coll_chunk_overlap = getattr(collection, "chunk_overlap", None) 

121 if coll_chunk_overlap is not None: 121 ↛ 123line 121 didn't jump to line 123 because the condition on line 121 was always true

122 self.chunk_overlap = int(coll_chunk_overlap) 

123 if getattr(collection, "splitter_type", None) is not None: 

124 self.splitter_type = collection.splitter_type 

125 if getattr(collection, "text_separators", None) is not None: 

126 self.text_separators = collection.text_separators 

127 if getattr(collection, "distance_metric", None) is not None: 

128 self.distance_metric = collection.distance_metric 

129 if getattr(collection, "normalize_vectors", None) is not None: 

130 self.normalize_vectors = to_bool( 

131 collection.normalize_vectors, 

132 default=DEFAULT_LOCAL_SEARCH_NORMALIZE_VECTORS, 

133 ) 

134 if getattr(collection, "index_type", None) is not None: 

135 self.index_type = collection.index_type 

136 

137 logger.info( 

138 f"Collection '{self.collection_name}' using embedding: " 

139 f"{self.embedding_provider}/{self.embedding_model}" 

140 ) 

141 

142 except Exception as e: 

143 safe_msg = self._scrub_error(e) 

144 logger.exception( 

145 f"Error loading collection {self.collection_id} settings ({type(e).__name__}): {safe_msg}" 

146 ) 

147 

148 def search( 

149 self, 

150 query: str, 

151 limit: int = 10, 

152 llm_callback=None, 

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

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

155 """ 

156 Search within the specific collection using semantic search. 

157 

158 Directly searches only this collection's FAISS index instead of 

159 searching all collections and filtering. 

160 

161 Args: 

162 query: Search query 

163 limit: Maximum number of results to return 

164 llm_callback: Optional LLM callback for processing results 

165 extra_params: Additional search parameters 

166 

167 Returns: 

168 List of search results from this collection 

169 """ 

170 if not self.username: 

171 logger.error("Cannot search collection without username") 

172 return [] 

173 

174 # search() does not go through BaseSearchEngine.run(), so apply the 

175 # runtime egress backstop here as well. Raises PolicyDeniedError on 

176 # denial — deliberately BEFORE the broad try/except below. 

177 self._verify_egress_scope() 

178 

179 try: 

180 # Get frozen embedding settings directly from Collection record 

181 with get_user_db_session(self.username) as db_session: 

182 collection = ( 

183 db_session.query(Collection) 

184 .filter_by(id=self.collection_id) 

185 .first() 

186 ) 

187 

188 if not collection or not collection.embedding_model: 

189 logger.info( 

190 f"No embedding settings found for collection '{self.collection_name}'" 

191 ) 

192 return [] 

193 

194 # Get embedding settings directly from Collection 

195 embedding_model = collection.embedding_model 

196 provider = collection.embedding_model_type 

197 if provider is not None: 

198 embedding_provider = ( 

199 provider.value 

200 if hasattr(provider, "value") 

201 else str(provider) 

202 ) 

203 else: 

204 embedding_provider = self.embedding_provider 

205 coll_chunk_size = getattr(collection, "chunk_size", None) 

206 chunk_size = ( 

207 int(coll_chunk_size) 

208 if coll_chunk_size is not None 

209 else int(self.chunk_size or DEFAULT_LOCAL_SEARCH_CHUNK_SIZE) 

210 ) 

211 coll_chunk_overlap = getattr(collection, "chunk_overlap", None) 

212 chunk_overlap = ( 

213 int(coll_chunk_overlap) 

214 if coll_chunk_overlap is not None 

215 else int( 

216 self.chunk_overlap 

217 if self.chunk_overlap is not None 

218 else DEFAULT_LOCAL_SEARCH_CHUNK_OVERLAP 

219 ) 

220 ) 

221 splitter_type = ( 

222 getattr(collection, "splitter_type", None) 

223 or getattr(self, "splitter_type", None) 

224 or DEFAULT_LOCAL_SEARCH_SPLITTER_TYPE 

225 ) 

226 text_separators = ( 

227 collection.text_separators 

228 if getattr(collection, "text_separators", None) is not None 

229 else getattr(self, "text_separators", None) 

230 ) 

231 # Thread the stored normalization flag AND distance 

232 # metric through, exactly like LibraryRAGSearchEngine. 

233 # Without normalize_vectors, LibraryRAGService defaults 

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

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

236 # distance_metric, the collection is labeled "cosine" 

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

238 # the relevance transform below. NULL → prior default. 

239 coll_normalize = getattr(collection, "normalize_vectors", None) 

240 normalize_vectors = ( 

241 to_bool( 

242 coll_normalize, 

243 default=DEFAULT_LOCAL_SEARCH_NORMALIZE_VECTORS, 

244 ) 

245 if coll_normalize is not None 

246 else getattr( 

247 self, 

248 "normalize_vectors", 

249 DEFAULT_LOCAL_SEARCH_NORMALIZE_VECTORS, 

250 ) 

251 ) 

252 distance_metric = ( 

253 getattr(collection, "distance_metric", None) 

254 or getattr(self, "distance_metric", None) 

255 or DEFAULT_LOCAL_SEARCH_DISTANCE_METRIC 

256 ) 

257 # Thread the stored index_type too (like metric/ 

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

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

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

261 index_type = ( 

262 getattr(collection, "index_type", None) 

263 or getattr(self, "index_type", None) 

264 or DEFAULT_LOCAL_SEARCH_INDEX_TYPE 

265 ) 

266 

267 # Create RAG service with collection's embedding settings 

268 with LibraryRAGService( 

269 username=self.username, 

270 embedding_model=embedding_model, 

271 embedding_provider=embedding_provider, 

272 chunk_size=chunk_size, 

273 chunk_overlap=chunk_overlap, 

274 splitter_type=splitter_type, 

275 text_separators=text_separators, 

276 normalize_vectors=normalize_vectors, 

277 distance_metric=distance_metric, 

278 index_type=index_type, 

279 ) as rag_service: 

280 # Check if there are indexed documents 

281 if not rag_service.has_indexed_documents(self.collection_id): 

282 logger.info( 

283 f"No documents indexed in collection '{self.collection_name}'" 

284 ) 

285 return [] 

286 

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

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

289 # encrypted DB by id — never read from the vector store 

290 # itself; see the SECURITY INVARIANT in 

291 # vector_stores/base.py). 

292 search_results = rag_service.search( 

293 query, self.collection_id, limit 

294 ) 

295 

296 if not search_results: 

297 logger.info( 

298 f"No results found in collection '{self.collection_name}'" 

299 ) 

300 return [] 

301 

302 # Convert to search result format 

303 results = [] 

304 for r in search_results: 

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

306 

307 # Get document ID 

308 doc_id = ( 

309 r.source_id 

310 or metadata.get("source_id") 

311 or metadata.get("document_id") 

312 ) 

313 

314 # Get title 

315 title = ( 

316 r.document_title 

317 or metadata.get("document_title") 

318 or metadata.get("title") 

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

320 ) 

321 

322 # Create snippet from content 

323 snippet = ( 

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

325 if len(r.text) > SNIPPET_LENGTH_LONG 

326 else r.text 

327 ) 

328 

329 # Generate document URL. Validate chunk_idx and 

330 # sanitise doc_id through the shared helpers so a 

331 # malformed chunk index (UUID, boolean, negative) or 

332 # path-traversal doc_id cannot leak into the citation. 

333 chunk_idx = extract_chunk_index(metadata) 

334 # Authoritative id FIRST. ``extract_document_id`` scans its 

335 # first mapping before any later one, so passing ``metadata`` 

336 # first would let a chunk's denormalised metadata override 

337 # ``r.source_id`` — the DocumentChunk FK that decides which 

338 # document the chunk actually belongs to. Pre-#5381 the 

339 # column always won; a divergent metadata id would otherwise 

340 # point the citation at a different document. 

341 sanitised_doc_id = extract_document_id( 

342 {"source_id": doc_id} if doc_id else None, 

343 metadata, 

344 ) 

345 document_url = self._get_document_url( 

346 sanitised_doc_id, chunk_index=chunk_idx 

347 ) 

348 

349 # Add collection info to metadata 

350 metadata["collection_id"] = self.collection_id 

351 metadata["collection_name"] = ( 

352 self.collection_name or "Unknown" 

353 ) 

354 

355 # r.distance's meaning depends on r.metric. Map both to a 

356 # [0, 1] relevance that grows with similarity: 

357 # - cosine/dot_product (inner product on normalized 

358 # vectors): a similarity in [-1, 1] where higher is 

359 # nearer, so (d+1)/2 clamped to [0, 1]. Using it raw would 

360 # yield negative relevance for anti-correlated hits and 

361 # break downstream [0,1] consumers (similarity %, filters). 

362 # - anything else (l2, or a non-standard metric string): a 

363 # (squared) distance >= 0 where LOWER is nearer, so 

364 # 1/(1+d) in (0, 1]. 

365 # This IP test MUST match faiss_store._build_base_index, 

366 # which builds an inner-product index iff the metric is 

367 # cosine/dot_product and an L2 index otherwise. A bare 

368 # `metric == "l2"` here would score a non-standard metric 

369 # (which builds an L2 index) with the IP formula, inverting 

370 # the ranking. 

371 relevance = ( 

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

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

374 else 1.0 / (1.0 + r.distance) 

375 ) 

376 

377 result = { 

378 "title": title, 

379 "snippet": snippet, 

380 "url": document_url, 

381 "link": document_url, 

382 "source": "library", 

383 "source_type": "library", 

384 # Authoritative document id at the TOP level, not only 

385 # nested in ``metadata``: SearchResultsCollector rebuilds 

386 # this citation URL and would otherwise have nothing but 

387 # the denormalised metadata to go on. 

388 "source_id": doc_id, 

389 "relevance_score": float(relevance), 

390 "metadata": metadata, 

391 } 

392 results.append(result) 

393 

394 logger.info( 

395 f"Collection '{self.collection_name}' search returned " 

396 f"{len(results)} results" 

397 ) 

398 

399 return results 

400 

401 except Exception as e: 

402 # Re-raise instead of returning [] so a failed search is not 

403 # indistinguishable from "no matching documents": run() records 

404 # the failure in metrics, and API callers can report the error. 

405 safe_msg = self._scrub_error(e) 

406 logger.exception( 

407 f"Error searching collection '{self.collection_name}' ({type(e).__name__}): {safe_msg}" 

408 ) 

409 raise