search_unified.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  1. #!/usr/bin/env python3
  2. """
  3. LambdaRAG v2: True Fusion Architecture
  4. All 4 retrieval paths run in parallel for EVERY query.
  5. Classifier adjusts weights, NOT routes.
  6. Results from all paths are fused via Weighted RRF, then reranked.
  7. Architecture:
  8. λ = Classify(q) → weights
  9. >> Par(BM25(q), Vector(q), GraphExpand(q), WikiRead(q))
  10. >> WeightedRRF(results, weights)
  11. >> BuildContext(fused_results, relation_context, wiki_context)
  12. The key insight: Fusion means ALL paths contribute to EVERY answer.
  13. The classifier only controls HOW MUCH each path contributes.
  14. """
  15. import json
  16. import os
  17. import re
  18. import sys
  19. import time
  20. import urllib.request
  21. from collections import defaultdict
  22. from pathlib import Path
  23. from concurrent.futures import ThreadPoolExecutor, as_completed
  24. # ── Skill imports ──
  25. try:
  26. from search_engine import search as _bm25_search_fn
  27. except ImportError:
  28. _bm25_search_fn = None
  29. try:
  30. from search_engine_v2 import _vector_search as _vector_search_fn, _embed_query
  31. except ImportError:
  32. _vector_search_fn = None
  33. _embed_query = None
  34. try:
  35. from graph_engine import get_graph
  36. except ImportError:
  37. get_graph = None
  38. # ── Config ──
  39. VLLM_URL = os.environ.get('VLLM_URL', 'http://127.0.0.1:8000/v1/chat/completions')
  40. WIKI_DIR = os.environ.get('WIKI_DIR', '/app/agentexample/qaagent67wiki/wiki')
  41. # ── Weight profiles per query type ──
  42. # Each weight controls how much that source contributes to final ranking
  43. WEIGHT_PROFILES = {
  44. 'fact': {'bm25': 1.0, 'vector': 0.5, 'graph': 0.1, 'wiki': 0.1},
  45. 'compare': {'bm25': 0.5, 'vector': 0.8, 'graph': 0.4, 'wiki': 0.7},
  46. 'relation': {'bm25': 0.5, 'vector': 0.3, 'graph': 0.7, 'wiki': 0.8},
  47. 'synthesis': {'bm25': 0.6, 'vector': 0.5, 'graph': 0.5, 'wiki': 0.6},
  48. 'temporal': {'bm25': 1.0, 'vector': 0.2, 'graph': 0.05, 'wiki': 0.2},
  49. }
  50. DEFAULT_WEIGHTS = {'bm25': 0.7, 'vector': 0.5, 'graph': 0.3, 'wiki': 0.3}
  51. RRF_K = 60
  52. def _llm_call(prompt, max_tokens=200):
  53. body = json.dumps({
  54. "model": "qwen2.5-32b",
  55. "messages": [{"role": "user", "content": prompt}],
  56. "temperature": 0.0, "max_tokens": max_tokens,
  57. }).encode('utf-8')
  58. req = urllib.request.Request(VLLM_URL, data=body,
  59. headers={"Content-Type": "application/json"}, method="POST")
  60. with urllib.request.urlopen(req, timeout=30) as resp:
  61. data = json.loads(resp.read())
  62. return data["choices"][0]["message"]["content"].strip()
  63. # ══════════════════════════════════════════
  64. # Step 1: Classify → Weights (not routes!)
  65. # ══════════════════════════════════════════
  66. def classify_query(query):
  67. """Classify query type and return weight profile"""
  68. q = query.lower()
  69. # Rule-based fast classification
  70. if re.search(r'\d{4}年.*多少|数量|人数|统计|及格率|总数', q):
  71. qtype = 'fact'
  72. elif re.search(r'异同|比较|区别|对比|不同|相同', q):
  73. qtype = 'compare'
  74. elif re.search(r'关系|隶属|上级|下级|依据|谁.*管|属于|归.*管', q):
  75. qtype = 'relation'
  76. elif re.search(r'最新|修订|变化|趋势|演变|历史|何时.*施行', q):
  77. qtype = 'temporal'
  78. elif re.search(r'总结|综合|梳理|分析.*体系|框架|概述|全面', q):
  79. qtype = 'synthesis'
  80. elif len(query) < 15:
  81. qtype = 'fact'
  82. else:
  83. # LLM fallback
  84. try:
  85. result = _llm_call(
  86. f"分类(只输出一个词): fact/compare/relation/synthesis/temporal\n问题: {query}\n类型:", 10)
  87. qtype = 'fact'
  88. for t in ['fact', 'compare', 'relation', 'synthesis', 'temporal']:
  89. if t in result.lower():
  90. qtype = t
  91. break
  92. except:
  93. qtype = 'fact'
  94. weights = WEIGHT_PROFILES.get(qtype, DEFAULT_WEIGHTS)
  95. return qtype, weights
  96. # ══════════════════════════════════════════
  97. # Step 2: Four parallel retrieval paths
  98. # ══════════════════════════════════════════
  99. def path_bm25(query, top_k=10):
  100. """Path A: BM25 keyword search"""
  101. if _bm25_search_fn:
  102. try:
  103. results = _bm25_search_fn(query, top_k=top_k)
  104. for r in results:
  105. r['_path'] = 'bm25'
  106. return results
  107. except Exception as e:
  108. print(f"[bm25] Error: {e}")
  109. return []
  110. def path_vector(query, top_k=10):
  111. """Path B: Embedding vector search"""
  112. if _vector_search_fn:
  113. try:
  114. results = _vector_search_fn(query, top_k=top_k)
  115. for r in results:
  116. r['_path'] = 'vector'
  117. return results
  118. except Exception as e:
  119. print(f"[vector] Error: {e}")
  120. return []
  121. def path_graph(query, top_k=10):
  122. """Path C: Entity graph expansion → BM25+Vector search"""
  123. if not get_graph:
  124. return []
  125. try:
  126. graph = get_graph(wiki_dir=WIKI_DIR)
  127. entities = graph.find_entities(query, max_results=5)
  128. if not entities:
  129. return []
  130. expand_result = graph.expand_query(query, seed_entities=entities, max_hops=2)
  131. expanded_query = expand_result.get('expanded_query', query)
  132. # Search with expanded query
  133. results = []
  134. if _bm25_search_fn:
  135. bm25_r = _bm25_search_fn(expanded_query, top_k=top_k // 2)
  136. results.extend(bm25_r)
  137. if _vector_search_fn:
  138. vec_r = _vector_search_fn(expanded_query, top_k=top_k // 2)
  139. results.extend(vec_r)
  140. # Also add relation triples as pseudo-results
  141. for rel in expand_result.get('relations', [])[:5]:
  142. results.append({
  143. 'source': f"relation:{rel['subject']}→{rel['object']}",
  144. 'chunk_id': 0,
  145. 'text': f"{rel['subject']} {rel['relation']} {rel['object']}。{rel.get('description', '')}",
  146. 'score': 0.5,
  147. })
  148. for r in results:
  149. r['_path'] = 'graph'
  150. # Store relation context for LLM prompt enrichment
  151. if results:
  152. results[0]['_relation_context'] = expand_result.get('relation_context', '')
  153. results[0]['_entities'] = expand_result.get('entities', [])
  154. return results
  155. except Exception as e:
  156. print(f"[graph] Error: {e}")
  157. return []
  158. def path_wiki(query, top_k=5):
  159. """Path D: Wiki compiled knowledge pages"""
  160. wiki_path = Path(WIKI_DIR)
  161. if not wiki_path.exists():
  162. return []
  163. # Find entities for better matching
  164. entities = []
  165. if get_graph:
  166. try:
  167. graph = get_graph(wiki_dir=WIKI_DIR)
  168. entities = graph.find_entities(query, max_results=5)
  169. except:
  170. pass
  171. pages = []
  172. for subdir in ['sources', 'entities', 'topics', 'analyses']:
  173. d = wiki_path / subdir
  174. if not d.exists():
  175. continue
  176. for f in d.glob('*.md'):
  177. try:
  178. content = f.read_text(encoding='utf-8')
  179. score = 0
  180. for e in entities:
  181. if e in f.stem or e in content[:500]:
  182. score += 10
  183. score += sum(1 for c in set(query) if c in content[:500])
  184. if score > len(query) * 0.2:
  185. pages.append({
  186. 'source': f'wiki:{subdir}/{f.stem}',
  187. 'chunk_id': 0,
  188. 'text': content[:2000],
  189. 'score': score / 100.0,
  190. '_path': 'wiki',
  191. })
  192. except:
  193. pass
  194. pages.sort(key=lambda x: -x['score'])
  195. return pages[:top_k]
  196. # ══════════════════════════════════════════
  197. # Step 3: Weighted RRF Fusion
  198. # ══════════════════════════════════════════
  199. def weighted_rrf_fuse(path_results, weights, k=RRF_K):
  200. """
  201. Weighted Reciprocal Rank Fusion.
  202. Each path's contribution is scaled by its weight.
  203. score(doc) = Σ weight_i / (k + rank_i(doc))
  204. """
  205. scores = defaultdict(float)
  206. chunk_map = {}
  207. path_tags = defaultdict(set) # Track which paths found each doc
  208. for path_name, results in path_results.items():
  209. w = weights.get(path_name, 0.5)
  210. if w == 0:
  211. continue
  212. for rank, r in enumerate(results):
  213. key = f"{r.get('source', '')}#{r.get('chunk_id', 0)}"
  214. scores[key] += w / (k + rank + 1)
  215. if key not in chunk_map:
  216. chunk_map[key] = r.copy()
  217. path_tags[key].add(path_name)
  218. # Sort by fused score
  219. sorted_keys = sorted(scores.items(), key=lambda x: -x[1])
  220. fused = []
  221. for key, score in sorted_keys:
  222. r = chunk_map[key]
  223. r['score'] = round(score, 4)
  224. r['_fusion_paths'] = list(path_tags[key])
  225. r['_fusion_score'] = round(score, 4)
  226. fused.append(r)
  227. return fused
  228. # ══════════════════════════════════════════
  229. # Step 4: Build enriched context for LLM
  230. # ══════════════════════════════════════════
  231. def build_enriched_context(fused_results, top_k=5):
  232. """
  233. Build LLM context from fused results.
  234. Includes: document chunks + relation context + wiki summaries.
  235. This is the key advantage of fusion - multiple knowledge sources in one context.
  236. """
  237. context_parts = []
  238. relation_context = ""
  239. wiki_summaries = []
  240. for i, r in enumerate(fused_results[:top_k]):
  241. source = r.get('source', '')
  242. text = r.get('text', '')
  243. if source.startswith('wiki:'):
  244. wiki_summaries.append(f"[Wiki: {source[5:]}]\n{text[:800]}")
  245. elif source.startswith('relation:'):
  246. # Relations go into a separate section
  247. if relation_context == "":
  248. relation_context = "## 实体关系 (来自知识图谱)\n"
  249. relation_context += f"- {text}\n"
  250. else:
  251. context_parts.append(f"[doc{i+1}: {source}]\n{text}")
  252. # Extract relation context from graph path results
  253. if '_relation_context' in r and r['_relation_context']:
  254. if relation_context == "":
  255. relation_context = "## 实体关系 (来自知识图谱)\n"
  256. relation_context += r['_relation_context'] + "\n"
  257. enriched = ""
  258. if relation_context:
  259. enriched += relation_context + "\n"
  260. if wiki_summaries:
  261. enriched += "## Wiki 编译知识\n" + "\n\n".join(wiki_summaries) + "\n\n"
  262. if context_parts:
  263. enriched += "## 检索文档\n" + "\n\n".join(context_parts)
  264. return enriched
  265. # ══════════════════════════════════════════
  266. # Main search function
  267. # ══════════════════════════════════════════
  268. def search(query, top_k=5):
  269. """
  270. True Fusion search:
  271. 1. Classify → get weights (not route!)
  272. 2. All 4 paths run in parallel
  273. 3. Weighted RRF fusion
  274. 4. Return enriched results
  275. Drop-in replacement for search_engine.search()
  276. """
  277. t0 = time.time()
  278. # Step 1: Classify and get weights
  279. qtype, weights = classify_query(query)
  280. # Step 2: Run all paths in parallel
  281. path_results = {}
  282. with ThreadPoolExecutor(max_workers=4) as pool:
  283. futures = {
  284. pool.submit(path_bm25, query, 10): 'bm25',
  285. pool.submit(path_vector, query, 10): 'vector',
  286. pool.submit(path_graph, query, 10): 'graph',
  287. pool.submit(path_wiki, query, 5): 'wiki',
  288. }
  289. for future in as_completed(futures):
  290. path_name = futures[future]
  291. try:
  292. result = future.result(timeout=15)
  293. path_results[path_name] = result
  294. except Exception as e:
  295. print(f"[{path_name}] Timeout/Error: {e}")
  296. path_results[path_name] = []
  297. # Step 3: Weighted RRF fusion
  298. fused = weighted_rrf_fuse(path_results, weights)
  299. # Step 4: Time filter for temporal queries
  300. if qtype == 'temporal':
  301. fused = _time_filter(fused, query)
  302. elapsed = time.time() - t0
  303. # Add metadata
  304. if fused:
  305. fused[0]['_query_type'] = qtype
  306. fused[0]['_weights'] = weights
  307. fused[0]['_strategy'] = f"fusion({'+'.join(f'{k}:{v}' for k,v in weights.items() if v > 0)})"
  308. fused[0]['_total_time'] = round(elapsed, 3)
  309. fused[0]['_path_counts'] = {k: len(v) for k, v in path_results.items()}
  310. # Build enriched context (stored for LLM prompt building)
  311. fused[0]['_enriched_context'] = build_enriched_context(fused, top_k)
  312. return fused[:top_k]
  313. def _time_filter(results, query):
  314. """Boost temporally relevant results"""
  315. scored = []
  316. for r in results:
  317. boost = 0
  318. text = r.get('text', '') + r.get('source', '')
  319. if any(k in text for k in ['最新修订', '现行有效', '起施行']):
  320. boost += 2
  321. src_years = re.findall(r'(20\d{2})', r.get('source', ''))
  322. if src_years:
  323. boost += (max(int(y) for y in src_years) - 2000) * 0.1
  324. scored.append((r, r.get('score', 0) + boost))
  325. scored.sort(key=lambda x: -x[1])
  326. return [r for r, _ in scored]
  327. # ══════════════════════════════════════════
  328. # CLI test
  329. # ══════════════════════════════════════════
  330. if __name__ == '__main__':
  331. query = sys.argv[1] if len(sys.argv) > 1 else '海上交通安全法与水污染防治法在船舶管理方面有何异同?'
  332. print(f"Query: {query}")
  333. print("=" * 60)
  334. qtype, weights = classify_query(query)
  335. print(f"Type: {qtype}")
  336. print(f"Weights: {weights}")
  337. print(f"\nSearching (all 4 paths parallel)...")
  338. results = search(query, top_k=5)
  339. print(f"Results: {len(results)}")
  340. if results:
  341. meta = results[0]
  342. print(f"Time: {meta.get('_total_time', '?')}s")
  343. print(f"Strategy: {meta.get('_strategy', '?')}")
  344. print(f"Path counts: {meta.get('_path_counts', {})}")
  345. for i, r in enumerate(results):
  346. paths = r.get('_fusion_paths', [])
  347. print(f"\n[{i+1}] score={r.get('score','?')} paths={paths} {r.get('source','?')[:60]}")
  348. print(f" {r.get('text','')[:150]}...")