尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Day 42:语义搜索来了——Elasticsearch kNN 向量检索与混合搜索

Day 42:语义搜索来了——Elasticsearch kNN 向量检索与混合搜索 客户做搜索优化用户输入「Nike 跑鞋」搜索结果里跳出来的全是字面带 Nike 的鞋款但用户想要的那双耐克飞马 40 慢跑鞋——标题里写的是飞马系列跑步鞋关键词命中率是0。另一个用户搜「适合马拉松的鞋子」连一双专业跑鞋都没召回。因为「马拉松」这词在商品标题里压根没出现过BM25 看到的是空白。这就是传统关键词检索的死穴它认识字不认识意思。而要解决这个靠的不只是同义词词典——你穷举不完跑鞋和慢跑鞋/竞速鞋/训练鞋的关联。你需要的是向量——把语义相近这件事变成数学上能比较的距离。今天这篇文章就把 Elasticsearch 8.x 的dense_vectorknn查询 RRF 混合检索这套组合拳完整打给你。一、为什么是 ES 做语义搜索而不是专门搞个向量数据库先回答一个常见问题我有 pgvector、有 Milvus、有 Qdrant为啥还要在 ES 里搞向量答案很直接——绝大多数团队的搜索场景都是混合的80% 的查询是商品名 / SKU / 编号 / 标签这种精确词命中向量检索算得慢还容易跑偏20% 的查询是我也不知道叫啥但大概描述一下这才是向量的战场业务还要按价格区间、品牌、库存、是否促销做结构化过滤如果再搞一套独立的向量库意味着你要维护两套数据写入、两套查询路由、两套权限、两套监控。ES 8.12 把dense_vector做成了一等公民原生支持 kNN 查询原生支持 RRF 混合排序这就是为我就是想在一个集群里搞定的人准备的。下面我用的版本Elasticsearch 8.12.2 Spring Boot 3.2.4 Java 17。二、第一步建索引——把dense_vector字段定义好ES 8.x 的dense_vector字段有两个核心参数dims向量维度必须和 Embedding 模型输出对齐index是否建 HNSW 索引推荐true否则只能全量暴力扫描我用一个商品搜索的场景来做例子。2.1 Mapping 定义PUT /products { settings: { number_of_shards: 3, number_of_replicas: 1 }, mappings: { properties: { title: { type: text, analyzer: ik_max_word, fields: { keyword: { type: keyword } } }, brand: { type: keyword }, price: { type: double }, stock: { type: integer }, tags: { type: keyword }, title_vec: { type: dense_vector, dims: 1024, index: true, similarity: cosine, index_options: { type: int8_hnsw, m: 16, ef_construction: 100 } } } } }几个容易踩坑的点老梁标出来dims一定要和模型对齐。我用BGE-M3中文 Embedding 模型输出维度是 1024。如果你用text-embedding-3-small是 1536用text-embedding-ada-002是 1536别搞错。int8_hnsw是 ES 8.12 新出的量化方案比hnsw省一半内存召回率损失 3%。内存紧张时优先选这个。similarity: cosine适合文本语义。如果做人脸/图像选dot_product或l2_norm。m和ef_construction是 HNSW 的图参数调大召回高但写入慢。生产环境一般m16, ef_construction100就够用。2.2 写入带向量的文档Spring Boot 3 实战java // 依赖spring-boot-starter-data-elasticsearch 3.2.4 // Elasticsearch Java Client 8.12.2 Service public class ProductIndexer { private final ElasticsearchClient esClient; private final EmbeddingModel embeddingModel; // 来自 Spring AI public ProductIndexer(ElasticsearchClient esClient, EmbeddingModel embeddingModel) { this.esClient esClient; this.embeddingModel embeddingModel; } public void index(Product product) throws IOException { // 1. 调用 Embedding 模型把标题转成向量 float[] vector embeddingModel.embed(product.getTitle()); // 2. 构造 ES 文档 MapString, Object doc new HashMap(); doc.put(title, product.getTitle()); doc.put(brand, product.getBrand()); doc.put(price, product.getPrice()); doc.put(stock, product.getStock()); doc.put(tags, product.getTags()); doc.put(title_vec, vector); // float[] 直接序列化进 JSON // 3. 写入 esClient.index(i - i .index(products) .id(product.getId()) .document(doc)); } } 提示Spring AI 的EmbeddingModel是一个统一接口背后可以是 OpenAI、Azure、智谱、通义千问、Ollama 本地模型。切换模型只改配置不用改业务代码。后面 Day 71 讲 RAG 时会专门展开。三、第二步kNN 查询——单兵作战先看最基础的纯向量查询长啥样。3.1 核心查询 DSLPOST /products/_search { knn: { field: title_vec, query_vector: [0.123, -0.456, 0.789, ... ], k: 20, num_candidates: 100, filter: { term: { brand: nike } } }, _source: [title, brand, price] }关键参数k: 20返回最相似的 20 条num_candidates: 100在 HNSW 图里扫描的候选数。这个值要远大于 k否则召回率会掉。一般经验num_candidates 5 ~ 10 × kfilter向量检索的同时做结构化过滤品牌、价格、库存ES 8.x 的 kNN 是支持 filter 的这是它比很多向量库的杀手锏3.2 Java 客户端代码public ListProduct searchByVector(float[] queryVector, String brand) throws IOException { SearchResponseProduct resp esClient.search(s - s .index(products) .knn(knn - knn .field(title_vec) .queryVector(queryVector) .k(20) .numCandidates(100) .filter(f - f.term(t - t.field(brand).value(brand)))) .source(src - src.filter(f - f.includes(title, brand, price))), Product.class ); return resp.hits().hits().stream() .map(Hit::source) .filter(Objects::nonNull) .collect(Collectors.toList()); }单兵作战的局限纯向量检索虽然能召回耐克跑鞋这种语义匹配但对精确词命中不友好。比如用户搜SKU: 8848-NK-BLACK向量算出来离8848-NK-RED更近结果推荐了红色款——这不是用户想要的。所以生产环境几乎没人用纯 kNN主流做法是混合。四、第三步RRF 混合检索——双剑合璧ES 8.8 之后引入了RRFReciprocal Rank Fusion倒数排名融合算法把关键词检索和向量检索的结果融合排序。4.1 RRF 原理一句话版每条记录在每个检索通道里都有一个排名位置 k最终得分 1 / (k rank)对所有通道求和得分最高的排第一。最终得分(doc) Σ [ 1 / (60 rank_i(doc)) ]60是 RRF 的默认平滑常数防止除零和过度倾斜。这玩意的好处是——不需要把不同通道的分数归一化因为它只关心排名不关心绝对分数。4.2 查询 DSLPOST /products/_search { query: { match: { title: 适合马拉松的跑鞋 } }, knn: { field: title_vec, query_vector: [0.123, ...], k: 20, num_candidates: 100 }, rank: { rrf: { window_size: 50, rank_constant: 60 } }, _source: [title, brand, price] }window_size是每个通道进入融合的候选数量上限。生产经验window_size取最终返回条数的 2~3 倍。4.3 Java 完整封装public SearchResponseProduct hybridSearch(String text, float[] queryVector) throws IOException { return esClient.search(s - s .index(products) .size(20) .query(q - q.match(m - m.field(title).query(text))) .knn(knn - knn .field(title_vec) .queryVector(queryVector) .k(20) .numCandidates(100)) .rank(r - r.rrf(rrf - rrf .windowSize(50) .rankConstant(60))), Product.class ); }就这么几行代码一个既能认字又能认意的搜索引擎就跑起来了。五、三个生产级调优建议建议 1向量维度和量化方案要根据数据量选型数据量 100 万直接hnsw简单暴力。 数据量 100 万 ~ 1 亿上int8_hnsw内存砍半召回几乎不损失。 数据量 1 亿考虑量化到bbq_hnswES 8.13 的二进制量化内存再砍 32 倍召回损失 5% 以内。别一上来就 bbq召回率敏感的场景法律/医疗优先保证精度。建议 2num_candidates是召回率和延迟的旋钮不是越大越好我做过压测在一个 500 万条文档、3 节点的 ES 8.12 集群上num_candidatesp99 延迟召回率5018 ms78%10026 ms92%20041 ms97%50089 ms99%经验值num_candidates 5 × kp99 延迟可控在 30ms 内召回 92% 以上对大多数搜索场景够用。建议 3先跑离线评估别凭直觉调权重RRF 的window_size和rank_constant怎么调靠业务指标调不是靠参数直觉。最朴素的做法找 200 个真实用户查询 标准答案集自动化算 NDCG10、MRR、召回率。我后面 Day 75 讲 RAGAS 评估时会专门拆这块今天先记下来没有评估指标的搜索优化都是玄学。六、一个常被忽略的坑Embedding 模型必须和 ES 版本对齐int8_hnsw和bbq_hnsw是 ES 8.12 / 8.13 才有的。如果你用 ES 7.x 或 8.0~8.8索引建不出来。另外dense_vector字段的index_options是不可变的——建好索引后想换量化方案必须 reindex。所以第一次建索引就把方案选对别想先跑起来再优化。向量检索不是替代关键词检索而是补全它的盲区。RRF 这种融合算法的精髓就是承认一件事用户的需求本来就是混合的有的用词精确有的用意模糊你的搜索引擎也得学会既认字又认意。下篇预告Day 43单体应用跑得挺好为啥要拆成微服务我们一起用 DDD 的限界上下文画出切分服务的那把刀。拆错了比不拆更惨拆对了就是企业级架构的起点。
返回列表