#!/usr/bin/env python3 """ LambdaRAG v2: True Fusion Architecture All 4 retrieval paths run in parallel for EVERY query. Classifier adjusts weights, NOT routes. Results from all paths are fused via Weighted RRF, then reranked. Architecture: λ = Classify(q) → weights >> Par(BM25(q), Vector(q), GraphExpand(q), WikiRead(q)) >> WeightedRRF(results, weights) >> BuildContext(fused_results, relation_context, wiki_context) The key insight: Fusion means ALL paths contribute to EVERY answer. The classifier only controls HOW MUCH each path contributes. """ import json import os import re import sys import time import urllib.request from collections import defaultdict from pathlib import Path from concurrent.futures import ThreadPoolExecutor, as_completed # ── Skill imports ── try: from search_engine import search as _bm25_search_fn except ImportError: _bm25_search_fn = None try: from search_engine_v2 import _vector_search as _vector_search_fn, _embed_query except ImportError: _vector_search_fn = None _embed_query = None try: from graph_engine import get_graph except ImportError: get_graph = None # ── Config ── VLLM_URL = os.environ.get('VLLM_URL', 'http://127.0.0.1:8000/v1/chat/completions') WIKI_DIR = os.environ.get('WIKI_DIR', '/app/agentexample/qaagent67wiki/wiki') # ── Weight profiles per query type ── # Each weight controls how much that source contributes to final ranking WEIGHT_PROFILES = { 'fact': {'bm25': 1.0, 'vector': 0.5, 'graph': 0.1, 'wiki': 0.1}, 'compare': {'bm25': 0.5, 'vector': 0.8, 'graph': 0.4, 'wiki': 0.7}, 'relation': {'bm25': 0.5, 'vector': 0.3, 'graph': 0.7, 'wiki': 0.8}, 'synthesis': {'bm25': 0.6, 'vector': 0.5, 'graph': 0.5, 'wiki': 0.6}, 'temporal': {'bm25': 1.0, 'vector': 0.2, 'graph': 0.05, 'wiki': 0.2}, } DEFAULT_WEIGHTS = {'bm25': 0.7, 'vector': 0.5, 'graph': 0.3, 'wiki': 0.3} RRF_K = 60 def _llm_call(prompt, max_tokens=200): body = json.dumps({ "model": "qwen2.5-32b", "messages": [{"role": "user", "content": prompt}], "temperature": 0.0, "max_tokens": max_tokens, }).encode('utf-8') req = urllib.request.Request(VLLM_URL, data=body, headers={"Content-Type": "application/json"}, method="POST") with urllib.request.urlopen(req, timeout=30) as resp: data = json.loads(resp.read()) return data["choices"][0]["message"]["content"].strip() # ══════════════════════════════════════════ # Step 1: Classify → Weights (not routes!) # ══════════════════════════════════════════ def classify_query(query): """Classify query type and return weight profile""" q = query.lower() # Rule-based fast classification if re.search(r'\d{4}年.*多少|数量|人数|统计|及格率|总数', q): qtype = 'fact' elif re.search(r'异同|比较|区别|对比|不同|相同', q): qtype = 'compare' elif re.search(r'关系|隶属|上级|下级|依据|谁.*管|属于|归.*管', q): qtype = 'relation' elif re.search(r'最新|修订|变化|趋势|演变|历史|何时.*施行', q): qtype = 'temporal' elif re.search(r'总结|综合|梳理|分析.*体系|框架|概述|全面', q): qtype = 'synthesis' elif len(query) < 15: qtype = 'fact' else: # LLM fallback try: result = _llm_call( f"分类(只输出一个词): fact/compare/relation/synthesis/temporal\n问题: {query}\n类型:", 10) qtype = 'fact' for t in ['fact', 'compare', 'relation', 'synthesis', 'temporal']: if t in result.lower(): qtype = t break except: qtype = 'fact' weights = WEIGHT_PROFILES.get(qtype, DEFAULT_WEIGHTS) return qtype, weights # ══════════════════════════════════════════ # Step 2: Four parallel retrieval paths # ══════════════════════════════════════════ def path_bm25(query, top_k=10): """Path A: BM25 keyword search""" if _bm25_search_fn: try: results = _bm25_search_fn(query, top_k=top_k) for r in results: r['_path'] = 'bm25' return results except Exception as e: print(f"[bm25] Error: {e}") return [] def path_vector(query, top_k=10): """Path B: Embedding vector search""" if _vector_search_fn: try: results = _vector_search_fn(query, top_k=top_k) for r in results: r['_path'] = 'vector' return results except Exception as e: print(f"[vector] Error: {e}") return [] def path_graph(query, top_k=10): """Path C: Entity graph expansion → BM25+Vector search""" if not get_graph: return [] try: graph = get_graph(wiki_dir=WIKI_DIR) entities = graph.find_entities(query, max_results=5) if not entities: return [] expand_result = graph.expand_query(query, seed_entities=entities, max_hops=2) expanded_query = expand_result.get('expanded_query', query) # Search with expanded query results = [] if _bm25_search_fn: bm25_r = _bm25_search_fn(expanded_query, top_k=top_k // 2) results.extend(bm25_r) if _vector_search_fn: vec_r = _vector_search_fn(expanded_query, top_k=top_k // 2) results.extend(vec_r) # Also add relation triples as pseudo-results for rel in expand_result.get('relations', [])[:5]: results.append({ 'source': f"relation:{rel['subject']}→{rel['object']}", 'chunk_id': 0, 'text': f"{rel['subject']} {rel['relation']} {rel['object']}。{rel.get('description', '')}", 'score': 0.5, }) for r in results: r['_path'] = 'graph' # Store relation context for LLM prompt enrichment if results: results[0]['_relation_context'] = expand_result.get('relation_context', '') results[0]['_entities'] = expand_result.get('entities', []) return results except Exception as e: print(f"[graph] Error: {e}") return [] def path_wiki(query, top_k=5): """Path D: Wiki compiled knowledge pages""" wiki_path = Path(WIKI_DIR) if not wiki_path.exists(): return [] # Find entities for better matching entities = [] if get_graph: try: graph = get_graph(wiki_dir=WIKI_DIR) entities = graph.find_entities(query, max_results=5) except: pass pages = [] for subdir in ['sources', 'entities', 'topics', 'analyses']: d = wiki_path / subdir if not d.exists(): continue for f in d.glob('*.md'): try: content = f.read_text(encoding='utf-8') score = 0 for e in entities: if e in f.stem or e in content[:500]: score += 10 score += sum(1 for c in set(query) if c in content[:500]) if score > len(query) * 0.2: pages.append({ 'source': f'wiki:{subdir}/{f.stem}', 'chunk_id': 0, 'text': content[:2000], 'score': score / 100.0, '_path': 'wiki', }) except: pass pages.sort(key=lambda x: -x['score']) return pages[:top_k] # ══════════════════════════════════════════ # Step 3: Weighted RRF Fusion # ══════════════════════════════════════════ def weighted_rrf_fuse(path_results, weights, k=RRF_K): """ Weighted Reciprocal Rank Fusion. Each path's contribution is scaled by its weight. score(doc) = Σ weight_i / (k + rank_i(doc)) """ scores = defaultdict(float) chunk_map = {} path_tags = defaultdict(set) # Track which paths found each doc for path_name, results in path_results.items(): w = weights.get(path_name, 0.5) if w == 0: continue for rank, r in enumerate(results): key = f"{r.get('source', '')}#{r.get('chunk_id', 0)}" scores[key] += w / (k + rank + 1) if key not in chunk_map: chunk_map[key] = r.copy() path_tags[key].add(path_name) # Sort by fused score sorted_keys = sorted(scores.items(), key=lambda x: -x[1]) fused = [] for key, score in sorted_keys: r = chunk_map[key] r['score'] = round(score, 4) r['_fusion_paths'] = list(path_tags[key]) r['_fusion_score'] = round(score, 4) fused.append(r) return fused # ══════════════════════════════════════════ # Step 4: Build enriched context for LLM # ══════════════════════════════════════════ def build_enriched_context(fused_results, top_k=5): """ Build LLM context from fused results. Includes: document chunks + relation context + wiki summaries. This is the key advantage of fusion - multiple knowledge sources in one context. """ context_parts = [] relation_context = "" wiki_summaries = [] for i, r in enumerate(fused_results[:top_k]): source = r.get('source', '') text = r.get('text', '') if source.startswith('wiki:'): wiki_summaries.append(f"[Wiki: {source[5:]}]\n{text[:800]}") elif source.startswith('relation:'): # Relations go into a separate section if relation_context == "": relation_context = "## 实体关系 (来自知识图谱)\n" relation_context += f"- {text}\n" else: context_parts.append(f"[doc{i+1}: {source}]\n{text}") # Extract relation context from graph path results if '_relation_context' in r and r['_relation_context']: if relation_context == "": relation_context = "## 实体关系 (来自知识图谱)\n" relation_context += r['_relation_context'] + "\n" enriched = "" if relation_context: enriched += relation_context + "\n" if wiki_summaries: enriched += "## Wiki 编译知识\n" + "\n\n".join(wiki_summaries) + "\n\n" if context_parts: enriched += "## 检索文档\n" + "\n\n".join(context_parts) return enriched # ══════════════════════════════════════════ # Main search function # ══════════════════════════════════════════ def search(query, top_k=5): """ True Fusion search: 1. Classify → get weights (not route!) 2. All 4 paths run in parallel 3. Weighted RRF fusion 4. Return enriched results Drop-in replacement for search_engine.search() """ t0 = time.time() # Step 1: Classify and get weights qtype, weights = classify_query(query) # Step 2: Run all paths in parallel path_results = {} with ThreadPoolExecutor(max_workers=4) as pool: futures = { pool.submit(path_bm25, query, 10): 'bm25', pool.submit(path_vector, query, 10): 'vector', pool.submit(path_graph, query, 10): 'graph', pool.submit(path_wiki, query, 5): 'wiki', } for future in as_completed(futures): path_name = futures[future] try: result = future.result(timeout=15) path_results[path_name] = result except Exception as e: print(f"[{path_name}] Timeout/Error: {e}") path_results[path_name] = [] # Step 3: Weighted RRF fusion fused = weighted_rrf_fuse(path_results, weights) # Step 4: Time filter for temporal queries if qtype == 'temporal': fused = _time_filter(fused, query) elapsed = time.time() - t0 # Add metadata if fused: fused[0]['_query_type'] = qtype fused[0]['_weights'] = weights fused[0]['_strategy'] = f"fusion({'+'.join(f'{k}:{v}' for k,v in weights.items() if v > 0)})" fused[0]['_total_time'] = round(elapsed, 3) fused[0]['_path_counts'] = {k: len(v) for k, v in path_results.items()} # Build enriched context (stored for LLM prompt building) fused[0]['_enriched_context'] = build_enriched_context(fused, top_k) return fused[:top_k] def _time_filter(results, query): """Boost temporally relevant results""" scored = [] for r in results: boost = 0 text = r.get('text', '') + r.get('source', '') if any(k in text for k in ['最新修订', '现行有效', '起施行']): boost += 2 src_years = re.findall(r'(20\d{2})', r.get('source', '')) if src_years: boost += (max(int(y) for y in src_years) - 2000) * 0.1 scored.append((r, r.get('score', 0) + boost)) scored.sort(key=lambda x: -x[1]) return [r for r, _ in scored] # ══════════════════════════════════════════ # CLI test # ══════════════════════════════════════════ if __name__ == '__main__': query = sys.argv[1] if len(sys.argv) > 1 else '海上交通安全法与水污染防治法在船舶管理方面有何异同?' print(f"Query: {query}") print("=" * 60) qtype, weights = classify_query(query) print(f"Type: {qtype}") print(f"Weights: {weights}") print(f"\nSearching (all 4 paths parallel)...") results = search(query, top_k=5) print(f"Results: {len(results)}") if results: meta = results[0] print(f"Time: {meta.get('_total_time', '?')}s") print(f"Strategy: {meta.get('_strategy', '?')}") print(f"Path counts: {meta.get('_path_counts', {})}") for i, r in enumerate(results): paths = r.get('_fusion_paths', []) print(f"\n[{i+1}] score={r.get('score','?')} paths={paths} {r.get('source','?')[:60]}") print(f" {r.get('text','')[:150]}...")