痛点:关键词匹配为什么撑不起智能客服
做智能客服最怕的不是用户问得难,而是问得“偏”。传统关键词匹配把 query 拆成词袋,去 FAQ 库里做 LIKE '%keyword%',结果遇到下面几种情况直接翻车:
- 长尾问题:用户输入“我昨天买的那个红色小玩意儿怎么退货”,关键词只有“退货”命中,结果把数码产品的退货政策推给用户。
- 多轮上下文:上一句问“你们支持分期吗”,下一句追问“那手续费呢”,关键词“手续费”孤零零出现,系统不知道主语是“分期”,答非所问。
- 同义词/口语化:用户说“想退单”,库里写的是“申请退款”,匹配度瞬间掉到 0。
这些问题在并发量一上来(促销、直播带货)会被放大:MySQL 全文索引 CPU 飙高,TP99 延迟从 200 ms 涨到 2 s,客服同学被用户催到怀疑人生。
技术选型:ES vs Solr vs MySQL 全文
我们先用 200 万条标准问答对在 8C32G 单机做压测,数据如下(单位:ms):
| 引擎 | TP50 | TP99 | 17 字短句 QPS | 50 字长句 QPS |
|---|---|---|---|---|
| MySQL 8.0 FULLTEXT | 120 | 2100 | 120 | 40 |
| Solr 8.11 | 45 | 380 | 800 | 320 |
| Elasticsearch 7.17 | 18 | 95 | 1600 | 850 |
ES 在实时性和扩展性上全面胜出,加上原生分布式、DSL 灵活、横向扩容简单,团队决定 All in ES。
整体架构:让检索层只做检索
- 对话服务把每轮用户问题写进 Kafka,同时带上 sessionId。
- 流处理节点用 sessionId 聚合近三轮对话,生成“增强 query”写回 Kafka。
- ES 检索服务只消费“增强 query”,取 Top5 答案后返回。
- 答案排序层再融合业务权重(商品、订单、会员等级)做重排。
这样检索层无状态,扩容只加节点,升级不影响对话逻辑。
实现方案:三条查询搞定模糊+上下文+同义词
1. multi-match + phrase_prefix 模糊召回
GET faq/_search { "query": { "bool": { "should": [ { "multi_match": { "query": "红色小玩意儿怎么退货", "fields": ["title^3", "content"], "type": "best_fields", "boost": 1 } }, { "match_phrase_prefix": { "content": { "query": "红色小玩意儿", "boost": 1.5, "max_expansions": 50 } } } ] } }, "size": 5 }2. Nested Object 保存多轮上下文(Python 示例)
from elasticsearch import Elasticsearch, helpers es = Elasticsearch(["http://es-node1:9200"]) def build_session_doc(session_id, turns): """ turns: [{"role":"user","text":"想退单"},{"role":"bot","text":"可申请退款"}] """ return { "_id": session_id, "_index": "session_context", "_source": { "update_time": datetime.utcnow(), "turns": [ {"role": t["role"], "text": t["text"], "pos": idx} for idx, t in enumerate(turns) ] } } # 增量更新,只保留最近 5 轮 def append_turn(session_id, role, text): es.update( index="session_context", id=session_id, body={ "script": { "source": """ if(ctx._source.turns.size()>=5){ctx._source.turns.remove(0);} ctx._source.turns.add(params.turn); ctx._source.update_time=params.ts; """, "params": {"turn": {"role": role, "text": text}, "ts": datetime.utcnow()} }, "upsert": { "update_time": datetime.utcnow(), "turns": [{"role": role, "text": text, "pos": 0}] } } )查询时把最近 3 轮文本拼成一句“增强 query”再走检索,实测长尾召回率提升 18%。
3. 自定义 analyzer + 同义词热更新
PUT _template/faq_template { "index_patterns": ["faq*"], "settings": { "number_of_shards": 3, "refresh_interval": "5s", "analysis": { "filter": { "synonym_filter": { "type": "synonym_graph", "synonyms_path": "analysis/synonyms.txt", "updateable": true } }, "analyzer": { "synonym_analyzer": { "tokenizer": "ik_max_word", "filter": ["synonym_filter", "lowercase"] } } } }, "mappings": { "properties": { "title": { "type": "text", "analyzer": "synonym_analyzer", "search_analyzer": "synonym_analyzer" } } } }synonyms.txt 示例:
退单,退款,退货 => 申请退款 分期,白条,花呗 => 分期付款热更新流程:
- 把新文件丢到每个节点的 config/analysis/ 下。
- 调用
POST /faq/_reload_search_analyzers让节点重载,毫秒级生效,无需滚动重启。
性能优化:让 TP99 再降一半
1. 分片策略与 refresh_interval
- 3 节点集群,索引 2 亿 docs,按“业务线”拆 6 个索引,每个索引 6 分片 1 副本。
- 写多读多场景把 refresh_interval 调到 5s,兼顾实时与合并 flush 压力;夜间低峰调到 30s,减少段合并。
2. search-after 替代 from/size 深度分页
GET faq/_search { "size": 20, "sort": [ {"_score": "desc"}, {"_id": "asc"} ], "search_after": [0.89, "faq_12345"] }避免 10000 条以上分页把节点内存打爆。
3. 压测报告(JMeter 1000 QPS)
- 单机 4 核 8G,ES 三节点,持续 30 min。
- 平均 CPU 58%,GC 年轻代 30 ms/次,TP99 95 ms,零错误。
- 当 QPS 提到 1500 出现队列堆积,加 2 个协调节点后 TP99 回到 110 ms,线性扩容得到验证。
避坑指南:生产踩过的坑
- wildcard 查询前后缀长度不限,直接
*退款*会把整个倒排表装进内存,曾让节点 OOM。做法:用 ngram+edge_ngram 预切词,查询阶段用 match,禁用 wildcard。 - 滚动重启时,分片自动平衡导致 IO 飙高。提前关闭
cluster.routing.allocation.enable=primaries,等所有主分片就位再开副本,重启时间从 40 min 缩到 12 min。 - 冷热分离:近 30 天索引放 SSD 热节点,>30 天迁到机械盘冷节点,用
index.routing.allocation.require.box_type=hot/cold规则,节省 45% 存储成本。
代码片段:Java High Level Client + Painless 权重脚本
RestHighLevelClient client = new RestHighLevelClient( RestClient.builder(new HttpHost("es-node1", 9200))); try { SearchRequest req = new SearchRequest("faq"); SearchSourceBuilder ssb = SearchSourceBuilder.searchSource() .query(QueryBuilders.functionScoreQuery( QueryBuilders.multiMatchQuery("怎么退货", "title^3", "content") .type(MultiMatchQueryBuilder.Type.BEST_FIELDS), new ScriptScoreFunctionBuilder( new Script(ScriptType.INLINE, "painless", "double w = doc['boost'].size()>0 ? doc['boost'].value : 1.0; " + "return _score * Math.log1p(w);", Collections.emptyMap()))) .setMinScore(0.3)) .size(5); req.source(ssb); SearchResponse resp = client.search(req, RequestOptions.DEFAULT); // 处理 resp... } catch (ElasticsearchException e) { log.error("检索失败", e); } finally { client.close(); }Kibana 慢查询定位三板斧
# 1. 开启慢日志 PUT /faq/_settings { "index.search.slowlog.threshold.query.warn": "200ms" } # 2. 查看慢查询 索引管理 -> 慢查询日志 -> 筛选耗时 >200ms # 3. Profile 一把梭 GET faq/_search { "profile": true, "query": {"match": {"content": "怎么退货"}} }把 profile 结果展开,看哪个子句耗时高,再决定要不要加缓存或改写 DSL。
延伸思考:BERT 语义增强怎么玩
倒排检索再准,也只是字面匹配。下一步计划把 BERT 向量也搬进 ES:
- 离线用 Sentence-BERT 把标准问题编码成 768 维向量,写进
dense_vector字段。 - 在线把用户问题实时编码,用
script_score+cosineSimilarity取 TopK。 - 字面检索与向量检索各取 30 条,融合打分(RRF 或线性加权),实测召回率再提 12%,TP99 增加 20 ms,仍在可接受范围。
等 8.x 官方出knn搜索就更好办了,届时把向量索引放内存,延迟还能再降一半。
整套方案上线三个月,FAQ 命中率从 68% 提到 87%,客服人均会话量降 32%。ES 集群稳稳跑到 1500 QPS,再也不用半夜起床重启节点。如果你也在为智能客服的“答非所问”掉头发,不妨试试把检索交给 Elasticsearch,让对话回归对话。