search_engine_v2.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. #!/usr/bin/env python3
  2. """
  3. RAG v2: Hybrid search (BM25 + Embedding + RRF + Rerank)
  4. Uses local Ollama bge-m3 for embedding (free, no API cost).
  5. Falls back to BM25-only if embedding unavailable.
  6. Lambda: Tool("hybrid_search", λq. Par(BM25(q), Vector(q)) >> RRF >> Rerank)
  7. """
  8. import json
  9. import math
  10. import os
  11. import pickle
  12. import re
  13. import struct
  14. import time
  15. import urllib.request
  16. from collections import Counter, defaultdict
  17. from pathlib import Path
  18. # Import config + BM25
  19. try:
  20. from config import cfg as _app_cfg
  21. except ImportError:
  22. _app_cfg = None
  23. try:
  24. from search_engine import search as bm25_search, _load_index as _load_bm25_index
  25. except ImportError:
  26. bm25_search = None
  27. # ── Config from config.py ──
  28. OLLAMA_EMBED_URL = _app_cfg.embed_url if _app_cfg else os.environ.get('OLLAMA_EMBED_URL', 'http://127.0.0.1:11435/api/embed')
  29. EMBED_MODEL = _app_cfg.embed_model if _app_cfg else os.environ.get('EMBED_MODEL', 'bge-m3')
  30. VECTOR_SEARCH_DISABLED = os.environ.get('DISABLE_VECTOR_SEARCH', '').lower() in ('1', 'true', 'yes')
  31. RRF_K = 60
  32. RERANK_CANDIDATES = 20
  33. SIMILARITY_THRESHOLD = 0.0
  34. _vector_cache = {}
  35. def _get_base_dir():
  36. if _app_cfg:
  37. return _app_cfg.base_dir
  38. return Path(os.environ.get('KNOWLEDGE_DIR', str(Path(__file__).parent)))
  39. # ── Embedding via Ollama ──
  40. def _embed_texts(texts, model=None):
  41. """Embed one or more texts via Ollama API. Returns list of vectors."""
  42. if model is None:
  43. model = EMBED_MODEL
  44. body = json.dumps({
  45. "model": model,
  46. "input": texts if isinstance(texts, list) else [texts],
  47. }).encode('utf-8')
  48. req = urllib.request.Request(
  49. OLLAMA_EMBED_URL,
  50. data=body,
  51. headers={"Content-Type": "application/json"},
  52. method="POST"
  53. )
  54. try:
  55. with urllib.request.urlopen(req, timeout=30) as resp:
  56. data = json.loads(resp.read())
  57. return data.get('embeddings', [])
  58. except Exception as e:
  59. print(f"[embed] Error: {e}")
  60. return []
  61. def _embed_query(query):
  62. """Embed a single query. Returns vector or None."""
  63. vecs = _embed_texts(query)
  64. return vecs[0] if vecs else None
  65. # ── Vector Index ──
  66. def _load_vector_index():
  67. """Load pre-built vector index (numpy .npy + metadata pickle)"""
  68. if 'vectors' in _vector_cache:
  69. return _vector_cache
  70. base = _get_base_dir()
  71. vec_path = base / 'rag_vectors_v2.npy'
  72. meta_path = base / 'rag_vectors_meta_v2.pkl'
  73. if not vec_path.exists() or not meta_path.exists():
  74. return None
  75. try:
  76. import numpy as np
  77. vectors = np.load(str(vec_path), mmap_mode='r') # memory-mapped for efficiency
  78. with open(meta_path, 'rb') as f:
  79. meta = pickle.load(f)
  80. _vector_cache['vectors'] = vectors
  81. _vector_cache['meta'] = meta
  82. _vector_cache['dim'] = vectors.shape[1]
  83. return _vector_cache
  84. except Exception as e:
  85. print(f"[vector] Load error: {e}")
  86. return None
  87. def _cosine_similarity_batch(query_vec, matrix):
  88. """Compute cosine similarity between query and all vectors in matrix"""
  89. try:
  90. import numpy as np
  91. query = np.array(query_vec, dtype=np.float32)
  92. # Normalize
  93. query_norm = query / (np.linalg.norm(query) + 1e-10)
  94. # matrix is already loaded as numpy array
  95. norms = np.linalg.norm(matrix, axis=1, keepdims=True) + 1e-10
  96. normalized = matrix / norms
  97. scores = normalized @ query_norm
  98. return scores
  99. except ImportError:
  100. # Pure Python fallback
  101. return _cosine_similarity_batch_python(query_vec, matrix)
  102. def _cosine_similarity_batch_python(query_vec, matrix):
  103. """Pure Python fallback for cosine similarity"""
  104. import math
  105. q_norm = math.sqrt(sum(x*x for x in query_vec)) + 1e-10
  106. scores = []
  107. for row in matrix:
  108. dot = sum(a*b for a, b in zip(query_vec, row))
  109. r_norm = math.sqrt(sum(x*x for x in row)) + 1e-10
  110. scores.append(dot / (q_norm * r_norm))
  111. return scores
  112. def _vector_search(query, top_k=20):
  113. """Search using embedding vectors"""
  114. if VECTOR_SEARCH_DISABLED:
  115. return []
  116. vidx = _load_vector_index()
  117. if vidx is None:
  118. return []
  119. query_vec = _embed_query(query)
  120. if query_vec is None:
  121. return []
  122. scores = _cosine_similarity_batch(query_vec, vidx['vectors'])
  123. meta = vidx['meta']
  124. # Get top-k indices
  125. try:
  126. import numpy as np
  127. top_indices = np.argsort(scores)[::-1][:top_k]
  128. results = []
  129. for idx in top_indices:
  130. idx = int(idx)
  131. score = float(scores[idx])
  132. if score < SIMILARITY_THRESHOLD:
  133. continue
  134. m = meta[idx]
  135. results.append({
  136. 'source': m['source'],
  137. 'chunk_id': m['chunk_id'],
  138. 'text': m['text'],
  139. 'score': round(score, 4),
  140. '_index': idx,
  141. })
  142. return results
  143. except ImportError:
  144. # Python fallback
  145. indexed = list(enumerate(scores))
  146. indexed.sort(key=lambda x: -x[1])
  147. results = []
  148. for idx, score in indexed[:top_k]:
  149. if score < SIMILARITY_THRESHOLD:
  150. continue
  151. m = meta[idx]
  152. results.append({
  153. 'source': m['source'],
  154. 'chunk_id': m['chunk_id'],
  155. 'text': m['text'],
  156. 'score': round(score, 4),
  157. '_index': idx,
  158. })
  159. return results
  160. # ── RRF Fusion ──
  161. def _rrf_fuse(bm25_results, vector_results, k=RRF_K):
  162. """Reciprocal Rank Fusion: combine two ranked lists"""
  163. scores = defaultdict(float)
  164. chunk_map = {}
  165. for rank, r in enumerate(bm25_results):
  166. key = f"{r['source']}#{r['chunk_id']}"
  167. scores[key] += 1.0 / (k + rank + 1)
  168. chunk_map[key] = r
  169. for rank, r in enumerate(vector_results):
  170. key = f"{r['source']}#{r['chunk_id']}"
  171. scores[key] += 1.0 / (k + rank + 1)
  172. if key not in chunk_map:
  173. chunk_map[key] = r
  174. sorted_keys = sorted(scores.items(), key=lambda x: -x[1])
  175. results = []
  176. for key, score in sorted_keys:
  177. r = chunk_map[key].copy()
  178. r['score'] = round(score, 4)
  179. results.append(r)
  180. return results
  181. # ── Rerank via LLM (optional, uses vLLM) ──
  182. def _rerank_llm(query, candidates, top_n=5):
  183. """
  184. Rerank candidates using vLLM local model.
  185. Fallback: skip rerank, return candidates as-is.
  186. """
  187. vllm_url = os.environ.get('VLLM_URL', 'http://127.0.0.1:8000/v1/chat/completions')
  188. if not candidates:
  189. return candidates
  190. # Build rerank prompt
  191. docs_text = ""
  192. for i, c in enumerate(candidates[:RERANK_CANDIDATES]):
  193. docs_text += f"\n[{i}] {c['text'][:300]}"
  194. prompt = f"""Given a query and candidate passages, rank them by relevance.
  195. Output ONLY a JSON array of passage indices in order of relevance (most relevant first).
  196. Example: [3, 0, 7, 1, 5]
  197. Query: {query}
  198. Passages:{docs_text}
  199. Ranking (JSON array):"""
  200. try:
  201. body = json.dumps({
  202. "model": "qwen2.5-32b",
  203. "messages": [{"role": "user", "content": prompt}],
  204. "temperature": 0.0,
  205. "max_tokens": 100,
  206. }).encode('utf-8')
  207. req = urllib.request.Request(
  208. vllm_url, data=body,
  209. headers={"Content-Type": "application/json"},
  210. method="POST"
  211. )
  212. with urllib.request.urlopen(req, timeout=30) as resp:
  213. data = json.loads(resp.read())
  214. response_text = data["choices"][0]["message"]["content"].strip()
  215. # Parse ranking
  216. match = re.search(r'\[[\d,\s]+\]', response_text)
  217. if match:
  218. ranking = json.loads(match.group())
  219. reranked = []
  220. for idx in ranking[:top_n]:
  221. if 0 <= idx < len(candidates):
  222. r = candidates[idx].copy()
  223. r['score'] = round(1.0 - len(reranked) * 0.1, 2) # Assign decreasing scores
  224. reranked.append(r)
  225. return reranked
  226. except Exception as e:
  227. print(f"[rerank] Error: {e}, using RRF order")
  228. # Fallback: return top_n from RRF ordering
  229. return candidates[:top_n]
  230. # ── Main Search Function ──
  231. def search(query, top_k=5):
  232. """
  233. Hybrid search: BM25 + Vector + RRF + Rerank
  234. Drop-in replacement for search_engine.search()
  235. Returns: [{source, chunk_id, score, text}, ...]
  236. """
  237. t0 = time.time()
  238. # 1. BM25 search
  239. bm25_results = []
  240. if bm25_search:
  241. try:
  242. bm25_results = bm25_search(query, top_k=RERANK_CANDIDATES)
  243. except Exception as e:
  244. print(f"[bm25] Error: {e}")
  245. # 2. Vector search
  246. vector_results = _vector_search(query, top_k=RERANK_CANDIDATES)
  247. # 3. Decide fusion strategy
  248. if bm25_results and vector_results:
  249. # Both available → RRF fusion
  250. fused = _rrf_fuse(bm25_results, vector_results)
  251. elif vector_results:
  252. fused = vector_results
  253. else:
  254. # Fallback to BM25 only
  255. return bm25_results[:top_k] if bm25_results else []
  256. # 4. Rerank top candidates
  257. reranked = _rerank_llm(query, fused[:RERANK_CANDIDATES], top_n=top_k)
  258. elapsed = time.time() - t0
  259. # Add timing metadata to first result
  260. if reranked:
  261. reranked[0]['_search_time'] = round(elapsed, 3)
  262. return reranked
  263. if __name__ == '__main__':
  264. import sys
  265. query = sys.argv[1] if len(sys.argv) > 1 else '海上交通安全法与水污染防治法'
  266. print(f"Query: {query}")
  267. print("=" * 60)
  268. t0 = time.time()
  269. results = search(query, top_k=5)
  270. t1 = time.time()
  271. print(f"Search time: {(t1-t0)*1000:.0f}ms")
  272. print(f"Results: {len(results)}")
  273. for i, r in enumerate(results):
  274. print(f"\n[{i+1}] score={r['score']} {r['source'][:60]}")
  275. print(f" {r['text'][:150]}...")