Coverage for agentos/rag/hybrid_search.py: 23%

267 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-06 10:59 +0800

1""" 

2Hybrid Search + Re-Ranking for RAG (v1.9.0) 

3 

4Production-grade hybrid search combining: 

5 - Dense (semantic) retrieval via embeddings 

6 - Sparse (keyword) retrieval via BM25 

7 - Cross-encoder re-ranking for precision 

8 - Citation tracking with source provenance 

9 - Multi-modal: text, code, markdown, tables 

10 - Fusion algorithms: RRF, weighted sum, cascade 

11 

12Compatible with existing ChromaStore + RAGPipeline. 

13""" 

14 

15from __future__ import annotations 

16 

17import math 

18import re 

19from collections import Counter, defaultdict 

20from dataclasses import dataclass, field 

21from typing import Any, Optional, Callable 

22 

23 

24# ── Types ─────────────────────────────────────────────────────────── 

25 

26@dataclass 

27class SearchResult: 

28 """A single search result with metadata.""" 

29 doc_id: str 

30 content: str 

31 source: str = "" # File path, URL, or source identifier 

32 title: str = "" 

33 score: float = 0.0 

34 dense_score: float = 0.0 

35 sparse_score: float = 0.0 

36 rerank_score: float = 0.0 

37 chunk_index: int = 0 

38 metadata: dict[str, Any] = field(default_factory=dict) 

39 citations: list[str] = field(default_factory=list) # Specific sentences/quotes 

40 

41 

42@dataclass 

43class Citation: 

44 """A citation from source material.""" 

45 text: str 

46 source: str 

47 doc_id: str = "" 

48 chunk_index: int = 0 

49 start_pos: int = 0 

50 end_pos: int = 0 

51 confidence: float = 1.0 

52 

53 

54# ── BM25 Sparse Retriever ─────────────────────────────────────────── 

55 

56class BM25Retriever: 

57 """Pure Python BM25 implementation for keyword search. 

58 

59 No external dependencies. Tokenizes, builds inverted index, 

60 and scores documents using Okapi BM25. 

61 """ 

62 

63 def __init__(self, k1: float = 1.5, b: float = 0.75): 

64 self.k1 = k1 

65 self.b = b 

66 self._docs: list[str] = [] 

67 self._doc_ids: list[str] = [] 

68 self._doc_lengths: list[int] = [] 

69 self._avg_dl: float = 0.0 

70 self._inverted_index: dict[str, dict[int, int]] = defaultdict(dict) 

71 self._idf: dict[str, float] = {} 

72 self._N: int = 0 

73 

74 def index(self, documents: list[dict[str, str]]): 

75 """Build BM25 index from documents. 

76 

77 Args: 

78 documents: List of {id, content} dicts. 

79 """ 

80 self._docs = [doc.get("content", "") for doc in documents] 

81 self._doc_ids = [doc.get("id", f"doc_{i}") for i, doc in enumerate(documents)] 

82 self._doc_lengths = [len(self._tokenize(doc)) for doc in self._docs] 

83 self._N = len(self._docs) 

84 self._avg_dl = sum(self._doc_lengths) / max(self._N, 1) 

85 

86 # Build inverted index 

87 self._inverted_index.clear() 

88 doc_freq: dict[str, int] = defaultdict(int) 

89 

90 for doc_id, doc in enumerate(self._docs): 

91 tokens = self._tokenize(doc) 

92 token_counts = Counter(tokens) 

93 for token, count in token_counts.items(): 

94 self._inverted_index[token][doc_id] = count 

95 doc_freq[token] += 1 

96 

97 # Compute IDF 

98 self._idf = { 

99 token: math.log(1 + (self._N - freq + 0.5) / (freq + 0.5)) 

100 for token, freq in doc_freq.items() 

101 } 

102 

103 def search(self, query: str, top_k: int = 10) -> list[SearchResult]: 

104 """BM25 keyword search.""" 

105 if not self._docs: 

106 return [] 

107 

108 query_tokens = self._tokenize(query) 

109 scores: list[float] = [0.0] * self._N 

110 

111 for token in query_tokens: 

112 if token not in self._inverted_index: 

113 continue 

114 idf = self._idf.get(token, 0) 

115 for doc_id, tf in self._inverted_index[token].items(): 

116 dl = self._doc_lengths[doc_id] 

117 numerator = tf * (self.k1 + 1) 

118 denominator = tf + self.k1 * (1 - self.b + self.b * dl / max(self._avg_dl, 1)) 

119 scores[doc_id] += idf * numerator / max(denominator, 1e-9) 

120 

121 # Rank and return 

122 ranked = sorted(enumerate(scores), key=lambda x: x[1], reverse=True) 

123 max_score = ranked[0][1] if ranked else 1.0 

124 

125 return [ 

126 SearchResult( 

127 doc_id=self._doc_ids[doc_id], 

128 content=self._docs[doc_id][:500], 

129 sparse_score=score / max(max_score, 1e-9), 

130 score=score / max(max_score, 1e-9), 

131 metadata={"method": "bm25"}, 

132 ) 

133 for doc_id, score in ranked[:top_k] if score > 0 

134 ] 

135 

136 def _tokenize(self, text: str) -> list[str]: 

137 """Simple tokenization: lowercase, split on non-alphanumeric, filter short tokens.""" 

138 tokens = re.findall(r'[\w\u4e00-\u9fff]+', text.lower()) 

139 return [t for t in tokens if len(t) > 1] 

140 

141 

142# ── Dense Retriever ───────────────────────────────────────────────── 

143 

144class DenseRetriever: 

145 """Semantic search via embeddings. 

146 

147 Wraps an embedding function (e.g., OpenAI embeddings, sentence-transformers) 

148 and a vector store (ChromaDB or similar). 

149 """ 

150 

151 def __init__( 

152 self, 

153 vector_store=None, 

154 embed_fn: Optional[Callable[[str], list[float]]] = None, 

155 ): 

156 self._store = vector_store 

157 self._embed = embed_fn 

158 

159 async def search(self, query: str, top_k: int = 10) -> list[SearchResult]: 

160 """Dense vector search.""" 

161 if not self._store: 

162 return [] 

163 

164 try: 

165 results = await self._store.search(query, top_k=top_k) 

166 

167 max_score = results[0].get("score", 1.0) if results else 1.0 

168 

169 return [ 

170 SearchResult( 

171 doc_id=result.get("id", ""), 

172 content=result.get("content", "")[:500], 

173 dense_score=result.get("score", 0) / max(max_score, 1e-9), 

174 score=result.get("score", 0) / max(max_score, 1e-9), 

175 metadata=result.get("metadata", {}), 

176 ) 

177 for result in results 

178 ] 

179 except Exception: 

180 return [] 

181 

182 

183# ── Cross-Encoder Re-Ranker ───────────────────────────────────────── 

184 

185class CrossEncoderReranker: 

186 """Re-rank search results with a cross-encoder model. 

187 

188 Instead of embedding query and documents independently (bi-encoder), 

189 a cross-encoder processes (query, document) pairs together for higher 

190 accuracy — at the cost of more computation. 

191 

192 Supports: 

193 - HuggingFace cross-encoder models (e.g., ms-marco-MiniLM) 

194 - Custom scoring functions 

195 - LLM-based re-ranking (use an LLM to judge relevance) 

196 """ 

197 

198 def __init__( 

199 self, 

200 model_name: str = "cross-encoder/ms-marco-MiniLM-L-6-v2", 

201 use_llm: bool = False, 

202 llm_client=None, 

203 ): 

204 self._model_name = model_name 

205 self._model = None 

206 self._use_llm = use_llm 

207 self._llm = llm_client 

208 

209 async def rerank( 

210 self, 

211 query: str, 

212 candidates: list[SearchResult], 

213 top_k: int = 5, 

214 ) -> list[SearchResult]: 

215 """Re-rank candidates by relevance to query. 

216 

217 Args: 

218 query: Original search query 

219 candidates: Initial retrieval results 

220 top_k: Number of results to return after re-ranking 

221 

222 Returns: 

223 Re-ranked candidates with updated rerank_score. 

224 """ 

225 if not candidates: 

226 return [] 

227 

228 if self._use_llm and self._llm: 

229 return await self._llm_rerank(query, candidates, top_k) 

230 else: 

231 return await self._cross_encoder_rerank(query, candidates, top_k) 

232 

233 async def _cross_encoder_rerank( 

234 self, 

235 query: str, 

236 candidates: list[SearchResult], 

237 top_k: int, 

238 ) -> list[SearchResult]: 

239 """Re-rank using HuggingFace cross-encoder.""" 

240 try: 

241 from sentence_transformers import CrossEncoder 

242 if self._model is None: 

243 self._model = CrossEncoder(self._model_name) 

244 

245 pairs = [(query, c.content[:1000]) for c in candidates] 

246 scores = self._model.predict(pairs) 

247 

248 for candidate, score in zip(candidates, scores): 

249 candidate.rerank_score = float(score) 

250 # Weighted fusion 

251 candidate.score = ( 

252 candidate.dense_score * 0.3 + 

253 candidate.sparse_score * 0.2 + 

254 float(score) * 0.5 

255 ) 

256 

257 candidates.sort(key=lambda x: x.rerank_score, reverse=True) 

258 return candidates[:top_k] 

259 

260 except ImportError: 

261 return candidates[:top_k] # Fallback: no re-ranking 

262 

263 async def _llm_rerank( 

264 self, 

265 query: str, 

266 candidates: list[SearchResult], 

267 top_k: int, 

268 ) -> list[SearchResult]: 

269 """Re-rank using LLM relevance judgment.""" 

270 if not self._llm: 

271 return candidates[:top_k] 

272 

273 prompt = f"Query: {query}\n\nRate each document's relevance on a scale of 0-10:\n\n" 

274 for i, c in enumerate(candidates[:20]): 

275 prompt += f"[{i}] {c.content[:300]}\n\n" 

276 prompt += "Output format: [doc_id] score" 

277 

278 try: 

279 response = await self._llm.complete(prompt) 

280 # Parse scores 

281 scores: dict[int, float] = {} 

282 for line in response.split("\n"): 

283 match = re.match(r'\[(\d+)\]\s*(\d+(?:\.\d+)?)', line.strip()) 

284 if match: 

285 idx = int(match.group(1)) 

286 score = float(match.group(2)) / 10.0 

287 if idx < len(candidates): 

288 scores[idx] = score 

289 

290 for i, candidate in enumerate(candidates): 

291 candidate.rerank_score = scores.get(i, 0.5) 

292 candidate.score = ( 

293 candidate.dense_score * 0.25 + 

294 candidate.sparse_score * 0.15 + 

295 candidate.rerank_score * 0.6 

296 ) 

297 

298 candidates.sort(key=lambda x: x.rerank_score, reverse=True) 

299 return candidates[:top_k] 

300 

301 except Exception: 

302 return candidates[:top_k] 

303 

304 

305# ── Fusion Algorithms ─────────────────────────────────────────────── 

306 

307class FusionMethod: 

308 """Collection of rank fusion algorithms.""" 

309 

310 @staticmethod 

311 def reciprocal_rank_fusion( 

312 dense_results: list[SearchResult], 

313 sparse_results: list[SearchResult], 

314 k: int = 60, 

315 ) -> list[SearchResult]: 

316 """RRF: Reciprocal Rank Fusion. 

317 

318 RRF_score(d) = sum_{ranker} 1 / (k + rank(d)) 

319 """ 

320 scores: dict[str, float] = {} 

321 docs: dict[str, SearchResult] = {} 

322 

323 for rank, result in enumerate(dense_results): 

324 scores[result.doc_id] = 1.0 / (k + rank + 1) 

325 docs[result.doc_id] = result 

326 

327 for rank, result in enumerate(sparse_results): 

328 if result.doc_id in scores: 

329 scores[result.doc_id] += 1.0 / (k + rank + 1) 

330 else: 

331 scores[result.doc_id] = 1.0 / (k + rank + 1) 

332 docs[result.doc_id] = result 

333 

334 fused = sorted(scores.items(), key=lambda x: x[1], reverse=True) 

335 results = [] 

336 for doc_id, score in fused: 

337 doc = docs[doc_id] 

338 doc.score = score 

339 results.append(doc) 

340 

341 return results 

342 

343 @staticmethod 

344 def weighted_sum( 

345 dense_results: list[SearchResult], 

346 sparse_results: list[SearchResult], 

347 dense_weight: float = 0.6, 

348 sparse_weight: float = 0.4, 

349 ) -> list[SearchResult]: 

350 """Weighted score summation.""" 

351 scores: dict[str, list[float]] = defaultdict(list) 

352 docs: dict[str, SearchResult] = {} 

353 

354 for result in dense_results: 

355 scores[result.doc_id].append(result.dense_score * dense_weight) 

356 docs[result.doc_id] = result 

357 

358 for result in sparse_results: 

359 scores[result.doc_id].append(result.sparse_score * sparse_weight) 

360 if result.doc_id not in docs: 

361 docs[result.doc_id] = result 

362 

363 fused = [] 

364 for doc_id, wscores in scores.items(): 

365 doc = docs[doc_id] 

366 doc.score = sum(wscores) 

367 fused.append(doc) 

368 

369 fused.sort(key=lambda x: x.score, reverse=True) 

370 return fused 

371 

372 @staticmethod 

373 def cascade( 

374 dense_results: list[SearchResult], 

375 sparse_results: list[SearchResult], 

376 ) -> list[SearchResult]: 

377 """Cascade: dense first, then sparse fills gaps.""" 

378 seen: set[str] = set() 

379 results: list[SearchResult] = [] 

380 

381 for r in dense_results: 

382 results.append(r) 

383 seen.add(r.doc_id) 

384 

385 for r in sparse_results: 

386 if r.doc_id not in seen: 

387 results.append(r) 

388 seen.add(r.doc_id) 

389 

390 return results 

391 

392 

393# ── Citation Tracker ──────────────────────────────────────────────── 

394 

395class CitationTracker: 

396 """Track and verify citations from source documents. 

397 

398 Key features: 

399 - Extract citations from generated text 

400 - Verify against source documents 

401 - Mark unverifiable (potential hallucination) 

402 - Track citation usage statistics 

403 """ 

404 

405 def __init__(self): 

406 self._citations: list[Citation] = [] 

407 self._source_index: dict[str, dict] = {} # doc_id → metadata 

408 

409 def add_source(self, doc_id: str, content: str, metadata: dict[str, Any] | None = None): 

410 """Register a source document.""" 

411 self._source_index[doc_id] = { 

412 "content": content, 

413 "metadata": metadata or {}, 

414 } 

415 

416 def extract_citations(self, text: str, sources: list[SearchResult]) -> list[Citation]: 

417 """Extract and verify citations from generated text. 

418 

419 Args: 

420 text: Generated response text 

421 sources: Source documents used for generation 

422 

423 Returns: 

424 List of verified Citation objects. 

425 """ 

426 citations: list[Citation] = [] 

427 

428 for source in sources: 

429 # Find substrings of generated text that appear in source 

430 source_content = source.content.lower() 

431 text_lower = text.lower() 

432 

433 # Extract sentences from generated text 

434 sentences = re.split(r'[.!?]+', text) 

435 for sent in sentences: 

436 sent = sent.strip() 

437 if len(sent) < 15: 

438 continue 

439 

440 # Check if this sentence appears in source (with fuzzy matching) 

441 if self._is_from_source(sent.lower(), source_content): 

442 citations.append(Citation( 

443 text=sent, 

444 source=source.source or source.doc_id, 

445 doc_id=source.doc_id, 

446 chunk_index=source.chunk_index, 

447 confidence=0.9, 

448 )) 

449 

450 # Deduplicate 

451 seen: set[str] = set() 

452 unique = [] 

453 for c in citations: 

454 key = c.text[:50] 

455 if key not in seen: 

456 seen.add(key) 

457 unique.append(c) 

458 

459 self._citations.extend(unique) 

460 return unique 

461 

462 def verify(self, text: str, sources: list[SearchResult]) -> dict[str, Any]: 

463 """Verify all claims in text against source documents. 

464 

465 Returns: 

466 Dict with verified/unverified segments and hallucination score. 

467 """ 

468 citations = self.extract_citations(text, sources) 

469 

470 sentences = re.split(r'[.!?]+', text) 

471 total_sentences = len(sentences) 

472 cited_sentences = sum( 

473 1 for s in sentences 

474 if any(c.text[:30].lower() in s.strip().lower() for c in citations) 

475 ) 

476 

477 uncited = total_sentences - cited_sentences 

478 hallucination_risk = uncited / max(total_sentences, 1) 

479 

480 return { 

481 "total_sentences": total_sentences, 

482 "cited_sentences": cited_sentences, 

483 "uncited_sentences": uncited, 

484 "hallucination_risk": round(hallucination_risk, 3), 

485 "citations": [ 

486 {"text": c.text[:100], "source": c.source, "confidence": c.confidence} 

487 for c in citations[:10] 

488 ], 

489 "status": "clean" if hallucination_risk < 0.3 else "medium_risk" if hallucination_risk < 0.6 else "high_risk", 

490 } 

491 

492 def _is_from_source(self, text: str, source: str, threshold: float = 0.6) -> bool: 

493 """Check if text originated from source using substring and word overlap.""" 

494 if text in source: 

495 return True 

496 

497 text_words = set(text.split()) 

498 source_words = set(source.split()) 

499 if not text_words: 

500 return False 

501 

502 overlap = len(text_words & source_words) / len(text_words) 

503 return overlap >= threshold 

504 

505 def get_stats(self) -> dict[str, Any]: 

506 """Get citation statistics.""" 

507 return { 

508 "total_citations": len(self._citations), 

509 "by_source": Counter(c.source for c in self._citations), 

510 "avg_confidence": ( 

511 sum(c.confidence for c in self._citations) / len(self._citations) 

512 if self._citations else 0 

513 ), 

514 "sources_indexed": len(self._source_index), 

515 } 

516 

517 

518# ── Hybrid Search Engine ──────────────────────────────────────────── 

519 

520class HybridSearchEngine: 

521 """Unified hybrid search engine. 

522 

523 Combines dense + sparse retrieval with fusion and re-ranking. 

524 

525 Usage: 

526 engine = HybridSearchEngine( 

527 dense_retriever=DenseRetriever(vector_store=chroma_store), 

528 sparse_retriever=BM25Retriever(), 

529 ) 

530 

531 # Index documents 

532 engine.index_sparse(documents) 

533 

534 # Hybrid search 

535 results = await engine.search("How to implement retry logic?") 

536 for r in results: 

537 print(f"{r.score:.3f} | {r.content[:100]}") 

538 """ 

539 

540 def __init__( 

541 self, 

542 dense_retriever: Optional[DenseRetriever] = None, 

543 sparse_retriever: Optional[BM25Retriever] = None, 

544 reranker: Optional[CrossEncoderReranker] = None, 

545 citation_tracker: Optional[CitationTracker] = None, 

546 fusion_method: str = "rrf", 

547 dense_weight: float = 0.6, 

548 ): 

549 self.dense = dense_retriever or DenseRetriever() 

550 self.sparse = sparse_retriever or BM25Retriever() 

551 self.reranker = reranker or CrossEncoderReranker() 

552 self.citations = citation_tracker or CitationTracker() 

553 

554 self.fusion_method = fusion_method 

555 self.dense_weight = dense_weight 

556 

557 def index_sparse(self, documents: list[dict[str, str]]): 

558 """Build sparse index from documents.""" 

559 self.sparse.index(documents) 

560 for doc in documents: 

561 self.citations.add_source( 

562 doc_id=doc.get("id", ""), 

563 content=doc.get("content", ""), 

564 metadata=doc.get("metadata"), 

565 ) 

566 

567 async def search( 

568 self, 

569 query: str, 

570 top_k: int = 10, 

571 rerank: bool = True, 

572 return_citations: bool = False, 

573 ) -> list[SearchResult]: 

574 """Hybrid search: dense + sparse → fusion → rerank. 

575 

576 Args: 

577 query: Search query 

578 top_k: Number of results 

579 rerank: Whether to apply re-ranking 

580 return_citations: Whether to attach citation info 

581 

582 Returns: 

583 Ranked SearchResults. 

584 """ 

585 # Step 1: Parallel retrieval 

586 dense_results = await self.dense.search(query, top_k=top_k * 2) 

587 sparse_results = self.sparse.search(query, top_k=top_k * 2) 

588 

589 # Step 2: Fusion 

590 if self.fusion_method == "rrf": 

591 fused = FusionMethod.reciprocal_rank_fusion(dense_results, sparse_results) 

592 elif self.fusion_method == "cascade": 

593 fused = FusionMethod.cascade(dense_results, sparse_results) 

594 else: # weighted_sum 

595 fused = FusionMethod.weighted_sum( 

596 dense_results, sparse_results, 

597 dense_weight=self.dense_weight, 

598 sparse_weight=1.0 - self.dense_weight, 

599 ) 

600 

601 # Step 3: Re-rank (optional) 

602 if rerank and len(fused) > top_k: 

603 fused = await self.reranker.rerank(query, fused, top_k=top_k) 

604 else: 

605 fused = fused[:top_k] 

606 

607 # Step 4: Attach citations (optional) 

608 if return_citations and fused: 

609 for result in fused: 

610 result.citations = [ 

611 c.text for c in self.citations.extract_citations( 

612 result.content, [result] 

613 ) 

614 ] 

615 

616 return fused 

617 

618 async def search_with_citations( 

619 self, 

620 query: str, 

621 top_k: int = 10, 

622 ) -> dict[str, Any]: 

623 """Search and return both results and verified citations.""" 

624 results = await self.search(query, top_k=top_k, return_citations=True) 

625 

626 # Build combined text from top results 

627 combined = "\n\n".join(r.content for r in results) 

628 

629 # Verify citations 

630 verification = self.citations.verify(combined, results) 

631 

632 return { 

633 "results": results, 

634 "verification": verification, 

635 "top_result": results[0] if results else None, 

636 "citation_stats": self.citations.get_stats(), 

637 } 

638 

639 def get_stats(self) -> dict[str, Any]: 

640 """Get search engine statistics.""" 

641 return { 

642 "bm25_documents": self.sparse._N if self.sparse else 0, 

643 "bm25_vocabulary": len(self.sparse._idf) if self.sparse else 0, 

644 "citation_stats": self.citations.get_stats(), 

645 "fusion_method": self.fusion_method, 

646 }