背景痛点:为什么传统 FAQ 总是“答非所问”
做智能客服的同学都踩过这些坑:
- 用户把“怎么退货”说成“东西不要了”,规则引擎直接宕机,Trie 树里根本搜不到“不要了”这条分支。
- 618 大促零点突刺,QPS 从 200 飙到 3 k,Tomcat 线程池瞬间打满,前端得到一大片 502。
- 多轮对话里,用户上一句说“那张卡”,下一句说“它”,对话状态机直接失忆,把上下文指代丢到九霄云外。
一句话:语义偏差、并发雪崩、状态丢失,三座大山压得 FAQ 系统喘不过气。
技术对比:规则、传统 ML、深度学习三路横评
我们在 4 核 8 G 的测试机里,用同一批 2 万条人工标注 FAQ 日志做了对比实验(平均句长 12 个汉字):
| 方案 | 意图准确率 | P99 延迟 | 冷启动时间 | 备注 |
|---|---|---|---|---|
| Trie+关键词 | 63 % | 7 ms | 0 s | 零门槛,但扩写同义词就是灾难 |
| SVM+TF-IDF | 78 % | 23 ms | 5 min | 特征工程占 70 % 工作量 |
| BERT+Faiss(GPU) | 93 % | 38 ms | 3 min | 需要 CUDA 内存 1.5 G |
结论:BERT 贵 15 ms,却换来 15 % 的准确率提升,在高并发场景下 ROI 最高。
核心实现一:BERT+Faiss 语义召回
1. 离线构建索引
# encode.py import torch from transformers import BertTokenizer, BertModel import faiss import numpy as np class FaissIndexBuilder: def __init__(self, model_name='bert-base-chinese'): self.tokenizer = BertTokenizer.from_pretrained(model_name) self.model = BertModel.from_pretrained(model_name).cuda().eval() def encode_batch(self, sentences: list, batch_size:64) -> np.ndarray: """返回 N×768 向量""" vec_list = [] with torch.no_grad(): for i in range(0, len(sentences), batch_size): batch = sentences[i:i+batch_size] encoded = self.tokenizer(batch, padding=True, truncation=True, return_tensors='pt').to('cuda') out = self.model(**encoded) vec = out.last_hidden_state[:, 0, :].cpu().numpy() vec_list.append(vec) return np.vstack(vec_list) def build_and_save(self, sentences: list, index_path:str): vecs = self.encode_batch(sentences) index = faiss.IndexFlatIP(768) # 内积,后面会做归一化 faiss.normalize_L2(vecs) index.add(vecs) faiss.write_index(index, index_path)2. 在线语义检索
# search.py import faiss import torch from encode import FaissIndexBuilder class SemanticRetrieval: def __init__(self, index_path:str, model_name:str): self.index = faiss.read_index(index_path) self.builder = FaissIndexBuilder(model_name) def query(self, sentence:str, topk:int=5) -> list[int]: """返回最相似的 FAQ id 列表""" vec = self.builder.encode_batch([sentence]) faiss.normalize_L2(vec) D, I = self.index.search(vec, topk) return I[0].tolist()GPU 加速要点:
- 把
BertModel和输入张量全部.cuda(),可让 768 维 2 万条索引构建从 20 min 降到 90 s。 - Faiss 采用
IndexFlatIP而非 L2,减少一次平方根运算,P99 延迟再降 4 ms。
核心实现二:Celery 异步架构——别让模型推理阻塞主线程
1. 任务定义
# tasks.py from celery import Celery from search import SemanticRetrieval app = Celery('faq', broker='redis://cluster:6379/1', backend='redis://cluster:6379/2') retrieval = SemanticRetrieval('/data/faq.index') @app.task(bind=True, max_retries=3) def async_search(self, query:str): try: ids = retrieval.query(query) return ids except Exception as exc: raise self.retry(exc=exc, countdown=1)2. 网关侧调用
# api.py from flask import Flask, request, jsonify from tasks import async_search app = Flask(__name__) @app.route('/ask', methods=['POST']) def ask(): query = request.json['q'] job = async_search.delay(query) return jsonify({'task_id': job.id}), 202通过 Celery+Redis 做队列,Web 层只负责任务投递,推理节点可水平扩展到 20 台,实测 3 k QPS 时主线程 RT 仍低于 30 ms。
性能优化:AB 测试与缓存组合拳
1. 连接池 vs 短连接
| 方案 | P99 RT | 错误率 |
|---|---|---|
| 短连接 | 120 ms | 2 % |
| redis-py 连接池(50) | 38 ms | 0.2 % |
2. 缓存策略
- 对 Top 5 k 问题做 Redis 缓存,key=hash(query),value=json(ids),TTL=10 min。
- 缓存命中率 62 %,回源 QPS 从 3 k 降到 1 k,后端 GPU 机器节省 3 台。
3. 对话状态 Redis 分片
多轮场景下,uid 的上下文写入单 Redis 易成热点。采用 16 个分片:
shard = crc32(uid.encode()) % 16 r = redis.Redis(host=f'redis-shard-{shard}', port=6379, db=0)将 2 G 数据拆成 16×128 M,单点故障率下降,水平扩容更丝滑。
避坑指南:热更新、敏感词与合规
1. 模型热更新导致会话不一致
- 场景:白天推送新 BERT 模型,线上新旧节点混合,同一用户前后请求落到不同版本,答案自相矛盾。
- 解决:
- 采用蓝绿标签:新模型先打
v2tag,灰度 5 % 流量。 - 同一 uid 的后续请求按
stickiness=tag写入路由,保证会话一致性。
- 采用蓝绿标签:新模型先打
2. 敏感词过滤钩子
# hook.py import re from functools import wraps BANNED = re.compile(r'(?:刷单|套现|枪支)', re.I) def sensitive_check(func): @wraps(func) def wrapper(query:str): if BANNED.search(query): return {'safe': False, 'answer': '您的提问包含敏感内容'} return func(query) return wrapper在async_search任务入口加@sensitive_check,即可统一拦截,避免不合规答案流出。
代码规范小结
- 所有 Python 文件统一用
black格式化,行宽 88。 - 公开函数必须写 docstring,注明输入类型、输出形状、异常。
- 变量命名采用
snake_case,类名PascalCase,常量UPPER_SNAKE。
留给你:如何平衡语义精度与响应速度?
BERT 带来 93 % 的准确率,却吃掉 38 ms;规则引擎只要 7 ms,却经常“智障”。在真实业务里,你会:
-1. 继续加 GPU 机器换时间? f2. 退回到轻量模型如 ALBERT 或 TinyBERT? f3. 还是把两阶段融合——先用关键词过滤,再送 BERT 精排?
欢迎在评论区贴出你的方案与压测数据,一起把 FAQ 的 RT 再降 10 ms!