Files
playbook/cangjie-skill/scripts/build_index.py
T
2026-08-31 05:00:46 +08:00

94 lines
3.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""build_index.py — chunks.jsonl → SQLite FTS5 词法索引(Phase 2A MVP,不引入向量库)。
建索引: python3 scripts/build_index.py <chunks.jsonl> [--db <lexical.sqlite>]
查询: python3 scripts/build_index.py <chunks.jsonl> --query "杠杆 复利" [--limit 5] [--neighbors 1]
查询返回命中块及其邻接块(防断章取义),供检索式 extractorcase/counter-example/glossary)取材。
中文按字符 bigram 分词(FTS5 unicode61 对 CJK 不分词,入库前预处理)。
"""
from __future__ import annotations
import argparse
import json
import re
import sqlite3
from pathlib import Path
CJK_RE = re.compile(r"[\u4e00-\u9fff]+")
def cjk_bigram(text: str) -> str:
"""把连续中文串展开为 bigram 词序列,使 FTS5 能做中文子串匹配。"""
def expand(m: re.Match) -> str:
s = m.group(0)
if len(s) == 1:
return s
return " ".join(s[i : i + 2] for i in range(len(s) - 1))
return CJK_RE.sub(expand, text)
def build(chunks_path: Path, db_path: Path) -> int:
chunks = [json.loads(line) for line in chunks_path.read_text(encoding="utf-8").splitlines() if line.strip()]
db_path.parent.mkdir(parents=True, exist_ok=True)
db_path.unlink(missing_ok=True)
con = sqlite3.connect(db_path)
con.execute("CREATE TABLE chunks (seq INTEGER PRIMARY KEY, chunk_id TEXT, heading_path TEXT, text TEXT, keywords TEXT)")
con.execute("CREATE VIRTUAL TABLE chunks_fts USING fts5(chunk_id, heading_path, body, keywords)")
for seq, c in enumerate(chunks):
hp = " / ".join(c["heading_path"])
con.execute("INSERT INTO chunks VALUES (?,?,?,?,?)",
(seq, c["chunk_id"], hp, c["text"], " ".join(c.get("keywords", []))))
con.execute("INSERT INTO chunks_fts VALUES (?,?,?,?)",
(c["chunk_id"], cjk_bigram(hp), cjk_bigram(c["text"]), cjk_bigram(" ".join(c.get("keywords", [])))))
con.commit()
con.close()
print(f"索引已建: {db_path}{len(chunks)} chunks")
return 0
def query(db_path: Path, q: str, limit: int, neighbors: int) -> int:
con = sqlite3.connect(db_path)
terms = " OR ".join(f'"{t}"' for t in cjk_bigram(q).split())
rows = con.execute(
"SELECT chunk_id FROM chunks_fts WHERE chunks_fts MATCH ? ORDER BY rank LIMIT ?", (terms, limit)
).fetchall()
if not rows:
print("(无命中;建议放宽查询词或回退全量扫描)")
return 0
seen: set[int] = set()
for (chunk_id,) in rows:
(seq,) = con.execute("SELECT seq FROM chunks WHERE chunk_id=?", (chunk_id,)).fetchone()
for s in range(max(0, seq - neighbors), seq + neighbors + 1):
seen.add(s)
for seq in sorted(seen):
row = con.execute("SELECT chunk_id, heading_path, text FROM chunks WHERE seq=?", (seq,)).fetchone()
if row:
cid, hp, text = row
print(f"\n===== {cid} [{hp}] =====\n{text[:1500]}")
con.close()
return 0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("chunks")
ap.add_argument("--db", default=None)
ap.add_argument("--query", default=None)
ap.add_argument("--limit", type=int, default=5)
ap.add_argument("--neighbors", type=int, default=1)
args = ap.parse_args()
chunks_path = Path(args.chunks)
db_path = Path(args.db) if args.db else chunks_path.parent.parent / "index" / "lexical.sqlite"
if args.query:
return query(db_path, args.query, args.limit, args.neighbors)
return build(chunks_path, db_path)
if __name__ == "__main__":
raise SystemExit(main())