《Spring ES实战》第14招 向量进入搜索主线
# 向量不是新终点:Elasticsearch 语义搜索与 BM25 的工程取舍 ```text 旧方案:BM25 只匹配字面词,语义相近但用词不同就召回失败 冲突:用户搜“适合经常出差程序员的轻便背包”,商品描述写“通勤电脑包” 后果:语义相关商品大量漏召回,转化率下降,运营被迫手工维护同义词表 ``` 很多团队把向量搜索单独做成 AI Demo,演示完就搁置。真正的问题是:它如何进入现有搜索主链路,而不是另起炉灶。 我是老李,这几年在电商搜索团队里反复遇到同一个矛盾:业务方希望搜索“懂意思”,但线上故障又总来自“懂意思”带来的不可控。 为什么精确词匹配仍然不能被向量召回替代? 当语义召回与关键词召回冲突时,应该选择合并、分层还是替换? 如果选错,线上搜索会出现什么后果? # 01、故事 ```text 周二 14:30→搜索项目室→任务:上线语义搜索提升长尾查询召回→冲突:测试集召回提升,但品牌词开始串味 ``` 那天下午两点半,搜索项目室里投影仪亮着。任务是让“适合经常出差程序员的轻便背包”这类长尾查询,能召回描述为“通勤电脑包”的商品。 我打开 Kibana 跑了一版 kNN 查询,语义结果确实漂亮。可运营同学立刻发来截图:搜“ThinkPad X1 Carbon 背包”,第一条变成了某品牌通用内胆包。 会议室安静了几秒。语义搜索把品牌词“ThinkPad X1 Carbon”也当成语义向量去近似匹配了。 > 长尾查询像雾, > 字面匹配漏掉真意。 > 向量补上缺口, > 品牌词却开始漂移。 # 02、问题 ```text 需求变化:从“字面匹配”到“理解意图”→冲突:语义召回与精确召回互相干扰→后果:品牌、型号、订单号等精确查询失准 ``` 旧方案里,BM25 对“ThinkPad X1 Carbon 背包”能精确命中标题和属性字段。引入向量后,如果只按 kNN 分数排序,精确词信号会被稀释。 影响很直接:用户搜品牌和型号时,看到的是“差不多”的商品,而不是“就是它”。这会造成客诉和退款率上升。 可验证的验收标准是:品牌词、型号词、订单号查询的 Top1 命中率不得低于旧版;长尾语义查询的召回率至少提升 20%。 > 字面是锚, > 语义是帆。 > 锚若被拔起, > 船会漂向无关。 # 03、原理 ```text 核心机制:BM25 做精确召回,dense_vector 做语义召回,两者分数独立计算后再融合 ``` Elasticsearch 的 dense_vector 字段存储 embedding,kNN 查询用相似度函数找近邻。 semantic_text 字段则把 embedding 生成和查询转换封装得更简单。 反直觉的一点是:向量相似度高不代表用户想找它。用户搜“轻便背包”,向量可能召回“轻便行李箱”,因为语义接近,但类目已经错了。 所以向量召回必须配合 Metadata Filter,例如 tenant、category、brand 等结构化条件,把语义搜索限制在业务边界内。 > 向量量的是近, > 不是量的是对。 > 近是数学, > 对是业务。 # 04、架构 ```text 输入:用户查询字符串 + tenant/category 过滤条件→本模块:BM25 召回 + kNN 语义召回 + 分数融合→输出:带业务过滤的排序结果 ``` 架构上分三层:查询解析层负责识别精确词和语义意图;召回层同时走 BM25 和 kNN;融合层用 RRF 或加权分数合并。 收益是长尾查询召回明显提升,代价是索引体积增大、查询延迟增加、embedding 模型需要维护。 适用条件是:有大量自然语言描述文本,且用户查询用词与商品描述用词经常不一致。纯 SKU 或订单号查询场景不适合。 > 一条查询两条路, > 字面走倒排, > 语义走向量。 > 最后在融合处会合。 # 05、第一版实战 ```text 环境:Elasticsearch 8.15+→配置:dense_vector 索引→代码:写入 embedding→启动:执行 kNN 查询→验证:语义召回→V1 ``` 先确认环境版本。Elasticsearch 8.15 已支持 semantic_text,但 dense_vector 更通用。以下用 dense_vector 演示,版本需按当前官方文档核验。 创建索引: ```json PUT /products { "mappings": { "properties": { "name": { "type": "text" }, "description": { "type": "text" }, "description_vector": { "type": "dense_vector", "dims": 768, "index": true, "similarity": "cosine" }, "tenant": { "type": "keyword" }, "category": { "type": "keyword" }, "brand": { "type": "keyword" } } } } ``` 写入文档前,需要把 description 转成 768 维向量。这里用 Python 调用本地 embedding 模型生成向量,再写入 ES: ```python from elasticsearch import Elasticsearch from sentence_transformers import SentenceTransformer model = SentenceTransformer('BAAI/bge-base-zh-v1.5') es = Elasticsearch("http://localhost:9200") docs = [ { "name": "轻量通勤电脑包", "description": "适合经常出差的程序员使用,可容纳14英寸笔记本,轻便防泼水", "tenant": "tenant_a", "category": "背包", "brand": "CityPack" }, { "name": "商务双肩包", "description": "大容量旅行背包,适合通勤和短途出差", "tenant": "tenant_a", "category": "背包", "brand": "UrbanGo" } ] for doc in docs: doc["description_vector"] = model.encode(doc["description"]).tolist() es.index(index="products", document=doc) ``` 执行 kNN 查询: ```json POST /products/_search { "knn": { "field": "description_vector", "query_vector_builder": { "text_embedding": { "model_id": ".multilingual-e5-small", "model_text": "适合经常出差程序员的轻便背包" } }, "k": 10, "num_candidates": 50, "filter": { "bool": { "must": [ { "term": { "tenant": "tenant_a" } }, { "term": { "category": "背包" } } ] } } } } ``` 示例输出: ```text 命中:轻量通勤电脑包,分数 0.91 命中:商务双肩包,分数 0.78 ``` V1 成功标准:语义相近但字面不同的商品能进入 Top10,且 tenant/category 过滤生效。未在当前环境实测,以下为预期结果。 > 向量写入索引, > 查询转成数字。 > 过滤先划边界, > 语义只在圈内。 # 06、排查 ```text 诊断树:语义召回异常→先查过滤条件→再查向量生成→最后查相似度阈值 ``` 第一个问题:搜“ThinkPad X1 Carbon 背包”时,通用内胆包排到第一。怀疑是 kNN 分数主导了排序。 检查查询: ```json GET /products/_search { "query": { "match": { "name": "ThinkPad X1 Carbon 背包" } } } ``` 证据:BM25 查询能精确命中品牌商品,但 kNN 查询把“ThinkPad”当成语义概念,召回一堆笔记本配件。根因是纯 kNN 查询没有保留精确词信号。 修复:把 BM25 和 kNN 合并,用 RRF 融合。 第二个问题:部分商品 description 为空,写入时报错。检查发现 embedding 模型对空字符串返回了 None。 ```bash curl -s "http://localhost:9200/products/_count" -H 'Content-Type: application/json' -d '{"query":{"exists":{"field":"description"}}}' ``` 证据:空 description 的文档没有写入 description_vector。根因是写入脚本缺少空值保护。修复:空 description 跳过向量生成,只写文本字段。 **错误尝试:** 一开始把 kNN 的 k 调到 100,想让语义结果更多。结果延迟翻倍,精确词问题更严重。 > 先查边界, > 再查向量。 > 最后查阈值, > 顺序别乱。 # 07、优化 ```text V1:纯 kNN 查询→证据:品牌词漂移、延迟升高→V2:BM25 + kNN 双路召回 + RRF 融合 ``` 改动核心是查询结构。不再单独发 kNN,而是用 bool 查询同时走 match 和 kNN,再用 RRF 合并。 ```json POST /products/_search { "query": { "bool": { "should": [ { "match": { "name": { "query": "ThinkPad X1 Carbon 背包", "boost": 2.0 } } }, { "knn": { "field": "description_vector", "query_vector_builder": { "text_embedding": { "model_id": ".multilingual-e5-small", "model_text": "ThinkPad X1 Carbon 背包" } }, "k": 10, "num_candidates": 50, "boost": 0.5 } } ], "filter": [ { "term": { "tenant": "tenant_a" } }, { "term": { "category": "背包" } } ] } }, "rank": { "rrf": { "window_size": 50, "rank_constant": 20 } } } ``` 为何改:BM25 保留精确词信号,kNN 补充语义召回,RRF 不依赖分数绝对值,避免向量分数和 BM25 分数不可比的问题。 > 两条路都走, > 分数各自算。 > 排名再合并, > 精确词不散。 # 08、演进 ```text V2:双路召回 + RRF→能力:精确词与语义词都能召回→遗留:embedding 模型更新、阈值调优、跨语言场景 ``` 用 V1 同一组请求验证:搜“适合经常出差程序员的轻便背包”,语义商品仍在前排;搜“ThinkPad X1 Carbon 背包”,品牌商品回到 Top1。 收益是长尾查询召回提升,精确查询不退化。代价是查询复杂度增加,需要维护两套召回逻辑。 遗留问题:embedding 模型升级后需要重建向量;相似度阈值需要按类目调优;跨语言语义搜索需要多语言模型。 > 能力增加了, > 代价也来了。 > 不是替代, > 是补位。 # 09、洞见 ```text 从现象到判断:向量召回不是新终点,而是倒排索引的新召回方式 ``` ## 向量搜索不能替代关键词搜索 ```text 精确词→BM25 锚定;语义词→kNN 补充 ``` 品牌、型号、订单号等精确词,用户期望的是“完全匹配”,不是“差不多”。BM25 在这类查询上更可靠,向量只适合自然语言描述类查询。 ## 反直觉判断:语义相似不等于业务相关 ```text 向量近→数学近;业务对→类目对 ``` 向量模型只衡量语义距离,不懂业务边界。没有 Metadata Filter,语义搜索会把“轻便背包”召回成“轻便行李箱”。 ## 反直觉判断:分数融合比单一召回更稳定 ```text BM25 分数与向量分数不可比→RRF 只比排名 ``` 直接加权相加会因为分数分布不同而偏向某一方。RRF 用排名融合,避免分数尺度问题,工程上更稳。 ## 向量是召回方式,不是搜索终点 ```text 倒排索引→精确召回;dense_vector→语义召回;融合→最终排序 ``` 把向量当成一种新的召回信号接入现有搜索链路,比另起炉灶做 AI 搜索更务实。 > 向量不是终点, > 是倒排的新枝。 > 精确词仍靠锚, > 语义词靠帆。 # 10、系统落地 ```text 入口:搜索 API→服务:查询解析与融合→本模块:BM25 + kNN + RRF→存储:dense_vector 索引→未实现:模型版本管理与自动重建 ``` 原来只有 BM25 字面搜索。本篇增加了 dense_vector 字段、embedding 生成、kNN 查询和 RRF 融合。 现在能做什么:长尾语义查询能召回字面不同但语义相关的商品,同时品牌词等精确查询不退化。 下一篇缺什么:embedding 模型版本管理、向量索引自动重建、相似度阈值自动调优。官方文档见 Elasticsearch 官网文档首页。 > 入口还是那个入口, > 服务多了一条路。 > 存储多了向量, > 模型管理还缺。 # 11、小结 ```text 三问三答:精确词为何不能替代→语义与精确冲突怎么办→选错会怎样 ``` 第一问:精确词匹配不能被向量替代,因为品牌、型号、订单号需要完全匹配,向量只保证语义近似。 第二问:语义与精确冲突时,选择双路召回加 RRF 融合,而不是替换或简单加权。 第三问:选错会导致品牌词漂移、客诉上升、延迟翻倍。当前状态是 V2 双路召回已上线,精确词 Top1 命中率保持,长尾召回提升。 > 三问有了答, > 路也走通了。 > 不是替代, > 是补位。 # 12、作业 ```text 理解→实战→排障→架构判断 ``` ## 理解题:为什么 BM25 在品牌词查询上比 kNN 更可靠? BM25 基于词项匹配,品牌词在标题和属性字段中通常以精确词形式出现,倒排索引能直接命中。kNN 把品牌词转成向量后,会与语义相近但不完全相同的商品混淆,导致排序漂移。 ## 实战题:给 products 索引增加 semantic_text 字段,并实现一个带 category 过滤的语义查询。 semantic_text 字段会自动处理 embedding 生成和查询转换。 创建索引时把 description 映射为 semantic_text,写入文档时无需手动生成向量。 查询时用 semantic 查询并加 filter 条件限制 category。 需按当前官方文档核验具体语法。 ## 排障题:语义搜索上线后,某类目商品全部消失,如何排查? 先检查 filter 条件是否把该 category 排除,再查 embedding 生成是否失败导致向量字段为空,最后确认 kNN 查询的 num_candidates 是否太小导致召回不足。 用 exists 查询检查向量字段覆盖率。 ## 架构判断题:订单号查询是否应该引入向量召回? 不应该。订单号是精确标识符,用户期望完全匹配。向量召回会引入无关订单,增加延迟且降低准确率。订单号查询应继续使用 term 或 keyword 精确匹配。 > 理解是根, > 实战是干。 > 排障是枝, > 架构是果。 # 13、升华 ```text 工程结论:向量搜索的价值在于补位,不在于替代 ``` 有依据的结论是:在电商搜索中,BM25 负责精确召回,向量负责语义召回,两者通过 RRF 融合,才能在提升长尾召回的同时不伤害精确查询。 下一篇可以继续做 embedding 模型版本管理与向量索引自动重建,把语义搜索从“能跑”推进到“可维护”。 > 倒排是根, > 向量是枝。 > 根深枝才茂, > 搜索才可靠。 **系列项目:** 电商搜索系统,服务运营与终端用户 **已完成:** BM25 字面搜索、类目与租户过滤 **本篇:** dense_vector 语义召回、kNN 查询、BM25+kNN 双路融合 **下一篇:** embedding 模型版本管理与向量索引自动重建