Ctrl K
ComunidadGuías…

scripts/xkb_text.py

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""
XKB 共用斷詞

原本各個 recall 模組各自帶一份 `re.findall(r"[A-Za-z0-9_\\-]{2,}|[\\u4e00-\\u9fff]{1,}", ...)`。
那個規則對英文沒問題,對中文是壞的:`[\\u4e00-\\u9fff]{1,}` 會貪婪地把整串中文吃成
**一個** token。「我想做一支產品廣告影片」變成單一個詞,而知識庫裡不會有任何一段文字
剛好等於這一整串,於是分數是 0——中文問句幾乎不可能命中任何東西。

中文沒有空格,沒有斷詞器就用 n-gram:把中文串切成 2 字與 3 字的滑動視窗。
這是最便宜的做法,不需要額外套件,對「影片」「分鏡」「報價」這種詞的召回已經夠用。

Usage:
    from xkb_text import tokenize
    tokenize("我想做一支產品廣告影片", STOPWORDS)
"""
from __future__ import annotations

import re
from typing import Iterable

_RUN_RE = re.compile(r"[A-Za-z0-9_\-]{2,}|[一-鿿]+")
_CJK_RE = re.compile(r"^[一-鿿]+$")

# 中文 n-gram 的長度。2 字抓得到「影片」「分鏡」,3 字抓得到「工作流」。
CJK_NGRAMS = (2, 3)

# 攝入端「找相關卡片」用的停用字。原本這一份在 local_ingest、_card_prompt、
# run_bookmark_worker 各存了一份一模一樣的字面量。
STOPWORDS = frozenset({
    "的", "了", "是", "在", "有", "和", "與", "就", "也", "都", "這", "那",
    "this", "that", "with", "from", "have", "will", "for", "and", "the", "a",
})


def tokenize(text: str, stopwords: Iterable[str] = (), min_len: int = 2) -> list[str]:
    """切成可比對的 token。英文照原樣,中文切 n-gram。

    保持出現順序並去重——呼叫端的 phrase bonus 依賴相鄰順序。
    """
    stops = set(stopwords)
    tokens: list[str] = []

    for run in _RUN_RE.findall(text.lower()):
        if not _CJK_RE.match(run):
            tokens.append(run)
            continue

        if len(run) <= max(CJK_NGRAMS):
            tokens.append(run)
        for n in CJK_NGRAMS:
            for i in range(len(run) - n + 1):
                tokens.append(run[i:i + n])

    out: list[str] = []
    seen: set[str] = set()
    for token in tokens:
        if len(token) < min_len or token in stops or token in seen:
            continue
        # 整個 n-gram 都由停用字組成(「我想」「的是」):是雜訊,不是主題
        if _CJK_RE.match(token) and all(ch in stops for ch in token):
            continue
        seen.add(token)
        out.append(token)
    return out


def _units(tokens: Iterable[str]) -> set[str]:
    """把 token 換算成計分單位:中文算「字」,英文算「詞」。"""
    units: set[str] = set()
    for token in tokens:
        if _CJK_RE.match(token):
            units.update(token)
        else:
            units.add(token)
    return units


def overlap_score(tokens: list[str], text: str) -> float:
    """查詢有多少比例真的出現在 text 裡,0.0 ~ 1.0。

    不能直接用 `命中數 / token 數`:中文切 n-gram 會產生大量無意義的組合
    (「支產」「品廣」),它們永遠不會命中,卻會把分母撐大,把真正命中的
    「產品」「影片」稀釋到門檻以下——結果就是中文查詢永遠差一點點,
    看起來像「知識庫沒東西」。

    改成用「單位」計算:中文以字為單位,重疊的 n-gram 不會重複計分,
    雜訊 n-gram 也不會膨脹分母。
    """
    if not tokens or not text:
        return 0.0
    text_lower = text.lower()
    matched = [t for t in tokens if t in text_lower]
    if not matched:
        return 0.0
    return len(_units(matched)) / max(len(_units(tokens)), 1)


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

if __name__ == "__main__":
    xkb_usage.record(__file__)
    import sys

    for arg in sys.argv[1:] or ["我想做一支產品廣告影片"]:
        print(f"{arg!r}\n  → {tokenize(arg)}")

# ── Redundancy ───────────────────────────────────────────────────────────────
# A second way to compare text, for a different question. ``tokenize`` asks
# "does this text match the query", and its 2/3-character n-grams are built for
# precision. ``similarity`` asks "has this already been said", where precision
# is the wrong instinct: two rephrasings of one claim are redundant, and the
# n-grams score them at 0.39 while single characters score them at 0.73.
#
# Ported from Memmy's recallTextSimilarity (MemTensor/memmy-agent, MIT). It
# lives here rather than beside its one caller so that the next thing needing
# "are these two texts saying the same thing" finds it instead of writing a
# ninth tokeniser.
_TERM_RE = re.compile(r"[一-鿿]|[a-z0-9_:-]{2,}")


def terms(value: str) -> set[str]:
    """Comparison terms:每個漢字自成一詞, plus ASCII runs of two or more."""
    return set(_TERM_RE.findall(value.lower()))


def similarity(left: str, right: str) -> float:
    """Term overlap over the larger term set, in 0..1."""
    a = terms(left)
    b = terms(right)
    if not a or not b:
        return 0.0
    return len(a & b) / max(len(a), len(b))