AI Assistant commited on
Commit
3a2ce42
·
1 Parent(s): 2bc0095

feat(retrieval): implement vectorless hybrid search with zero-dependency BM25 and weighted RRF

Browse files
app/core/bm25.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ import re
3
+ import time
4
+ from typing import List, Dict, Tuple, Optional, Any, Set
5
+ from collections import Counter
6
+ from utils.logger import app_logger
7
+
8
+
9
+ STOPWORDS: Set[str] = {
10
+ # English stopwords
11
+ 'the', 'and', 'for', 'that', 'this', 'with', 'from', 'what', 'which', 'where',
12
+ 'when', 'how', 'why', 'are', 'was', 'were', 'been', 'have', 'has', 'had', 'does',
13
+ 'did', 'about', 'some', 'any', 'each', 'into', 'over', 'after', 'before', 'such',
14
+ 'a', 'an', 'in', 'on', 'at', 'to', 'by', 'of', 'is', 'it', 'its', 'as', 'be',
15
+ # Arabic stopwords
16
+ 'في', 'من', 'على', 'إلى', 'عن', 'مع', 'هذا', 'هذه', 'ذلك', 'تلك', 'التي', 'الذي',
17
+ 'ما', 'ماذا', 'كيف', 'لماذا', 'أين', 'متى', 'كم', 'هل', 'هو', 'هي', 'أن', 'إن', 'كان'
18
+ }
19
+
20
+
21
+ class BM25NodeIndex:
22
+ """
23
+ Lightweight, in-memory Okapi BM25 index over flattened document nodes.
24
+ Zero external dependencies. Highly optimized for section-level retrieval.
25
+ """
26
+ def __init__(self, nodes: List[Dict[str, Any]], k1: float = 1.5, b: float = 0.75):
27
+ self.k1 = k1
28
+ self.b = b
29
+ self.node_ids: List[str] = []
30
+ self.tokenized_corpus: List[List[str]] = []
31
+ self.doc_lens: List[int] = []
32
+ self.doc_freqs: Counter = Counter()
33
+ self.N: int = 0
34
+ self.avgdl: float = 0.0
35
+
36
+ self._build_index(nodes)
37
+
38
+ @staticmethod
39
+ def tokenize(text: str) -> List[str]:
40
+ if not text:
41
+ return []
42
+ tokens = re.findall(r'[\w\-]+', text.lower())
43
+ return [t for t in tokens if len(t) >= 2 and t not in STOPWORDS]
44
+
45
+ def _build_index(self, nodes: List[Dict[str, Any]]):
46
+ self.node_ids = []
47
+ self.tokenized_corpus = []
48
+ self.doc_lens = []
49
+ self.doc_freqs = Counter()
50
+
51
+ for node in nodes:
52
+ nid = node.get("node_id")
53
+ if not nid:
54
+ continue
55
+
56
+ # Weighted field representation:
57
+ # Title: 3x boost
58
+ # Summary: 2x boost
59
+ # Text: 1x (first 3000 chars)
60
+ title = node.get("title") or ""
61
+ summary = node.get("summary") or ""
62
+ text = (node.get("text") or node.get("content") or "")[:3000]
63
+
64
+ title_tokens = self.tokenize(title) * 3
65
+ summary_tokens = self.tokenize(summary) * 2
66
+ text_tokens = self.tokenize(text)
67
+
68
+ combined_tokens = title_tokens + summary_tokens + text_tokens
69
+ if not combined_tokens:
70
+ combined_tokens = self.tokenize(title or nid) or ["document"]
71
+
72
+ self.node_ids.append(nid)
73
+ self.tokenized_corpus.append(combined_tokens)
74
+ dl = len(combined_tokens)
75
+ self.doc_lens.append(dl)
76
+ self.doc_freqs.update(set(combined_tokens))
77
+
78
+ self.N = len(self.node_ids)
79
+ self.avgdl = (sum(self.doc_lens) / self.N) if self.N > 0 else 1.0
80
+
81
+ def search(self, query: str, top_k: int = 5) -> List[Tuple[str, float]]:
82
+ """
83
+ Executes Okapi BM25 scoring over indexed nodes.
84
+ Returns [(node_id, score), ...] sorted descending.
85
+ """
86
+ if self.N == 0:
87
+ return []
88
+
89
+ q_tokens = self.tokenize(query)
90
+ if not q_tokens:
91
+ # Fallback to raw tokens if all words were stopwords
92
+ raw_tokens = re.findall(r'[\w\-]+', query.lower())
93
+ q_tokens = [t for t in raw_tokens if len(t) >= 2]
94
+ if not q_tokens:
95
+ return []
96
+
97
+ scores: List[Tuple[str, float]] = []
98
+
99
+ for idx, doc_tokens in enumerate(self.tokenized_corpus):
100
+ nid = self.node_ids[idx]
101
+ dl = self.doc_lens[idx]
102
+ tf = Counter(doc_tokens)
103
+ score = 0.0
104
+
105
+ for q in q_tokens:
106
+ if q in tf:
107
+ n_q = self.doc_freqs[q]
108
+ # Robertson-Spärck Jones IDF
109
+ idf = math.log((self.N - n_q + 0.5) / (n_q + 0.5) + 1.0)
110
+ f_qd = tf[q]
111
+ denom = f_qd + self.k1 * (1.0 - self.b + self.b * (dl / self.avgdl))
112
+ score += idf * (f_qd * (self.k1 + 1.0)) / denom
113
+
114
+ if score > 0.0:
115
+ scores.append((nid, score))
116
+
117
+ scores.sort(key=lambda x: x[1], reverse=True)
118
+ return scores[:top_k]
app/core/rag_pipeline.py CHANGED
@@ -1,13 +1,15 @@
1
  import asyncio
2
  import re
3
  import time
4
- from typing import Optional, List, Dict, Any
5
  from utils.logger import app_logger
 
6
 
7
 
8
  class VectorlessRAG:
9
  def __init__(self, llm_service):
10
  self.llm = llm_service
 
11
 
12
  @staticmethod
13
  def _parse_paragraphs_with_page_markers(text: str, default_page: Any) -> List[Dict[str, Any]]:
@@ -184,6 +186,74 @@ class VectorlessRAG:
184
 
185
  return pruned_nodes
186
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  async def answer_multi_doc_query_stream(
188
  self,
189
  query: str,
@@ -214,12 +284,39 @@ class VectorlessRAG:
214
  node_map = doc.get("node_map")
215
  flat_sections = doc.get("flat_sections")
216
  doc_name = doc.get("filename") or "Document"
 
 
 
 
 
 
 
217
 
218
- search_res = await self.llm.llm_tree_search(
219
  query, tree, chat_history=chat_history, flat_sections=flat_sections
220
  )
221
- node_ids = search_res.get("node_list", [])
222
- nodes = self.llm.find_nodes_by_ids(tree, node_ids, node_map=node_map)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
 
224
  # Fallback to leading sections for overview/comparative queries if no nodes matched
225
  if not nodes:
@@ -247,7 +344,7 @@ class VectorlessRAG:
247
  n_copy = dict(n)
248
  n_copy["doc_name"] = doc_name
249
  n_copy["filename"] = doc_name
250
- n_copy["doc_id"] = doc.get("doc_id")
251
  tagged_nodes.append(n_copy)
252
  return tagged_nodes
253
 
 
1
  import asyncio
2
  import re
3
  import time
4
+ from typing import Optional, List, Dict, Any, Tuple
5
  from utils.logger import app_logger
6
+ from core.bm25 import BM25NodeIndex
7
 
8
 
9
  class VectorlessRAG:
10
  def __init__(self, llm_service):
11
  self.llm = llm_service
12
+ self._bm25_cache: Dict[str, BM25NodeIndex] = {}
13
 
14
  @staticmethod
15
  def _parse_paragraphs_with_page_markers(text: str, default_page: Any) -> List[Dict[str, Any]]:
 
186
 
187
  return pruned_nodes
188
 
189
+ @staticmethod
190
+ def _get_doc_nodes(doc: Dict[str, Any]) -> List[Dict[str, Any]]:
191
+ """Extracts flattened node dictionaries containing node_id, title, summary, text."""
192
+ node_map = doc.get("node_map") or {}
193
+ flat_sections = doc.get("flat_sections") or []
194
+ if flat_sections and node_map:
195
+ nodes = [node_map[s["node_id"]] for s in flat_sections if s.get("node_id") in node_map]
196
+ if nodes:
197
+ return nodes
198
+ if node_map:
199
+ return list(node_map.values())
200
+
201
+ # Collect from tree recursively
202
+ tree = doc.get("tree") or []
203
+ collected = []
204
+ def collect(items):
205
+ for it in items:
206
+ if isinstance(it, dict):
207
+ collected.append(it)
208
+ if it.get("nodes"):
209
+ collect(it["nodes"])
210
+ collect(tree)
211
+ return collected
212
+
213
+ @staticmethod
214
+ def reciprocal_rank_fusion(
215
+ ranked_lists: List[Tuple[List[str], float]],
216
+ rrf_k: int = 60,
217
+ top_k: int = 4,
218
+ ) -> List[Tuple[str, float]]:
219
+ """
220
+ Weighted Reciprocal Rank Fusion (RRF):
221
+ Combines multiple ranked lists into a single consensus ranking.
222
+ score(d) = sum_L (w_L / (rrf_k + rank_L(d)))
223
+ """
224
+ rrf_scores: Dict[str, float] = {}
225
+ for items, weight in ranked_lists:
226
+ for rank, item_id in enumerate(items, start=1):
227
+ if not item_id:
228
+ continue
229
+ rrf_scores[item_id] = rrf_scores.get(item_id, 0.0) + (weight / (rrf_k + rank))
230
+
231
+ sorted_items = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)
232
+ return sorted_items[:top_k]
233
+
234
+ def bm25_search_nodes(
235
+ self,
236
+ query: str,
237
+ nodes: List[Dict[str, Any]],
238
+ doc_id: Optional[str] = None,
239
+ top_k: int = 5,
240
+ ) -> Tuple[List[Tuple[str, float]], float]:
241
+ """
242
+ Executes Okapi BM25 lexical search with in-memory index caching.
243
+ Returns (matches, latency_ms).
244
+ """
245
+ t0 = time.monotonic()
246
+ cache_key = doc_id or str(id(nodes))
247
+ index = self._bm25_cache.get(cache_key)
248
+ if index is None or index.N != len(nodes):
249
+ index = BM25NodeIndex(nodes)
250
+ if doc_id:
251
+ self._bm25_cache[cache_key] = index
252
+
253
+ matches = index.search(query, top_k=top_k)
254
+ elapsed_ms = (time.monotonic() - t0) * 1000
255
+ return matches, elapsed_ms
256
+
257
  async def answer_multi_doc_query_stream(
258
  self,
259
  query: str,
 
284
  node_map = doc.get("node_map")
285
  flat_sections = doc.get("flat_sections")
286
  doc_name = doc.get("filename") or "Document"
287
+ doc_id = doc.get("doc_id")
288
+
289
+ doc_nodes = self._get_doc_nodes(doc)
290
+
291
+ # Parallel Retrieval: LLM Hierarchical Tree Search + Lexical BM25
292
+ async def run_bm25():
293
+ return self.bm25_search_nodes(query, doc_nodes, doc_id=doc_id, top_k=5)
294
 
295
+ llm_task = self.llm.llm_tree_search(
296
  query, tree, chat_history=chat_history, flat_sections=flat_sections
297
  )
298
+ bm25_task = run_bm25()
299
+
300
+ search_res, (bm25_matches, bm25_latency) = await asyncio.gather(
301
+ llm_task, bm25_task, return_exceptions=False
302
+ )
303
+
304
+ llm_node_ids = search_res.get("node_list", [])
305
+ bm25_node_ids = [nid for nid, sc in bm25_matches if sc > 0]
306
+
307
+ # Weighted Reciprocal Rank Fusion: 0.6 LLM Tree Search, 0.4 BM25
308
+ fused_results = self.reciprocal_rank_fusion(
309
+ ranked_lists=[(llm_node_ids, 0.6), (bm25_node_ids, 0.4)],
310
+ top_k=4 if deep_analysis else 3,
311
+ )
312
+ fused_node_ids = [nid for nid, _ in fused_results]
313
+
314
+ app_logger.info(
315
+ f"[HYBRID-RETRIEVAL] doc='{doc_name}' llm_nodes={llm_node_ids} "
316
+ f"bm25_nodes={bm25_node_ids} fused={fused_node_ids} bm25_latency={bm25_latency:.2f}ms"
317
+ )
318
+
319
+ nodes = self.llm.find_nodes_by_ids(tree, fused_node_ids, node_map=node_map)
320
 
321
  # Fallback to leading sections for overview/comparative queries if no nodes matched
322
  if not nodes:
 
344
  n_copy = dict(n)
345
  n_copy["doc_name"] = doc_name
346
  n_copy["filename"] = doc_name
347
+ n_copy["doc_id"] = doc_id
348
  tagged_nodes.append(n_copy)
349
  return tagged_nodes
350
 
app/core/storage.py CHANGED
@@ -25,6 +25,7 @@ class MemoryStorage:
25
  self._documents: Dict[str, Dict[str, Any]] = {}
26
  self._node_maps: Dict[str, Dict[str, Any]] = {}
27
  self._flat_sections: Dict[str, List[Dict[str, Any]]] = {}
 
28
  # hash → doc_id quick lookup (in-memory only; rebuilt from DB on demand)
29
  self._hash_to_doc_id: Dict[str, str] = {}
30
  # active background indexing tasks
@@ -175,6 +176,12 @@ class MemoryStorage:
175
  def set_flat_sections(self, doc_id: str, sections: List[Dict[str, Any]]) -> None:
176
  self._flat_sections[doc_id] = sections
177
 
 
 
 
 
 
 
178
  # ──────────────────────────────────────────────────────────────────────
179
  # Document lifecycle
180
  # ──────────────────────────────────────────────────────────────────────
 
25
  self._documents: Dict[str, Dict[str, Any]] = {}
26
  self._node_maps: Dict[str, Dict[str, Any]] = {}
27
  self._flat_sections: Dict[str, List[Dict[str, Any]]] = {}
28
+ self._bm25_indices: Dict[str, Any] = {}
29
  # hash → doc_id quick lookup (in-memory only; rebuilt from DB on demand)
30
  self._hash_to_doc_id: Dict[str, str] = {}
31
  # active background indexing tasks
 
176
  def set_flat_sections(self, doc_id: str, sections: List[Dict[str, Any]]) -> None:
177
  self._flat_sections[doc_id] = sections
178
 
179
+ def get_bm25_index(self, doc_id: str) -> Optional[Any]:
180
+ return self._bm25_indices.get(doc_id)
181
+
182
+ def set_bm25_index(self, doc_id: str, index: Any) -> None:
183
+ self._bm25_indices[doc_id] = index
184
+
185
  # ──────────────────────────────────────────────────────────────────────
186
  # Document lifecycle
187
  # ──────────────────────────────────────────────────────────────────────
app/routers/documents.py CHANGED
@@ -59,6 +59,13 @@ async def run_background_indexing(
59
  # 3. Store in-memory acceleration indexes
60
  storage.set_node_map(doc_id, node_map)
61
  storage.set_flat_sections(doc_id, flat_sections)
 
 
 
 
 
 
 
62
 
63
  total_duration = round(time.monotonic() - t_start, 2)
64
 
 
59
  # 3. Store in-memory acceleration indexes
60
  storage.set_node_map(doc_id, node_map)
61
  storage.set_flat_sections(doc_id, flat_sections)
62
+ try:
63
+ from core.bm25 import BM25NodeIndex
64
+ doc_nodes = [node_map[s["node_id"]] for s in flat_sections if s.get("node_id") in node_map]
65
+ if doc_nodes:
66
+ storage.set_bm25_index(doc_id, BM25NodeIndex(doc_nodes))
67
+ except Exception as bm_ex:
68
+ app_logger.warning(f"Could not pre-warm BM25 index for {doc_id}: {bm_ex}")
69
 
70
  total_duration = round(time.monotonic() - t_start, 2)
71
 
test_phase2_hybrid_bm25_rrf.py ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import time
4
+ import asyncio
5
+
6
+ # Ensure app path is discoverable
7
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
8
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "app"))
9
+
10
+ from core.bm25 import BM25NodeIndex
11
+ from core.rag_pipeline import VectorlessRAG
12
+
13
+
14
+ def test_bm25_node_index_scoring_and_latency():
15
+ """Verify BM25NodeIndex scores nodes correctly and executes in under 20ms."""
16
+ nodes = [
17
+ {
18
+ "node_id": "sec_01",
19
+ "title": "Introduction to Vectorless RAG",
20
+ "summary": "Overview of tree-based hierarchical indexing without vector databases or chunking.",
21
+ "text": "Traditional RAG depends on embedding models and vector similarity. Vectorless RAG navigates the document tree directly."
22
+ },
23
+ {
24
+ "node_id": "sec_02",
25
+ "title": "Knowledge Graph Representation Learning",
26
+ "summary": "Covers TransE, ERNIE, and entity embedding alignments.",
27
+ "text": "ERNIE fuses entity representations into text tokens by utilizing TransE knowledge-graph embeddings with multi-layer encoders."
28
+ },
29
+ {
30
+ "node_id": "sec_03",
31
+ "title": "Evaluation Benchmark & Cost",
32
+ "summary": "Benchmark results on 80 questions with 87.7% correctness.",
33
+ "text": "Evaluated on an 80-question benchmark with 1.8s avg latency and $0.0009 cost per query."
34
+ },
35
+ ]
36
+
37
+ t0 = time.monotonic()
38
+ index = BM25NodeIndex(nodes)
39
+ build_time_ms = (time.monotonic() - t0) * 1000
40
+
41
+ # Search query specifically targeting Section 2
42
+ query = "How does ERNIE integrate TransE embeddings into language models?"
43
+ t_search_0 = time.monotonic()
44
+ results = index.search(query, top_k=3)
45
+ search_time_ms = (time.monotonic() - t_search_0) * 1000
46
+
47
+ print(f"BM25 build time: {build_time_ms:.2f}ms, search time: {search_time_ms:.2f}ms")
48
+ assert search_time_ms < 20.0, f"Lexical search exceeded 20ms limit: {search_time_ms}ms"
49
+ assert len(results) > 0, "Expected at least 1 BM25 match"
50
+
51
+ # Top match must be sec_02 (ERNIE / TransE)
52
+ top_nid, top_score = results[0]
53
+ assert top_nid == "sec_02", f"Expected sec_02 to rank highest, got {top_nid}"
54
+ assert top_score > 0.0
55
+
56
+ print("✓ Test 1 Passed: BM25NodeIndex accurately scored relevant section in under 20ms!")
57
+
58
+
59
+ def test_bm25_arabic_support_and_title_boost():
60
+ """Verify Arabic tokenization and title field weighting."""
61
+ nodes = [
62
+ {
63
+ "node_id": "ar_01",
64
+ "title": "بنية الذاكرة المتعددة",
65
+ "summary": "نظام الذاكرة ثلاثي الطبقات على PostgreSQL.",
66
+ "text": "عزل محادثات الجلسات وسجلات المستخدمين وتوليد ملخصات تلقائية."
67
+ },
68
+ {
69
+ "node_id": "ar_02",
70
+ "title": "البحث الحي عبر FastMCP",
71
+ "summary": "استرجاع نتائج الويب والتحقق من صحة الروابط.",
72
+ "text": "فحص الروابط الحية والتأكد من توافر المحتوى المطلوب."
73
+ }
74
+ ]
75
+ index = BM25NodeIndex(nodes)
76
+ results = index.search("ما هو نظام الذاكرة في قاعدة البيانات؟", top_k=2)
77
+ assert len(results) > 0
78
+ assert results[0][0] == "ar_01", f"Expected ar_01 to rank highest for memory query, got {results[0][0]}"
79
+ print("✓ Test 2 Passed: Arabic tokenization and field weighting verified!")
80
+
81
+
82
+ def test_weighted_reciprocal_rank_fusion():
83
+ """Verify Weighted Reciprocal Rank Fusion combines LLM and BM25 results with 0.6 and 0.4 weights."""
84
+ llm_ranked = ["sec_02", "sec_01"]
85
+ bm25_ranked = ["sec_02", "sec_03", "sec_01"]
86
+
87
+ fused = VectorlessRAG.reciprocal_rank_fusion(
88
+ ranked_lists=[(llm_ranked, 0.6), (bm25_ranked, 0.4)],
89
+ rrf_k=60,
90
+ top_k=3,
91
+ )
92
+
93
+ fused_nids = [nid for nid, sc in fused]
94
+ # sec_02 is Rank 1 in both lists -> must be top fused
95
+ assert fused_nids[0] == "sec_02", f"Expected sec_02 at top of RRF, got {fused_nids[0]}"
96
+
97
+ # Calculate expected mathematical RRF scores:
98
+ # sec_02: 0.6/(60+1) + 0.4/(60+1) = 1.0 / 61 = ~0.016393
99
+ # sec_01: 0.6/(60+2) + 0.4/(60+3) = 0.6/62 + 0.4/63 = ~0.009677 + ~0.006349 = ~0.016026
100
+ # sec_03: 0.0 + 0.4/(60+2) = 0.4/62 = ~0.006452
101
+ score_sec02 = dict(fused)["sec_02"]
102
+ score_sec01 = dict(fused)["sec_01"]
103
+ score_sec03 = dict(fused)["sec_03"]
104
+ assert score_sec02 > score_sec01 > score_sec03
105
+ print(f"✓ Test 3 Passed: Weighted RRF correctly prioritized agreed nodes (Scores: sec_02={score_sec02:.6f}, sec_01={score_sec01:.6f}, sec_03={score_sec03:.6f})!")
106
+
107
+
108
+ def test_parallel_hybrid_retrieval_multi_doc():
109
+ """Verify concurrent execution of tree search + BM25 across multiple attached documents."""
110
+ class MockLLMService:
111
+ async def llm_tree_search(self, query, tree, chat_history=None, flat_sections=None):
112
+ # Simulate ~30ms async LLM search
113
+ await asyncio.sleep(0.03)
114
+ # LLM picks section 1
115
+ return {"node_list": ["doc1_sec1"]}
116
+
117
+ def find_nodes_by_ids(self, tree, target_ids, node_map=None):
118
+ if node_map:
119
+ return [node_map[nid] for nid in target_ids if nid in node_map]
120
+ return [{"node_id": nid, "title": f"Title {nid}", "text": "Content"} for nid in target_ids]
121
+
122
+ mock_llm = MockLLMService()
123
+ rag = VectorlessRAG(mock_llm)
124
+
125
+ doc1 = {
126
+ "doc_id": "doc_1",
127
+ "filename": "Doc1.pdf",
128
+ "tree": [],
129
+ "node_map": {
130
+ "doc1_sec1": {"node_id": "doc1_sec1", "title": "Overview", "text": "General overview"},
131
+ "doc1_sec2": {"node_id": "doc1_sec2", "title": "ERNIE Details", "text": "ERNIE TransE architecture"},
132
+ },
133
+ "flat_sections": [
134
+ {"node_id": "doc1_sec1", "title": "Overview", "summary": "General overview"},
135
+ {"node_id": "doc1_sec2", "title": "ERNIE Details", "summary": "ERNIE TransE architecture"},
136
+ ],
137
+ }
138
+
139
+ doc2 = {
140
+ "doc_id": "doc_2",
141
+ "filename": "Doc2.pdf",
142
+ "tree": [],
143
+ "node_map": {
144
+ "doc2_sec1": {"node_id": "doc2_sec1", "title": "Financial Results", "text": "Revenue and profit"},
145
+ },
146
+ "flat_sections": [
147
+ {"node_id": "doc2_sec1", "title": "Financial Results", "summary": "Revenue and profit"},
148
+ ],
149
+ }
150
+
151
+ async def run_test():
152
+ query = "What is ERNIE TransE architecture?"
153
+ t0 = time.monotonic()
154
+ results_generator = rag.answer_multi_doc_query_stream(
155
+ query=query,
156
+ docs=[doc1, doc2],
157
+ chat_history=[],
158
+ )
159
+ # Collect first yielded stage event to measure retrieval latency
160
+ stage_event = await results_generator.__anext__()
161
+ total_retrieval_ms = (time.monotonic() - t0) * 1000
162
+ print(f"Parallel hybrid retrieval across 2 docs took {total_retrieval_ms:.2f}ms")
163
+ assert "STAGE" in stage_event
164
+ assert total_retrieval_ms < 150.0 # Concurrently waited for LLM ~30ms + BM25 ~1ms
165
+ print("✓ Test 4 Passed: Multi-document parallel hybrid retrieval verified!")
166
+
167
+ asyncio.run(run_test())
168
+
169
+
170
+ if __name__ == "__main__":
171
+ print("\n--- RUNNING PHASE 2 HYBRID RETRIEVAL (BM25 + RRF) TEST SUITE ---")
172
+ test_bm25_node_index_scoring_and_latency()
173
+ test_bm25_arabic_support_and_title_boost()
174
+ test_weighted_reciprocal_rank_fusion()
175
+ test_parallel_hybrid_retrieval_multi_doc()
176
+ print("\n=======================================================")
177
+ print(" ALL 4 PHASE 2 HYBRID RETRIEVAL TESTS PASSED PERFECTLY!")
178
+ print("=======================================================\n")