search_engine_v2.py 9.6 KB

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