Ctrl K
ComunidadGuías…

scripts/xkb_relevance.py

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""判斷「這筆結果到底相不相關」——全專案唯一的一份。

為什麼要有這支:

    混合檢索(gbrain / XBrain)回傳的 score 是 **RRF 排名分數**。
    它說的是「這筆排第幾」,不是「這筆多相關」。
    第一名永遠落在 0.88 附近,即使整個知識庫對這個主題一無所知。

    實測(2026-08-02,1,316 張卡片):
        「今天天氣如何」      → 0.863
        「碳盤查的計算方式」  → 0.862
    分數分不出這兩者,所以拿它當相關度過濾等於沒過濾。

這個教訓在 2026-07 於 recall_router 學過一次、寫成註解,
然後 2026-08 的 knowledge service 又原封不動踩了同一個坑——
因為註解只存在一個檔案裡,下一支腳本不會知道。

所以判斷邏輯與門檻都收在這裡。要改門檻改這裡,不要在呼叫端另立一個。
"""
from __future__ import annotations

import os
import sys
from pathlib import Path
from typing import Any, Callable, Iterable

sys.path.insert(0, str(Path(__file__).resolve().parent))

import xkb_failures

# 真實餘弦相似度的門檻。低於它就不值得送到使用者面前。
#
# 0.55 是實測出來的分水嶺:知識庫真的有內容的主題落在 0.60 以上,
# 完全沒有內容的落在 0.55 以下(碳盤查 0.546 vs XKB 架構 0.604)。
# 這個邊界很窄,換一個知識庫或換 embedding 模型都要重新量。
#
# 舊的環境變數名稱一併接受,免得既有部署突然改變行為。
DEFAULT_MIN_SIMILARITY = 0.55

# 餘弦門檻不只一個,因為問的不是同一件事:檢索階段先擋掉什麼,跟送到使用者
# 面前之前最後擋掉什麼,量出來的分水嶺本來就不同。它們可以不同,但只能在
# 這裡不同——散在各檔案時,XKB_CARD_MIN_SIMILARITY 這個環境變數同時被
# 兩處讀走、預設值卻是 0.55 和 0.58,設下去才發現兩邊往不同方向動。
#
# 每一項的值都是量出來的,換知識庫或換 embedding 模型要重新量。
DEFAULT_THRESHOLDS = {
    # 最後一道:低於它就不值得送出。0.60 以上是真的有內容,0.55 以下是沒有
    # (碳盤查 0.546 vs XKB 架構 0.604)。
    "card_result": DEFAULT_MIN_SIMILARITY,
    # 檢索階段的卡片召回。相關的落在 0.60~0.74,不相關的 0.47~0.57,取中間。
    "card_recall": 0.58,
    # wiki 語意檢索本身的下限。
    "wiki_recall": 0.65,
}

# 每一項可覆寫的環境變數,依序取第一個有設的。
THRESHOLD_ENV = {
    "card_result": ("XKB_MIN_SIMILARITY", "XKB_SERVICE_MIN_RELEVANCE",
                    "XKB_CARD_MIN_SIMILARITY", "XKB_ABSORB_MIN_RELEVANCE"),
    "card_recall": ("XKB_CARD_MIN_SIMILARITY",),
    "wiki_recall": ("XKB_WIKI_MIN_SIMILARITY",),
}


def threshold(name: str) -> float:
    """取一個具名的餘弦門檻。名字不在表裡就是打錯字,直接炸掉。"""
    if name not in DEFAULT_THRESHOLDS:
        raise KeyError(
            f"未知的門檻 {name!r};可用的是 {sorted(DEFAULT_THRESHOLDS)}"
        )
    for env_name in THRESHOLD_ENV.get(name, ()):
        raw = os.getenv(env_name)
        if raw:
            try:
                return float(raw)
            except ValueError:
                continue
    return DEFAULT_THRESHOLDS[name]


def min_similarity() -> float:
    return threshold("card_result")


def vector_key(record_id: str) -> str:
    """把各種來源的識別字串對到向量索引用的鍵。

    檢索後端回傳 slug(`01-topic/12345`),向量索引用的是卡片相對路徑
    (`01-topic/12345.md`)。網址與合成 id 不對應任何卡片,回空字串代表
    「查不到,不要據此過濾」。
    """
    record_id = (record_id or "").strip()
    if not record_id or record_id.startswith(("http://", "https://", "semantic:")):
        return ""
    return record_id if record_id.endswith(".md") else f"{record_id}.md"


def _mark_unverified(item: dict[str, Any]) -> None:
    """標明這一筆沒有被拿去跟問題比對過。不要動它的分數。

    這裡我改了六次,每一次都是在替「驗不出來的東西該排在哪」挑一個數字:
    釘在門檻減 0.01、等比壓進門檻之下、乾脆不動……每一個都是捏造的測量值,
    而且壓到哪一段都會撞上別層的尺度——壓高壓過 wiki 的真餘弦,壓低掉到
    對話軌跡之下,而後者會讓索引壞掉長得像「知識庫裡沒東西」。

    正確的做法是不要在這裡排序。score 保持是後端自己算的那個數字,
    score_scale 說明它是哪一套尺度;跨層要怎麼比是 xkb_score.rank() 的事,
    而 unverified 這個尺度的錨點與權重就寫在那張表上,一個地方、一個定義。
    """
    item["score_basis"] = "unverified"
    item["score_scale"] = "unverified"
    if item.get("score") is None:
        # 沒有分數的項目補 0.0,不要留 None:下游排序是 item.get("score", 0.0),
        # 鍵存在時拿到的是 None,同一批裡再有一筆數值分數就丟 TypeError。
        item["score"] = 0.0


def similarities(query: str, keys: Iterable[str]) -> dict[str, float] | None:
    """回傳每個鍵與問題的真實相似度;None 代表無法判斷。

    無法判斷與「全部不相關」是兩件完全不同的事,呼叫端必須分開處理——
    把兩者混為一談,就會在索引壞掉時把所有結果靜靜刪光。
    """
    keys = [k for k in keys if k]
    if not keys:
        return {}
    try:
        from continuity_recall import card_similarities
    except ImportError:
        return None
    try:
        return card_similarities(query, keys)
    except Exception as err:
        # None 的意思是「算不出來」,呼叫端會因此不做過濾——
        # 那跟「算出來大家都相關」是完全不同的狀況。
        xkb_failures.note("relevance scoring", err)
        return None


def filter_irrelevant(
    query: str,
    items: list[dict[str, Any]],
    *,
    key_of: Callable[[dict[str, Any]], str],
    threshold: float | None = None,
    rewrite_score: bool = True,
) -> tuple[list[dict[str, Any]], int, dict[str, float]]:
    """丟掉真實相似度低於門檻的項目。

    回傳 (保留的項目, 丟掉幾筆, 各鍵的相似度)。

    判斷不出來時原樣放行。索引沒建好或拿不到 embedding 是設定問題,
    不該表現成「你的知識庫裡沒有東西」——那正是這個專案吃過大虧的
    那種靜默失敗。

    `rewrite_score=True` 會把 score 換成實測相似度,原本的排名分數
    移到 `rank_score`,讓呼叫端不會再誤用它。
    """
    if not items:
        return items, 0, {}
    limit = min_similarity() if threshold is None else threshold
    keys = {id(item): vector_key(key_of(item)) for item in items}
    scores = similarities(query, keys.values())
    if not scores:
        # 一個鍵都解不出來(索引壞了、或這批全是網址/semantic: id)。
        # 只標記,不動分數。
        #
        # 這裡我來回改了三次,每次都是在替一個不屬於這裡的問題找位置。
        # 跨層可比是 xkb_score 的工作,而合併點(Store.recall_packet)原本
        # 沒有呼叫它、直接照原始 score 排三種尺度。只要那件事沒修,這裡把
        # 分數壓到哪一段都會撞到別層:壓高壓過 wiki 的真餘弦,壓低掉到
        # 對話軌跡(上限 0.65)之下——後者會讓索引壞掉長得像「知識庫裡
        # 沒東西」,而那正是這個專案最不能有的失敗模式。
        #
        # 合併點修好之後,這裡只做它誠實做得到的事:整批都驗不出來時,
        # 沒有任何比較發生過,就不要假裝比過。標記讓下游知道這件事。
        for item in items:
            # rewrite_score=False 的呼叫端要的是「只過濾、不要碰分數」。
            # _mark_unverified 會寫 score_scale、也會把 None 補成 0.0,
            # 都算碰。今天沒有這種呼叫端——契約就是在那種時候悄悄失效的。
            if rewrite_score:
                _mark_unverified(item)
        # 只有「本來該查得到卻查不到」才算故障。整批都是網址或 semantic: id
        # 時 vector_key 依設計就回空字串,那是正常情況——而 note() 一個行程
        # 只報一次,讓正常情況把那一次用掉,之後真的索引壞掉就沒人說話了。
        if any(keys.values()):
            # 有可查的鍵卻一個都對不到 = 索引真的有問題。把這個判斷寫在
            # 記錄上帶出去,下游才不必從結果反推——反推會把「整批都是書籤,
            # 本來就沒有向量鍵」誤判成索引壞掉,在完全健康的系統上報警。
            for item in items:
                item["index_unreadable"] = True
            xkb_failures.note(
                "relevance filter",
                RuntimeError("有可查的鍵卻對不到向量索引——索引可能壞了"))
        return items, 0, {}

    kept: list[dict[str, Any]] = []
    for item in items:
        if str(item.get("score_scale") or "").endswith("_bm25"):
            # BM25 命中:相關性已經由「索引裡真的有這些詞」建立。餘弦對識別碼
            # 算不出意義,用 0.55 門檻砍會把這條腿存在的理由整個抵銷掉。
            # score_scale 不改寫——下游要靠 *_bm25 判斷「直接給卡片內容」。
            kept.append(item)
            continue
        similarity = scores.get(keys[id(item)])
        if similarity is None:
            # 判斷不出來 → 放行,但要記住它沒有被驗證過。過濾器不能刪掉
            # 它判斷不了的東西,但也不該讓它排在真的比對過的前面。
            item["_unverified"] = True
            if rewrite_score:
                _mark_unverified(item)
            kept.append(item)
            continue
        if similarity < limit:
            continue
        if rewrite_score:
            item["rank_score"] = item.get("score")
            item["score"] = round(similarity, 4)
            # 分數換尺度了就要說。原本改寫成餘弦之後記錄還標著 card,
            # 而那個錨點是照 RRF 量的——同一個標籤底下兩種尺度。
            item["score_scale"] = "card_semantic"
        kept.append(item)
    if rewrite_score:
        # 排序跟著分數走(分數已在上面調整過)。原本大家一起比 score,而那時候「算出來的」
        # 是餘弦(0.6–0.75),「算不出來的」還留著原本的 RRF(約 0.88)或
        # 關鍵字分數——於是一筆索引裡查不到的書籤,會壓過真的以 0.74 命中的
        # 那張卡。今天第六個同型錯誤:一個排序,兩種尺度。
        kept.sort(key=lambda item: (not item.get("_unverified", False),
                                    item.get("score", 0.0)), reverse=True)
    for item in kept:
        item.pop("_unverified", None)
    return kept, len(items) - len(kept), scores


import xkb_usage  # noqa: E402  — 量測誰在跑,見 scripts/xkb_usage.py

if __name__ == "__main__":
    xkb_usage.record(__file__)
    print(f"min_similarity = {min_similarity()}")
    for sample in ("01-topic/12345", "01-topic/12345.md", "https://x.com/i/status/1", ""):
        print(f"  vector_key({sample!r}) = {vector_key(sample)!r}")