| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417 |
- #!/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]}...")
|