2026-07-21 11:05:57 +08:00
|
|
|
|
# ============================================================
|
|
|
|
|
|
# 轻量混合检索(moduleId: knowledge-retrieval, 可重生 ✅, 黄金测试 tests/golden/test_m3_knowledge.py)
|
|
|
|
|
|
# plan.md §8.2 的 M3 降配实现(诚实声明):
|
|
|
|
|
|
# 向量检索 → 字符二元组(bigram)相似度代替(中文无分词友好、零依赖)
|
|
|
|
|
|
# BM25 → 词频-逆文档频率的简化打分
|
|
|
|
|
|
# 元数据 → 标签精确命中加权
|
|
|
|
|
|
# 接口不变:后续换真向量库/重排器时调用方零改动。
|
|
|
|
|
|
# 硬规则(§8.1):命中必须带出处(assetId/title/version)。
|
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
from __future__ import annotations # 前向类型引用
|
|
|
|
|
|
|
|
|
|
|
|
import math # idf 对数
|
|
|
|
|
|
import re # 清洗
|
|
|
|
|
|
from typing import Any # 类型标注
|
|
|
|
|
|
|
|
|
|
|
|
# 非中文字母数字的清洗模式(保留语义字符)
|
|
|
|
|
|
_CLEAN = re.compile(r"[^\w\u4e00-\u9fff]+")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _bigrams(text: str) -> set[str]:
|
|
|
|
|
|
"""字符二元组集合:中文场景下无需分词的稳健相似度基元。"""
|
|
|
|
|
|
t = _CLEAN.sub("", text.lower()) # 去符号统一小写
|
|
|
|
|
|
if len(t) < 2: # 太短退化为单字
|
|
|
|
|
|
return set(t)
|
|
|
|
|
|
return {t[i:i + 2] for i in range(len(t) - 1)} # 相邻两字符滑窗
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _bigram_sim(q: set[str], d: set[str]) -> float:
|
|
|
|
|
|
"""Dice 相似度:2|交|/(|q|+|d|),对长度差异更稳健。"""
|
|
|
|
|
|
if not q or not d: # 空集合无相似
|
|
|
|
|
|
return 0.0
|
|
|
|
|
|
return 2 * len(q & d) / (len(q) + len(d)) # Dice 系数
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def search(assets: list[dict[str, Any]], query: str, top_k: int = 3,
|
|
|
|
|
|
min_score: float = 0.05) -> list[dict[str, Any]]:
|
|
|
|
|
|
"""混合检索:bigram 相似 + 标签命中加权 + 标题命中加权(P0 只读)。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
assets: 知识资产列表(KnowledgeStore.assets)
|
|
|
|
|
|
query: 用户问题
|
|
|
|
|
|
top_k: 返回条数
|
|
|
|
|
|
min_score: 分数下限(低于视为未命中,触发"知识库没有相关内容"的诚实回答)
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
[{asset 元信息, score, snippet}] 按分数降序;必带出处字段
|
|
|
|
|
|
"""
|
|
|
|
|
|
q_bi = _bigrams(query) # 查询的二元组
|
|
|
|
|
|
# 简化 idf:出现某二元组的文档越少,其区分度越高
|
|
|
|
|
|
docs_bi = [_bigrams(a["title"] + a["content"] + " ".join(a["tags"])) for a in assets] # 各文档二元组
|
|
|
|
|
|
n_docs = max(len(assets), 1) # 文档数
|
|
|
|
|
|
df: dict[str, int] = {} # 文档频率表
|
|
|
|
|
|
for bi in docs_bi: # 统计每个二元组出现的文档数
|
|
|
|
|
|
for g in bi:
|
|
|
|
|
|
df[g] = df.get(g, 0) + 1
|
|
|
|
|
|
scored: list[tuple[float, dict[str, Any]]] = [] # (分数, 资产)
|
|
|
|
|
|
for a, d_bi in zip(assets, docs_bi): # 逐资产打分
|
2026-07-23 13:38:43 +08:00
|
|
|
|
shared = q_bi & d_bi # 共有二元组
|
|
|
|
|
|
if not shared: # 无共有 → 未命中
|
|
|
|
|
|
continue
|
2026-07-21 11:05:57 +08:00
|
|
|
|
base = _bigram_sim(q_bi, d_bi) # 基础相似度
|
2026-07-23 13:38:43 +08:00
|
|
|
|
# 纯拉丁噪声查询(如 quantum blockchain)易与英文技术词偶合:要求更高 base
|
|
|
|
|
|
query_has_cjk = any("\u4e00" <= c <= "\u9fff" for c in query)
|
|
|
|
|
|
if not query_has_cjk and base < 0.12:
|
|
|
|
|
|
continue
|
2026-07-21 11:05:57 +08:00
|
|
|
|
# idf 加权:查询与文档共有的高区分度二元组占比
|
|
|
|
|
|
idf_bonus = sum(math.log(1 + n_docs / df.get(g, 1)) for g in shared) / (len(q_bi) + 1) # 区分度加成
|
|
|
|
|
|
tag_bonus = 0.3 if any(t in query for t in a["tags"]) else 0.0 # 标签精确命中加权
|
|
|
|
|
|
title_bonus = 0.4 if any(ch in a["title"] for ch in [query]) or a["title"] in query else 0.0 # 标题整体命中
|
|
|
|
|
|
score = base + 0.1 * idf_bonus + tag_bonus + title_bonus # 混合分
|
|
|
|
|
|
if score >= min_score: # 过滤噪声
|
|
|
|
|
|
scored.append((score, a))
|
|
|
|
|
|
scored.sort(key=lambda x: -x[0]) # 按分数降序
|
|
|
|
|
|
hits = [] # 命中结果(带出处)
|
|
|
|
|
|
for score, a in scored[:top_k]: # 取前 K
|
|
|
|
|
|
hits.append({
|
|
|
|
|
|
"assetId": a["assetId"], # 出处:资产 ID
|
|
|
|
|
|
"title": a["title"], # 出处:标题
|
|
|
|
|
|
"kind": a["kind"], # 类别
|
|
|
|
|
|
"version": a["version"], # 出处:版本(§8.1 版本化)
|
|
|
|
|
|
"score": round(score, 3), # 相关度(调试与排序展示)
|
|
|
|
|
|
"snippet": a["content"][:120] + ("…" if len(a["content"]) > 120 else ""), # 摘录
|
|
|
|
|
|
"content": a["content"], # 全文(LLM 拼上下文用)
|
|
|
|
|
|
})
|
|
|
|
|
|
return hits # 空列表 = 未命中(上游诚实告知)
|