scripts/xkb_text.py
Rama main · réplica de lectura del repositorio original.
#!/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))