| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329 |
- #!/usr/bin/env python3
- """
- RAG v2: Hybrid search (BM25 + Embedding + RRF + Rerank)
- Uses local Ollama bge-m3 for embedding (free, no API cost).
- Falls back to BM25-only if embedding unavailable.
- Lambda: Tool("hybrid_search", λq. Par(BM25(q), Vector(q)) >> RRF >> Rerank)
- """
- import json
- import math
- import os
- import pickle
- import re
- import struct
- import time
- import urllib.request
- from collections import Counter, defaultdict
- from pathlib import Path
- # Import config + BM25
- try:
- from config import cfg as _app_cfg
- except ImportError:
- _app_cfg = None
- try:
- from search_engine import search as bm25_search, _load_index as _load_bm25_index
- except ImportError:
- bm25_search = None
- # ── Config from config.py ──
- 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')
- EMBED_MODEL = _app_cfg.embed_model if _app_cfg else os.environ.get('EMBED_MODEL', 'bge-m3')
- VECTOR_SEARCH_DISABLED = os.environ.get('DISABLE_VECTOR_SEARCH', '').lower() in ('1', 'true', 'yes')
- RRF_K = 60
- RERANK_CANDIDATES = 20
- SIMILARITY_THRESHOLD = 0.0
- _vector_cache = {}
- def _get_base_dir():
- if _app_cfg:
- return _app_cfg.base_dir
- return Path(os.environ.get('KNOWLEDGE_DIR', str(Path(__file__).parent)))
- # ── Embedding via Ollama ──
- def _embed_texts(texts, model=None):
- """Embed one or more texts via Ollama API. Returns list of vectors."""
- if model is None:
- model = EMBED_MODEL
- body = json.dumps({
- "model": model,
- "input": texts if isinstance(texts, list) else [texts],
- }).encode('utf-8')
- req = urllib.request.Request(
- OLLAMA_EMBED_URL,
- data=body,
- headers={"Content-Type": "application/json"},
- method="POST"
- )
- try:
- with urllib.request.urlopen(req, timeout=30) as resp:
- data = json.loads(resp.read())
- return data.get('embeddings', [])
- except Exception as e:
- print(f"[embed] Error: {e}")
- return []
- def _embed_query(query):
- """Embed a single query. Returns vector or None."""
- vecs = _embed_texts(query)
- return vecs[0] if vecs else None
- # ── Vector Index ──
- def _load_vector_index():
- """Load pre-built vector index (numpy .npy + metadata pickle)"""
- if 'vectors' in _vector_cache:
- return _vector_cache
- base = _get_base_dir()
- vec_path = base / 'rag_vectors_v2.npy'
- meta_path = base / 'rag_vectors_meta_v2.pkl'
- if not vec_path.exists() or not meta_path.exists():
- return None
- try:
- import numpy as np
- vectors = np.load(str(vec_path), mmap_mode='r') # memory-mapped for efficiency
- with open(meta_path, 'rb') as f:
- meta = pickle.load(f)
- _vector_cache['vectors'] = vectors
- _vector_cache['meta'] = meta
- _vector_cache['dim'] = vectors.shape[1]
- return _vector_cache
- except Exception as e:
- print(f"[vector] Load error: {e}")
- return None
- def _cosine_similarity_batch(query_vec, matrix):
- """Compute cosine similarity between query and all vectors in matrix"""
- try:
- import numpy as np
- query = np.array(query_vec, dtype=np.float32)
- # Normalize
- query_norm = query / (np.linalg.norm(query) + 1e-10)
- # matrix is already loaded as numpy array
- norms = np.linalg.norm(matrix, axis=1, keepdims=True) + 1e-10
- normalized = matrix / norms
- scores = normalized @ query_norm
- return scores
- except ImportError:
- # Pure Python fallback
- return _cosine_similarity_batch_python(query_vec, matrix)
- def _cosine_similarity_batch_python(query_vec, matrix):
- """Pure Python fallback for cosine similarity"""
- import math
- q_norm = math.sqrt(sum(x*x for x in query_vec)) + 1e-10
- scores = []
- for row in matrix:
- dot = sum(a*b for a, b in zip(query_vec, row))
- r_norm = math.sqrt(sum(x*x for x in row)) + 1e-10
- scores.append(dot / (q_norm * r_norm))
- return scores
- def _vector_search(query, top_k=20):
- """Search using embedding vectors"""
- if VECTOR_SEARCH_DISABLED:
- return []
- vidx = _load_vector_index()
- if vidx is None:
- return []
- query_vec = _embed_query(query)
- if query_vec is None:
- return []
- scores = _cosine_similarity_batch(query_vec, vidx['vectors'])
- meta = vidx['meta']
- # Get top-k indices
- try:
- import numpy as np
- top_indices = np.argsort(scores)[::-1][:top_k]
- results = []
- for idx in top_indices:
- idx = int(idx)
- score = float(scores[idx])
- if score < SIMILARITY_THRESHOLD:
- continue
- m = meta[idx]
- results.append({
- 'source': m['source'],
- 'chunk_id': m['chunk_id'],
- 'text': m['text'],
- 'score': round(score, 4),
- '_index': idx,
- })
- return results
- except ImportError:
- # Python fallback
- indexed = list(enumerate(scores))
- indexed.sort(key=lambda x: -x[1])
- results = []
- for idx, score in indexed[:top_k]:
- if score < SIMILARITY_THRESHOLD:
- continue
- m = meta[idx]
- results.append({
- 'source': m['source'],
- 'chunk_id': m['chunk_id'],
- 'text': m['text'],
- 'score': round(score, 4),
- '_index': idx,
- })
- return results
- # ── RRF Fusion ──
- def _rrf_fuse(bm25_results, vector_results, k=RRF_K):
- """Reciprocal Rank Fusion: combine two ranked lists"""
- scores = defaultdict(float)
- chunk_map = {}
- for rank, r in enumerate(bm25_results):
- key = f"{r['source']}#{r['chunk_id']}"
- scores[key] += 1.0 / (k + rank + 1)
- chunk_map[key] = r
- for rank, r in enumerate(vector_results):
- key = f"{r['source']}#{r['chunk_id']}"
- scores[key] += 1.0 / (k + rank + 1)
- if key not in chunk_map:
- chunk_map[key] = r
- sorted_keys = sorted(scores.items(), key=lambda x: -x[1])
- results = []
- for key, score in sorted_keys:
- r = chunk_map[key].copy()
- r['score'] = round(score, 4)
- results.append(r)
- return results
- # ── Rerank via LLM (optional, uses vLLM) ──
- def _rerank_llm(query, candidates, top_n=5):
- """
- Rerank candidates using vLLM local model.
- Fallback: skip rerank, return candidates as-is.
- """
- vllm_url = os.environ.get('VLLM_URL', 'http://127.0.0.1:8000/v1/chat/completions')
- if not candidates:
- return candidates
- # Build rerank prompt
- docs_text = ""
- for i, c in enumerate(candidates[:RERANK_CANDIDATES]):
- docs_text += f"\n[{i}] {c['text'][:300]}"
- prompt = f"""Given a query and candidate passages, rank them by relevance.
- Output ONLY a JSON array of passage indices in order of relevance (most relevant first).
- Example: [3, 0, 7, 1, 5]
- Query: {query}
- Passages:{docs_text}
- Ranking (JSON array):"""
- try:
- body = json.dumps({
- "model": "qwen2.5-32b",
- "messages": [{"role": "user", "content": prompt}],
- "temperature": 0.0,
- "max_tokens": 100,
- }).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())
- response_text = data["choices"][0]["message"]["content"].strip()
- # Parse ranking
- match = re.search(r'\[[\d,\s]+\]', response_text)
- if match:
- ranking = json.loads(match.group())
- reranked = []
- for idx in ranking[:top_n]:
- if 0 <= idx < len(candidates):
- r = candidates[idx].copy()
- r['score'] = round(1.0 - len(reranked) * 0.1, 2) # Assign decreasing scores
- reranked.append(r)
- return reranked
- except Exception as e:
- print(f"[rerank] Error: {e}, using RRF order")
- # Fallback: return top_n from RRF ordering
- return candidates[:top_n]
- # ── Main Search Function ──
- def search(query, top_k=5):
- """
- Hybrid search: BM25 + Vector + RRF + Rerank
- Drop-in replacement for search_engine.search()
- Returns: [{source, chunk_id, score, text}, ...]
- """
- t0 = time.time()
- # 1. BM25 search
- bm25_results = []
- if bm25_search:
- try:
- bm25_results = bm25_search(query, top_k=RERANK_CANDIDATES)
- except Exception as e:
- print(f"[bm25] Error: {e}")
- # 2. Vector search
- vector_results = _vector_search(query, top_k=RERANK_CANDIDATES)
- # 3. Decide fusion strategy
- if bm25_results and vector_results:
- # Both available → RRF fusion
- fused = _rrf_fuse(bm25_results, vector_results)
- elif vector_results:
- fused = vector_results
- else:
- # Fallback to BM25 only
- return bm25_results[:top_k] if bm25_results else []
- # 4. Rerank top candidates
- reranked = _rerank_llm(query, fused[:RERANK_CANDIDATES], top_n=top_k)
- elapsed = time.time() - t0
- # Add timing metadata to first result
- if reranked:
- reranked[0]['_search_time'] = round(elapsed, 3)
- return reranked
- if __name__ == '__main__':
- import sys
- query = sys.argv[1] if len(sys.argv) > 1 else '海上交通安全法与水污染防治法'
- print(f"Query: {query}")
- print("=" * 60)
- t0 = time.time()
- results = search(query, top_k=5)
- t1 = time.time()
- print(f"Search time: {(t1-t0)*1000:.0f}ms")
- print(f"Results: {len(results)}")
- for i, r in enumerate(results):
- print(f"\n[{i+1}] score={r['score']} {r['source'][:60]}")
- print(f" {r['text'][:150]}...")
|