#!/usr/bin/env python3 """BM25 召回腿(SQLite FTS5)。 XKB 的關鍵字腿一直是 token overlap(沒有 IDF)。罕見的精確 token——錯誤碼、 repo slug、tweet ID——拿不到「因為稀有所以重要」的分,於是得靠一條審了十輪的 識別碼特例腿去補。SQLite FTS5 內建 BM25,久經考驗、零新依賴,把那條特例腿 換掉:BM25 的 IDF 天生就會把罕見 token 往上抬。 跟向量腿對等——不是取代。語意查詢向量腿扛,字面查詢 BM25 扛, xkb_score.rank() 用 RRF 融合。 索引由 build_fts_index.py 建(跟 build_vector_index.py 並排跑)。 """ from __future__ import annotations import sqlite3 from pathlib import Path from typing import Any, Dict, List import xkb_paths import xkb_text FTS_DB = xkb_paths.BOOKMARKS_DIR / "fts_index.db" _conn: sqlite3.Connection | None = None _conn_key: tuple[float, int] | None = None # 快取的連線指向的 db 檔 (mtime, size) def _db() -> sqlite3.Connection | None: global _conn, _conn_key try: st = FTS_DB.stat() except OSError: return None key = (st.st_mtime, st.st_size) if _conn is not None and _conn_key == key: return _conn # build_fts_index 結尾是 tmp.replace(FTS_DB):舊 inode 還在、只是被 unlink。 # 長命行程(recall server)若不重連,會一直查舊索引——本專案記錄在案的 # 靜默過期失敗模式。檔案的 (mtime, size) 一變就重連。 if _conn is not None: try: _conn.close() except Exception: # noqa: BLE001 pass _conn = None # Path.as_uri():空白、#、Windows 反斜線與磁碟機冒號都要正確轉義, # 不然本機鏡像上 file:C:\... 是壞 URI,BM25 腿會靜默回空。 _conn = sqlite3.connect(f"{FTS_DB.as_uri()}?mode=ro", uri=True, check_same_thread=False) _conn_key = key return _conn def available() -> bool: return FTS_DB.exists() def fts_search(query: str, limit: int = 5) -> List[Dict[str, Any]]: """BM25 命中,recall() 的 dict 形狀。score 越大越相關(= -bm25())。 kind=card 的標 score_scale=card_bm25、kind=wiki 的標 wiki_bm25,rank() 就把 它們當各自的一條腿融合。 """ db = _db() if db is None: return [] toks = xkb_text.tokenize(query or "") if not toks: return [] # 每個 token 當一個帶引號的片語(避免 FTS5 把 - 之類當運算子),OR 起來。 match = " OR ".join(f'"{t}"' for t in toks) try: rows = db.execute( "SELECT path, title, kind, bm25(docs) AS s FROM docs " "WHERE docs MATCH ? ORDER BY s LIMIT ?", (match, limit), ).fetchall() except sqlite3.OperationalError: return [] out: List[Dict[str, Any]] = [] for path, title, kind, s in rows: # wiki 的 path 是 wiki/topics/foo.md#段落;卡片是 relative_path rel = path.split("#", 1)[0] if kind == "wiki" else path out.append({ "title": title or Path(rel).stem, "summary": "", "category": "general", "tags": [], "relative_path": rel, "source_url": "", "score": round(-float(s), 6), "score_scale": "wiki_bm25" if kind == "wiki" else "card_bm25", "section": title, "relevance_reason": f"BM25 字面命中({kind})", }) return out