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

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

客户做搜索优化,用户输入「Nike 跑鞋」,搜索结果里跳出来的全是字面带 "Nike" 的鞋款,但用户想要的那双"耐克飞马 40 慢跑鞋"——标题里写的是"飞马系列跑步鞋",关键词命中率是0

另一个用户搜「适合马拉松的鞋子」,连一双专业跑鞋都没召回。因为「马拉松」这词在商品标题里压根没出现过,BM25 看到的是空白。

这就是传统关键词检索的死穴:它认识"字",不认识"意思"

而要解决这个,靠的不只是同义词词典——你穷举不完"跑鞋"和"慢跑鞋/竞速鞋/训练鞋"的关联。你需要的是向量——把"语义相近"这件事变成数学上能比较的距离。

今天这篇文章,就把 Elasticsearch 8.x 的dense_vector+knn查询 + 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 } } } } }

几个容易踩坑的点,老梁标出来:

  1. dims一定要和模型对齐。我用BGE-M3中文 Embedding 模型,输出维度是 1024。如果你用text-embedding-3-small是 1536,用text-embedding-ada-002是 1536,别搞错。
  1. int8_hnsw是 ES 8.12 新出的量化方案,比hnsw省一半内存,召回率损失 < 3%。内存紧张时优先选这个。
  1. similarity: cosine适合文本语义。如果做人脸/图像选dot_productl2_norm
  1. mef_construction是 HNSW 的图参数,调大召回高但写入慢。生产环境一般m=16, ef_construction=100就够用。

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 文档 Map<String, 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 核心查询 DSL

POST /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 × k
  • filter:向量检索的同时做结构化过滤(品牌、价格、库存),ES 8.x 的 kNN 是支持 filter 的,这是它比很多向量库的杀手锏

3.2 Java 客户端代码

public List<Product> searchByVector(float[] queryVector, String brand) throws IOException { SearchResponse<Product> 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 之后引入了RRF(Reciprocal Rank Fusion,倒数排名融合)算法,把"关键词检索"和"向量检索"的结果融合排序。

4.1 RRF 原理(一句话版)

每条记录在每个检索通道里都有一个排名位置 k,最终得分 =1 / (k + rank),对所有通道求和,得分最高的排第一

最终得分(doc) = Σ [ 1 / (60 + rank_i(doc)) ]

60是 RRF 的默认平滑常数(防止除零和过度倾斜)。这玩意的好处是——不需要把不同通道的分数归一化,因为它只关心排名,不关心绝对分数。

4.2 查询 DSL

POST /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 SearchResponse<Product> 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_hnsw(ES 8.13+ 的二进制量化,内存再砍 32 倍,召回损失 5% 以内)。

别一上来就 bbq,召回率敏感的场景(法律/医疗)优先保证精度。

建议 2:num_candidates是召回率和延迟的旋钮,不是越大越好

我做过压测:在一个 500 万条文档、3 节点的 ES 8.12 集群上:

num_candidates

p99 延迟

召回率

50

18 ms

78%

100

26 ms

92%

200

41 ms

97%

500

89 ms

99%

经验值:num_candidates = 5 × k,p99 延迟可控在 30ms 内,召回 92% 以上,对大多数搜索场景够用。

建议 3:先跑离线评估,别凭直觉调权重

RRF 的window_sizerank_constant怎么调?靠业务指标调,不是靠参数直觉

最朴素的做法:找 200 个真实用户查询 + 标准答案集,自动化算 NDCG@10、MRR、召回率。我后面 Day 75 讲 RAGAS 评估时会专门拆这块,今天先记下来:没有评估指标的搜索优化都是玄学


六、一个常被忽略的坑:Embedding 模型必须和 ES 版本对齐

int8_hnswbbq_hnsw是 ES 8.12 / 8.13 才有的。如果你用 ES 7.x 或 8.0~8.8,索引建不出来。

另外,dense_vector字段的index_options不可变的——建好索引后想换量化方案,必须 reindex。所以第一次建索引就把方案选对,别想"先跑起来再优化"。


向量检索不是替代关键词检索,而是补全它的盲区。RRF 这种融合算法的精髓,就是承认一件事:用户的需求本来就是混合的,有的用词精确,有的用意模糊,你的搜索引擎也得学会既认字又认意。

下篇预告(Day 43):单体应用跑得挺好,为啥要拆成微服务?我们一起用 DDD 的限界上下文,画出"切分服务"的那把刀。拆错了比不拆更惨,拆对了就是企业级架构的起点。