Ctrl K
ComunidadGuías…

scripts/fts_recall.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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