兩個 bug 同源於自檢測資「照能過建、不照現實建」: - ingest preflight:git status --porcelain 預設會把含中文或空格的路徑加引號 轉義,raw/ 白名單比對因此失效,中文檔名的 convert 產出被誤判為 raw/ 以外的 未提交變更而中止攝入。舊測資用 ASCII 檔名,整條路徑從未被走過。 - search:BM25Okapi 的 idf 在 df >= N/2 時 <= 0(rank_bm25 對負 idf 的替代值 epsilon x average_idf 在 average_idf 為負時同樣為負),與 search() 的 s > 0 過濾相乘,會讓「每頁都提到的核心詞」查詢全數落空——知識庫愈小、詞愈核心 愈嚴重,MCP 的 search_wiki 一併受害。改用 Lucene 式恆正 idf。 舊測資每個查詢詞都只出現在一頁(df=1),恰好避開此配置。 變更: - tools/ingest.py:preflight 比對前剝除 git 的轉義引號 - tools/search.py:_BM25 子類覆寫 _calc_idf 為 log(1 + (N-df+0.5)/(df+0.5)) - tools/selfcheck_ingest.py:新增 preflight 中文檔名放行 / 非 raw 擋下的檢查 - tools/selfcheck_search.py:新增與既有頁共用核心詞的測資,鎖住 idf 退化 - tools/convert/selfcheck.py、tools/selfcheck_lint.py:測資檔名改中文+空格 - AGENTS.md §13.4:新增「測資照現實建,不是照能過建」規則 - README.md §3.2:補 convert 產出應保持未提交、由 ingest 一併 commit 的流程 驗證:四個自檢全數通過;分別移除兩個修法後對應檢查會失敗。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
85 lines
3.0 KiB
Python
85 lines
3.0 KiB
Python
"""wiki BM25 全文檢索(第一版不用向量庫,AGENTS.md §0)。
|
||
|
||
用法:python tools/search.py "查詢詞" [-k 10] [--root DIR]
|
||
輸出:JSON 陣列(path、title、description、tags、score),供 agent 與 MCP 使用。
|
||
"""
|
||
import argparse
|
||
import json
|
||
import math
|
||
import pathlib
|
||
import re
|
||
import sys
|
||
|
||
sys.path.insert(0, str(pathlib.Path(__file__).parent))
|
||
import kb
|
||
from rank_bm25 import BM25Okapi
|
||
|
||
|
||
def tokenize(text):
|
||
"""英數字詞 + CJK bigram。
|
||
|
||
ponytail: bigram 斷詞無語意理解(同義詞不匹配),天花板是字面重疊;
|
||
升級路徑:Phase 6 後評估 EmbeddingGemma / snowflake-arctic-embed 向量檢索。
|
||
"""
|
||
text = text.lower()
|
||
tokens = re.findall(r"[a-z0-9]+", text)
|
||
for run in re.findall(r"[一-鿿]+", text):
|
||
tokens += [run] if len(run) == 1 else [run[i:i + 2] for i in range(len(run) - 1)]
|
||
return tokens
|
||
|
||
|
||
class _BM25(BM25Okapi):
|
||
"""改用 Lucene 式 idf:log(1 + (N-df+0.5)/(df+0.5)),恆為正。
|
||
|
||
BM25Okapi 原式在 df >= N/2 時 idf <= 0(rank_bm25 對負 idf 的替代值
|
||
epsilon × average_idf 在 average_idf 為負時同樣是負的),與 search() 的
|
||
`s > 0` 過濾相乘,會讓「每頁都提到的核心詞」查詢全數落空——知識庫愈小、
|
||
詞愈核心愈嚴重。恆正 idf 讓常見詞只是權重低,而不是被整批濾掉。
|
||
"""
|
||
|
||
def _calc_idf(self, nd):
|
||
for word, freq in nd.items():
|
||
self.idf[word] = math.log(1 + (self.corpus_size - freq + 0.5) / (freq + 0.5))
|
||
|
||
|
||
def build_corpus(root):
|
||
docs = []
|
||
for p in kb.iter_pages(root):
|
||
try:
|
||
meta, body = kb.parse_page(p.read_text(encoding="utf-8"))
|
||
except ValueError:
|
||
continue # 壞頁由 lint 報,檢索直接略過
|
||
text = (f"{meta['title']} " * 3 + f"{meta['description']} " * 2
|
||
+ " ".join(meta.get("tags") or []) + " " + body)
|
||
docs.append({"path": p.relative_to(root).as_posix(), "title": str(meta["title"]),
|
||
"description": str(meta["description"]),
|
||
"tags": [str(t) for t in meta.get("tags") or []],
|
||
"tokens": tokenize(text)})
|
||
return docs
|
||
|
||
|
||
def search(root, query, k=10):
|
||
docs = build_corpus(pathlib.Path(root))
|
||
if not docs:
|
||
return []
|
||
bm = _BM25([d["tokens"] for d in docs])
|
||
scores = bm.get_scores(tokenize(query))
|
||
ranked = sorted(zip(docs, scores), key=lambda x: -x[1])[:k]
|
||
return [{"path": d["path"], "title": d["title"], "description": d["description"],
|
||
"tags": d["tags"], "score": round(float(s), 4)}
|
||
for d, s in ranked if s > 0]
|
||
|
||
|
||
def main(argv=None):
|
||
ap = argparse.ArgumentParser(description=__doc__)
|
||
ap.add_argument("query")
|
||
ap.add_argument("-k", type=int, default=10, help="回傳筆數上限(查詢工作流上限 10)")
|
||
ap.add_argument("--root", default=str(kb.ROOT))
|
||
a = ap.parse_args(argv)
|
||
hits = search(a.root, a.query, max(1, min(a.k, 10)))
|
||
print(json.dumps(hits, ensure_ascii=False, indent=2))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|