背景痛点:规则引擎为何撑不起现代 FAQ
- 早期 FAQ 系统大多基于“关键词+正则”或决策树,维护人员每天盯着用户日志手工加规则,一条新问法就得补一条正则,极易冲突。
- 规则之间优先级全靠“人肉”排序,随着 FAQ 条目破千,调试一次全量回归要半天,迭代速度被业务方疯狂吐槽。
- 泛化能力弱:用户把“如何重置密码”说成“忘记秘密想重新弄”,规则直接懵圈,召回率/recall 掉到 30% 以下。
- 多语言场景更灾难,中文、英文、缩写、谐音梗混用时,规则量呈指数级膨胀,成本与体验双重崩溃。
一句话:规则引擎适合冷启动 50 条以内的“小作坊”,一旦问答对破百,就得让位给语义模型。
技术选型:BERT 还是 TF-IDF?先给它们排个座次
| 维度 | TF-IDF | BERT-Sentence | SimCSE | |---|---|---|---|---| | 训练成本 | 0 GPU,秒级 | 1×RTX 3060 6h | 同上+1h对比学习 | | 语义泛化 | 差,字面匹配 | 好,同义改写可识别 | 更好,正负样本对比 | | 索引速度 | 倒排毫秒级 | 向量召回需 Faiss | 同左 | | 数据量要求 | 无 | >500 问答对即可微调 | >1000 对更稳 | | 线上 QPS | 1w+ | 2000+(Faiss-IVF) | 同左 |
结论:
- 0 数据冷启动 → TF-IDF 先扛两天流量
- 有 500+ 历史对话 → 直接上 Sentence-BERT
- 想再榨 3% 准确率 → SimCSE 对比学习锦上添花,但 ROI 递减
核心实现:Sentence-BERT + Faiss 五分钟搭完语义召回
1. 训练语义编码器
以下代码基于sentence-transformers库,输出 768 维向量,符合 PEP8,带类型注解。
# train_encoder.py from typing import List from sentence_transformers import SentenceTransformer, InputExample, losses from torch.utils.data import DataLoader def train_sentence_bert( qa_pairs: List[str], model_name: str = "paraphrase-multilingual-MiniLM-L12-v2", epochs: int = 3, batch_size: int = 64 ) -> SentenceTransformer: model = SentenceTransformer(model_name) examples = [InputExample(texts=[q, a]) for q, a in qa_pairs] loader = DataLoader(examples, batch_size=batch_size, shuffle=True) loss = losses.MultipleNegativesRankingLoss(model) model.fit(loader, epochs=epochs, warmup_steps=100) return model if __name__ == "__main__": qa_pairs = [...] # 你自己的问答对 model = train_sentence_bert(qa_pairs) model.save("faq-bert")2. 构建 Faiss 索引
# build_index.py import faiss import numpy as np from sentence_transformers import SentenceTransformer def build_faiss_index( sentences: List[str], model_path: str = "faq-bert", index_path: str = "faq.index" ) -> None: model = SentenceTransformer(model_path) vecs = model.encode(sentences, normalize_embeddings=True, batch_size=256) dim = vecs.shape[1] index = faiss.IndexFlatIP(dim) # 内积即 cosine index.add(vecs.astype("float32")) faiss.write_index(index, index_path) build_faiss_index(["如何重置密码", "开发票流程", ...])3. 在线召回
# online_search.py import faiss from sentence_transformers import SentenceTransformer class FaqSearcher: def __init__(self, index_path: str, model_path: str, qa_map: dict): self.index = faiss.read_index(index_path) self.model = SentenceTransformer(model_path) self.qa_map = qa_map # id→答案 def search(self, query: str, k: int = 5) -> List[dict]: vec = self.model.encode([query], normalize_embeddings=True) scores, idxs = self.index.search(vec.astype("float32"), k) return [{"answer": self.qa_map[i], "score": float(s)} for i, s in zip(idxs[0], scores[0]) if i != -1]4. 对话状态机(PlantUML)
把“闲聊/问答/结束”三态画成状态机,方便后面加“多轮澄清”。
@startuml state 闲聊 <<state>> state 问答 <<state>> state 结束 <<state>> [*] --> 闲聊 闲聊 --> 问答 : 触发关键词\n如"如何"/"怎么" 问答 --> 问答 : 继续提问 问答 --> 闲聊 : 未命中 问答 --> 结束 : 用户说"谢谢" 闲聊 --> 结束 : 用户说"退出" @enduml生产考量:别让日志把异步把准确率坑了
异步日志实验
- 同步写日志:P99 延迟 120 ms,问答准确率 88.3%
- 异步队列(无 back-pressure):P99 延迟 60 ms,但队列堆积时召回率/recall 掉到 85.1%,因超时截断
- 异步 + 队列长度监控 >200 自动降级为同步:P99 110 ms,准确率回到 87.9%,可接受
结论:日志必须带背压感知,否则高并发下“丢日志”=“丢特征”=“模型瞎猜”。
敏感词过滤
用 AC 自动机(Aho-Corasick)一次扫描,复杂度 O(n),再小服务器也扛得住。
# ac_filter.py from typing import List, Set import ahocorasick class SensitiveFilter: def __init__(self, word_list: List[str]): self.ac = ahocorasick.Automaton() for w in word_list: self.ac.add_word(w.lower()) self.ac.make_automaton() def mask(self, text: str, repl: str = "*") -> str: text_l = text.lower() for end, word in self.ac.iter(text_l): text = text[:end-len(word)+1] + repl*len(word) + text[end+1:] return text避坑指南:负样本与灰度,一个都不能少
负样本构建
- 别只拿“随机句子”当负例,那样模型太轻松,上线就飘。
- 采用“同批次内其他问题做负例+随机采样”混合,比例 1:1:1,训练 loss 更平滑。
- 每轮训练后把预测置信度 0.4~0.6 的“模糊区”问法写回负样本池,实现动态难例。
知识库灰度发布
- 把索引分片:v1、v2 双索引并存,流量按用户尾号 5%→10%→30%→100%四阶段切。
- 每阶段观察指标:准确率、人工转接率、平均对话轮数。
- 回滚策略:30 分钟内无人工审核即自动回切,防止“夜里上线、早上背锅”。
代码规范小结
- 统一 black 格式化,行宽 88
- 公开函数必写类型注解,返回值 -> None 也别省
- docstring 采用 Google Style,例:
def search(self, query: str, k: int = 5) -> List[dict]: """语义召回 Top-k 答案。 Args: query: 用户原始问句。 k: 召回条数。 Returns: 包含 answer 与 score 的字典列表,按 score 降序。 """延伸思考:让模型“长”在用户的反馈上
日志埋点
每次回答后插一条“是否解决”按钮,前端埋点把 session_id、answer_id、feedback 回流到 Kafka。自动标注
feedback=1 且置信度>0.8 的直接当正例;feedback=0 且原置信度>0.6 的当难负例,周级批量重训。人工复核
低置信 0.3~0.5 区间随机抽 5% 进审单平台,运营同学 2 小时内纠正标签,防止用户教坏模型。版本控制
每轮重训生成新 model_id,统一放在 OSS,灰度方案同上,保证“回滚按钮”始终亮着。
写在最后
整套方案跑下来,我们 4 人小团队用两周完成从 0 到 2000 QPS 的上线,问答准确率 88%,比老规则系统提升 20 个百分点,维护工时从每天 2 小时降到每周 1 小时。
BERT 不是银弹,但把“语义召回+灰度发布+反馈闭环”串好后,FAQ 场景基本够用。下一步想试试多轮对话的 Slot Filling,到时候再来记录踩坑日记。祝你落地顺利,有问题评论区一起掰扯。