headless / scripts /hybrid_search.py
logos42's picture
Upload folder using huggingface_hub
216004d verified
Raw History Blame Contribute Delete
7.6 kB
from __future__ import annotations
# llm-wiki-version: 2.0.0
# runtime: dev-only
"""
hybrid_search.py — v2 混合检索 (BM25 + 图遍历).
Rohit G00 v2 范式:BM25 (keyword) + 向量 (semantic) + 图遍历 (structural).
v2.0 实现: BM25 over wiki 页面 + 图遍历 (follows supersedes/contradicts links).
v2.1+ 可加 embedding-based vector search.
Usage:
python3 scripts/hybrid_search.py "货币主权"
python3 scripts/hybrid_search.py "DIAP" --depth 2
python3 scripts/hybrid_search.py "价值函数" --json --top 5
"""
import argparse
import json
import math
import re
import sys
from collections import Counter, defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
WIKI_ROOT = ROOT / "docs" / "wiki"
FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---", re.DOTALL)
TOKEN_RE = re.compile(r"[\w一-鿿]+", re.UNICODE) # 英文/数字/汉字
# BM25 parameters
K1 = 1.5
B = 0.75
def parse_frontmatter(text: str) -> dict[str, str]:
m = FRONTMATTER_RE.match(text)
if not m:
return {}
out: dict[str, str] = {}
for line in m.group(1).splitlines():
if ":" in line:
key, _, value = line.partition(":")
out[key.strip()] = value.strip()
return out
def tokenize(text: str) -> list[str]:
"""CJK + ASCII tokenization. Each CJK char is its own token, ASCII words grouped."""
tokens: list[str] = []
for m in TOKEN_RE.finditer(text):
s = m.group(0)
if re.search(r"[一-鿿]", s):
tokens.extend(s) # CJK: one char per token
else:
tokens.append(s.lower())
return tokens
def bm25_score(query_tokens: list[str], doc_tokens: list[str], avg_dl: float, df: dict[str, int], n_docs: int) -> float:
"""Compute BM25 score for a single document."""
score = 0.0
doc_len = len(doc_tokens)
tf = Counter(doc_tokens)
for q in query_tokens:
if q not in tf:
continue
idf = math.log((n_docs - df[q] + 0.5) / (df[q] + 0.5) + 1)
denom = tf[q] + K1 * (1 - B + B * doc_len / avg_dl)
score += idf * (tf[q] * (K1 + 1)) / denom
return score
def main() -> int:
parser = argparse.ArgumentParser(description="v2 hybrid search (BM25 + graph)")
parser.add_argument("query", help="search query")
parser.add_argument("--wiki-root", type=Path, default=WIKI_ROOT)
parser.add_argument("--depth", type=int, default=1, help="graph traversal depth (0=no graph)")
parser.add_argument("--top", type=int, default=5, help="top N results")
parser.add_argument("--json", action="store_true", help="emit JSON output")
args = parser.parse_args()
if not args.wiki_root.exists():
print(f"hybrid_search: {args.wiki_root} does not exist")
return 1
md_files = sorted(args.wiki_root.rglob("*.md"))
docs: list[dict] = []
for path in md_files:
text = path.read_text(encoding="utf-8")
# Strip frontmatter for tokenization
body = FRONTMATTER_RE.sub("", text, count=1)
tokens = tokenize(body)
fm = parse_frontmatter(text)
rel = path.relative_to(args.wiki_root).as_posix()
docs.append({"rel": rel, "tokens": tokens, "fm": fm, "path": path})
if not docs:
print("hybrid_search: no wiki pages found")
return 1
# Build DF
df: dict[str, int] = defaultdict(int)
for d in docs:
for term in set(d["tokens"]):
df[term] += 1
avg_dl = sum(len(d["tokens"]) for d in docs) / len(docs)
query_tokens = tokenize(args.query)
# BM25 scores
bm25_hits: list[tuple[float, dict]] = []
for d in docs:
score = bm25_score(query_tokens, d["tokens"], avg_dl, df, len(docs))
if score > 0:
bm25_hits.append((score, d))
bm25_hits.sort(key=lambda x: -x[0])
# Build graph from supersedes + contradicts
graph: dict[str, set[str]] = defaultdict(set)
for d in docs:
rel = d["rel"]
for edge_field in ("supersedes", "contradicts"):
raw = d["fm"].get(edge_field, "")
if not raw:
continue
for t in re.findall(r"\./[^\s,\]]+", raw):
graph[rel].add(t.lstrip("./"))
# Graph traversal: for each top BM25 hit, expand neighbors up to depth
bm25_paths = {d["rel"]: score for score, d in bm25_hits[:args.top]}
expanded_paths: dict[str, list[str]] = {}
if args.depth > 0:
# BFS from top hits
visited: set[str] = set()
queue: list[tuple[str, int, list[str]]] = []
for rel in bm25_paths:
queue.append((rel, 0, [rel]))
visited.add(rel)
while queue:
node, d, path = queue.pop(0)
if d >= args.depth:
continue
for nxt in graph.get(node, set()):
if nxt not in visited and nxt in {d2["rel"] for d2 in docs}:
visited.add(nxt)
new_path = path + [nxt]
queue.append((nxt, d + 1, new_path))
if d + 1 == args.depth:
# Add as expansion
expanded_paths.setdefault(nxt, []).extend(new_path)
# Flatten
graph_hits: list[tuple[str, int, list[str]]] = []
for nxt, paths in expanded_paths.items():
for p in paths:
graph_hits.append((0.5 / (len(p)), d, p))
graph_hits.sort(key=lambda x: -x[0])
else:
graph_hits = []
# Reciprocal Rank Fusion (RRF)
rrf_scores: dict[str, float] = defaultdict(float)
for rank, (score, d) in enumerate(bm25_hits, start=1):
rrf_scores[d["rel"]] += 1.0 / (60 + rank)
for rank, (score, _, path) in enumerate(graph_hits, start=1):
rrf_scores[path[-1]] += 0.5 / (60 + rank)
# Final ranking
final_ranked = sorted(rrf_scores.items(), key=lambda kv: -kv[1])[:args.top]
# Build snippets
results: list[dict] = []
for rel, rrf in final_ranked:
doc = next(d for d in docs if d["rel"] == rel)
body = FRONTMATTER_RE.sub("", doc["path"].read_text(encoding="utf-8"), count=1)
# Find first matching line
snippet = ""
for line in body.splitlines():
if any(q.lower() in line.lower() for q in [args.query]):
snippet = line.strip()[:200]
break
if not snippet:
snippet = body.splitlines()[0][:200] if body.splitlines() else ""
results.append({
"rel": rel,
"rrf_score": round(rrf, 5),
"title": doc["fm"].get("title", ""),
"stage": doc["fm"].get("stage", ""),
"snippet": snippet,
})
report = {
"query": args.query,
"query_tokens": query_tokens,
"wiki_pages_scanned": len(docs),
"bm25_hits": len(bm25_hits),
"graph_depth": args.depth,
"results": results,
}
if args.json:
print(json.dumps(report, indent=2, ensure_ascii=False))
else:
print(f"hybrid_search: query={args.query!r} (tokens: {query_tokens[:10]}{'...' if len(query_tokens) > 10 else ''})")
print(f"- scanned: {len(docs)} pages")
print(f"- BM25 hits: {len(bm25_hits)}")
print(f"- graph depth: {args.depth}")
print(f"- top {args.top} results (RRF):")
for r in results:
print(f" {r['rrf_score']:.4f} {r['rel']} [{r['stage']}] {r['title']}")
if r['snippet']:
print(f" {r['snippet'][:120]}")
return 0
if __name__ == "__main__":
sys.exit(main())