FAQ 覆盖度打分:TF-IDF 与向量嵌入双路召回的 Python 实现

很多团队上线 FAQ 库以后,对覆盖度的判断其实靠感觉:能想到的问题都写进去了。把真实问题集铺开跑一遍巡检,结论常常反转------同义问法被判成没覆盖,答案沾边的反而被当成命中。这说明单一相似度路径不可靠,字面与语义两路召回再加权融合,才是更稳的打法。本文给出一套完整的 Python 实现与打分口径,只依赖 numpy 与 scikit-learn,不涉及任何私有库。

一、任务定义

输入两份文本清单:FAQ 库(已入库的问答条目)与问题集(用户真实问过的问题)。对每个问题,算出它在 FAQ 库里能拿到的相似度高值,作为这个题的覆盖分;再按分数把问题分成已覆盖、部分覆盖、空白三档。空白清单就是下一轮内容生产的待办。

打分前先约法三章:

· 按问题算,不按 FAQ 算。FAQ 库往往严重偏科,平均分掩盖盲区。

· 每个问题取它能拿到的分,不数命中条数。命中十条相似答案和命中一条,覆盖意义相同。

· 分档阈值不是通用真理,要靠抽样对账校准,方法见第五节。

二、字面路:TF-IDF 字符 n-gram

中文没有天然空格,引入分词就要额外承担分词质量的误差。更省事的做法是字符级 n-gram:文本先归一化,再把 TfidfVectorizer 的 analyzer 设为 char_wb、取 1 到 3 元组,字面重合的信号就足够稳定。

python 复制代码
import re
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import linear_kernel

def normalize(text):
    text = text.lower()
    text = re.sub(r"\s+", "", text)
    return re.sub(r"[^\w\u4e00-\u9fff]", "", text)

def tfidf_scores(queries, faqs, ngram=(1, 3)):
    corpus = [normalize(t) for t in faqs + queries]
    vec = TfidfVectorizer(analyzer="char_wb", ngram_range=ngram)
    mat = vec.fit_transform(corpus)
    sim = linear_kernel(mat[len(faqs):], mat[:len(faqs)])
    return sim.max(axis=1)

字面路擅长抓同源表述:改一个字、多一个限定词,分数会立刻变化,这种敏感正是它该干的活。表述一换、说法漂移,它就会失控,这部分只能交给语义路。

示意图:客服质检场景示意,AI 生成,非实拍,不对应具体项目。

三、语义路:向量嵌入

「质保期是多久」和「保修多长时间」几乎没有重合的 n-gram,说的却是同一件事。语义路处理的就是这类问题。生产环境里如果有嵌入服务,把下面的函数换成模型调用即可;暂时没有模型、想先把评测管道跑通,可以用确定性哈希向量代替:把字符 n-gram 哈希映射到固定维度再归一化,近似保留语义相近文本之间的相对距离。

python 复制代码
import zlib

def embed(text, dim=256):
    vec = np.zeros(dim)
    text = normalize(text)
    for n in (2, 3):
        for i in range(max(len(text) - n + 1, 0)):
            h = zlib.crc32(text[i:i + n].encode("utf-8"))
            vec[h % dim] += 1.0
    norm = np.linalg.norm(vec)
    return vec / norm if norm > 0 else vec

def embed_scores(queries, faqs):
    ref = np.array([embed(t) for t in faqs])
    qry = np.array([embed(t) for t in queries])
    return (qry @ ref.T).max(axis=1)

要强调一点:哈希向量只是替身,判别力与训练过的嵌入模型差得远。它的正确用途是让分档分布先跑起来,换上真模型后再对比档位移动了多少。两条路都返回每个问题的相似度,量纲一致,可以直接融合。

示意图:AI 搜索占位场景示意,AI 生成,非实拍,不对应具体项目。

维度 dim 的取值在 256 到 1024 之间都可用,问题集在千条以内时 256 维足够;维度再高,稀疏哈希冲突减少有限,内存与计算却线性上涨。另一个细节是 n-gram 只取 2、3 元组:单个字符噪声大,四元组以上在短问句里出现次数太少,统计意义薄弱。这两处取舍不算定论,换语种、换文本长度时应重新观察分布再调。

四、双路融合与覆盖报告

融合有两种流派:加权求和与两路取高。校准样本还少的时候,加权求和更稳,因为两条路各自覆盖不同的失误类型;样本对过一轮账之后,取高更贴近业务判断------一个问题只要被任何一条路接住,它的条目就存在。下面实现加权求和,alpha 留成可调参数。

python 复制代码
def coverage_report(queries, faqs, alpha=0.5, hi=0.75, lo=0.45):
    t = tfidf_scores(queries, faqs)
    e = embed_scores(queries, faqs)
    rows = []
    for q, s1, s2 in zip(queries, t, e):
        score = alpha * float(s1) + (1 - alpha) * float(s2)
        if score >= hi:
            level = "covered"
        elif score >= lo:
            level = "partial"
        else:
            level = "blank"
        rows.append({"query": q, "tfidf": round(float(s1), 3),
                     "embed": round(float(s2), 3), "score": round(score, 3),
                     "level": level})
    return rows

跑完得到的是带问题、双路分、融合分与档位的记录列表,落盘 CSV 按档位一排,空白题清单就是下一轮内容生产的队列。

示意图:数据看板复盘场景示意,AI 生成,非实拍,不对应具体项目。

五、分档口径怎么校准

| 档位 | 融合分 | 典型形态 | 建议动作 |

|---|---|---|---|

| 已覆盖 | ≥ 0.75 | 字面与语义双命中 | 抽查答案文本的时效与口径 |

| 部分覆盖 | 0.45 -- 0.75 | 接近但没对上 | 给既有条目补同义问法 |

| 空白 | < 0.45 | 没有相关条目 | 进选题队列新写条目 |

示意图:AI 搜索结果占位对比示意,AI 生成,非实拍,不对应具体项目。

校准是人工活:三个分数段各抽二十条,看问题与它命中的答案对在一起「像不像一回事」,按判断结果把阈值挪一两档,错判率降到可接受范围就收手。阈值要连同样本一起存档,下一轮复跑才有可比性。

alpha 的初值取 0.5 即可,不必在它身上做精细搜索。更值得投入的地方是记录双路分数:当某批题长期呈现「字面低、语义高」,说明表述漂移在加剧,要补同义问法;反过来「字面高、语义低」,通常是条目里堆了相同词但答非所问。两个分数的背离方向,本身就是库况的诊断信号。

六、落地 SOP

示意图:文档扫描录入场景示意,AI 生成,非实拍,不对应具体项目。

· 步骤一:备题。从客服记录、搜索日志里收真实问题,再让业务团队补一批,一百到三百条起步。

· 步骤二:清洗。去表情与编号、合并完全同形的问法,保留原始表述列以便回溯。

· 步骤三:双路各跑各的,先字面后语义,两个分数分开落盘再融合。

· 步骤四:按第五节抽样校准阈值,确定三档边界。

· 步骤五:空白清单进内容队列,每周或每月复跑,盯档位分布的变化趋势。

工程上没有性能压力:问题集千条以内、FAQ 万条以内,整轮跑完在秒级。字面路之后想升级成 BM25,或者语义路换成真正的嵌入服务,融合的管道与分档口径都不用改,适合作为第二步迭代。定期跑的话,把报告连同问题集的版本号一起存档,跨期的趋势才有可比的基准。

七、三个常见误区

· 只用一路。只用 TF-IDF 会把同义问题误判成空白,制造假危机;只用嵌入会漏掉「同一答案换了表述就认不出」的风险,两路不是互替关系。

· 忘记归一化。大小写、全半角、空白不统一,字面分就会漂,n-gram 统计必须在归一化之后做。

· 问题集不更新。静态评测集半年就过期,产品一变问法跟着变,评测集本身也需要持续从真实渠道补采。

覆盖度打分不是终点,它的作用是把「还缺哪些内容」从感觉变成清单。管道搭好后复跑成本很低,低到足以每周跑一遍------这正好对上问题集本身的变化速度。

(本文由一支长期做检索评测与知识库工程的技术团队整理,欢迎同行交流指正。)

相关推荐
爱笑的k1124 分钟前
nano-vllm 源码剖析:千行 Python 手写离线推理引擎的调度、显存与并行时序
开发语言·python·vllm
用户8356290780511 小时前
使用 Python 控制 Excel 工作表的分页位置
后端·python
用户8356290780511 小时前
Python 如何在 Word 中添加和管理文档变量
后端·python
用户019027581614 小时前
Dify / Coze 低代码 Agent 怎么接 A 股行情?
python
Flynn Liang4 小时前
Rerank 与上下文工程:重排、压缩与引用溯源的收益边界
langchain·rag·langchain4j·rerank·上下文工程·引用溯源
Dumbness5 小时前
不传 day 参数,座位图显示全空:用 Python 标准库手写 Web 框架,请求到业务代码前的 5 个细节
后端·python
databook6 小时前
收上来一堆 Excel,怎么保证没改错
python·数据分析
2601_967987376 小时前
Python 教程:默认字典(defaultdict)全维度深度指南
python·性能优化·数据处理·collections·defaultdict
鱼听禅6 小时前
Ubuntu安装YOLOv8进行菌落识别训练
linux·python·yolo·ubuntu