
查询一次时间,大约 30 秒。 elasticsearch 8.15,单机,总数据量大约 200 万,filter 过滤后大约 1 万~20 万
部分索引如下
"mappings": { "properties": { "brandId": { "type": "long" }, "pageContent": { "analyzer": "store_analyzer_page", "search_analyzer": "search_analyzer_page", "type": "text" }, "pageEmbedding": { "type": "dense_vector", "dims": 1024, "index": true, "similarity": "cosine", "index_options": { "type": "int8_hnsw", "m": 16, "ef_construction": 100 } } } } 部分查询语句如下,
{ "knn": [ { "field": "pageEmbedding", "query_vector": {{embedding}}, "k": 200, "num_candidates": 1000, "boost": 1.0, "filter": [ { "bool": { "filter": [ { "term": { "brandId": { "value": {{brandId}} } } } ] } } ] } ], "query": { "bool": { "filter": [ { "term": { "brandId": { "value": {{brandId}} } } }, ], "should": [ { "match": { "pageContent": { "boost": 0.5, "query": "{{keyword}}" } } } ] } }, } 部分查询条件会 400ms 左右返回,偶尔会出现查询出现 30 秒,应该如何优化?
1 chihiro2014 13 小时 57 分钟前 机械硬盘? |
2 cxy1234 OP @chihiro2014 是的,机械盘 |
3 yangxin0 13 小时 16 分钟前 下面给出一个**可直接落地的优化方案**,按“先易后难”的顺序:先把**延迟稳定**下来,再逐步把**平均耗时**做低。你现在的规模(单机、≈200 万文档,过滤后 120 万、1024 维向量、int8 HNSW 、k=200 、num_candidates=1000 )在 8.15 完全可以做到**稳定 < 300500 ms**;出现偶发 30s ,大概率是**冷数据/段太多/合并或 I/O 抖动**叠加**不必要的计算量**导致。 --- ## 一、立刻可做(无需重建索引) ### 1) 把“取多少”和“算多少”对齐 * **让 `k` 接近你实际返回的 `size`**(例如前端只需要 20 条,那就 `k=40~60`;最多 `k<=2×size`)。 现在 `k=200` 很可能远大于你最终 `size`,会放大 HNSW 探索和重排序成本。 * **把 `num_candidates` 控制在 `36 × k`**。先用 `3×k` 起步,再按召回调。你现在 `num_candidates=1000` 配 `k=200` 还算合理,但如果把 `k` 降到 50 ,就把 `num_candidates` 降到 200300 ,可显著降时延。 ### 2) 只在一个地方做品牌过滤 * 你在 `knn.filter` 和外层 `query.bool.filter` 里**重复**了 `brandId` 过滤。 **保留 `knn.filter`**(它能让 HNSW 预过滤),外层 `query` 用来做文本匹配即可,避免引擎做两套交集计算。 ### 3) 采用“混合检索 + RRF 融合”,减少单路的计算压力 Elasticsearch 8.15 支持把 `query`( BM25 )和 `knn`(向量)并行检索,然后用 **RRF** 融合,通常比单路大 `k` 更稳更快。 **推荐查询改写:** ```json POST your_index/_search { "track_total_hits": false, "_source": ["id","brandId","title","url"], // 避免把大字段一次性拉回 "size": 40, // 例如前端展示 20 ,这里取 2×size 给 RRF "knn": [ { "field": "pageEmbedding", "query_vector": {{embedding}}, "k": 60, // ≈ 返回 size "num_candidates": 300, // 35×k "filter": [ { "term": { "brandId": {{brandId}} } } ] } ], "query": { "bool": { "filter": [ // 供文本子检索复用 { "term": { "brandId": {{brandId}} } } ], "should": [ { "match": { "pageContent": { "query": "{{keyword}}", "boost": 1.0 } } }, { "match_phrase": { "pageContent": { "query": "{{keyword}}", "slop": 2, "boost": 2.0 } } } ], "minimum_should_match": 0 } }, "rank": { "rrf": { "window_size": 200, "rank_constant": 60 } } // 融合得分,更稳 } ``` ### 4) 减少结果抓取成本 * `_source` 只保留列表页需要的字段;长文本延迟按需再取(第二跳 `mget`)。 * 若需要排序稳定,可加 `"track_total_hits": false`(默认足够,但显式关闭能避免一些统计开销)。 * 如果还要更省:`"stored_fields": ["id"]` + `"docvalue_fields"` 提取结构化字段。 ### 5) 避免“回退到精确暴力搜索” 当过滤后命中很少而 `k` 又很大时,引擎可能为“凑满 k 个结果”而对过滤集**暴力算相似度**,在 1024 维上会抖很厉害。 **做法**:把 `k` 控制在合理范围(见第 1 点),并把 `num_candidates` 设为 `36×k`,基本能避开这种回退。 --- ## 二、几分钟完成(不改语义、不重建数据) ### 6) 稳定 I/O:预热与段合并 * **强制合并段**(只读或低写入场景,最有效的稳定手段): ``` POST your_index/_forcemerge?max_num_segments=1&flush=true ``` *说明:HNSW 是“每段一个图”,段越多合并成本越大、延迟越抖。* * **预加载向量相关文件进页缓存**(避免冷启动 30s ): ```json PUT your_index/_settings { "index" : { "store.preload": ["nvd","dvd","tim","doc"] // Lucene 文件:含向量/倒排/词典等 } } ``` * 若写入不多,把 `refresh_interval` 调到 `30s` 或 `60s`,减少段生成频率;批量导入时可先 `-1`,导完再恢复并合并段。 ### 7) 用分片路由把“品牌”打散(同一品牌落同一分片) 单机也有收益:每次只打到**一个分片**,而不是全分片并行、最后再归并。 ```json PUT your_index { "settings": { "number_of_shards": 4, "number_of_replicas": 0, "routing_path": ["brandId"] }, "mappings": { ...原 mapping... } } ``` **搜索时携带 `?routing={{brandId}}`**。这需要重建索引,但对你这种“固定 brandId 过滤”的场景非常合适。 --- ## 三、需要重建(可选的结构优化,性价比很高) ### 8) 降维或换更紧凑的嵌入 * 从 **1024 维→512/384 维**(如用更紧凑的文本嵌入模型或做 PCA/投影校准)。 **检索延迟、内存/磁盘占用基本按比例下降**,而语义召回通常损失很小(需离线验证)。 ### 9) HNSW 图参数 * 你现在 `m=16, ef_cOnstruction=100`。如果索引空间允许: * 把 **`ef_construction` 提到 200**(构图多连几条边), * 或把 **`m` 提到 24**。 这能在**查询时**用更小的 `num_candidates` 达成同等召回,综合延迟更低更稳(代价是建索与磁盘略增)。 --- ## 四、为什么会偶发 30s (以及如何确认) 最常见的三个来源: 1. **冷数据**:节点重启 / 段合并后,向量图和倒排不在页缓存里,首批查询全靠磁盘 I/O → 秒级到十秒级。 * 佐证:重启或合并后第一枪慢,后续恢复正常。 * 解决:第 6 点的预加载与合并段,或做“热身查询”。 2. **段过多 + 合并/刷新频繁**:并发读时要在多个段做 knn ,再做归并,遇上后台合并抢 I/O/CPU ,长尾飙升。 * 解决:增大 `refresh_interval`、forcemerge 。 3. **计算量设置偏大**:`k`、`num_candidates` 取值与业务 `size` 不匹配,或触发“凑满 k 的暴力回退”。 * 解决:第 1 、5 点的取值策略。 **排查命令(线上也安全):** ```bash # 看段与大小 GET /your_index/_segments GET /_cat/segments/your_index?v # 看 GC / 热线程 / I/O GET /_nodes/stats/jvm,fs,indices GET /_nodes/hot_threads # 看一次慢查询的真正耗时在哪 POST /your_index/_search { "profile": true, ...你的查询... } ``` `profile` 输出里若 `Fetch phase` 占比高,多半是 `_source` 太大或网络;若 `Query phase` 某些段特别慢,通常是冷段或段太多。 --- ## 五、对你当前 mapping 的具体建议 你现在的 mapping 基本可用,针对向量字段可考虑: ```json "pageEmbedding": { "type": "dense_vector", "dims": 512, // 建议逐步验证更低维 "index": true, "similarity": "cosine", "index_options": { "type": "int8_hnsw", "m": 24, // 16→24 (可选) "ef_construction": 200 // 100→200 (可选) } } ``` > 维度下调和 HNSW 参数的调整需要**离线评测**一下召回与相关性曲线,再决定是否全量重建。 --- ## 六、落地清单(按优先级) 1. **改查询**:用上面的混合检索 + RRF ;把 `k`、`num_candidates` 与 `size` 对齐;删除重复过滤;收紧 `_source`。 2. **forcemerge & 预加载**:合并至 1 段并开启 `index.store.preload`,刷新间隔改长。 3. **观察**:用 `profile`、`_segments`、`_nodes/hot_threads` 看一次慢查询的瓶颈。 4. **路由分片**(重建时做):按 `brandId` 路由,单机也能减负。 5. **向量维度与 HNSW 参数**(评测后再决定):512/384 维 + `m=24`/`ef_cOnstruction=200`。 按以上步骤执行,通常能把你现在的 400ms 稳定进一步拉低,并消除 30s 的长尾。需要我根据你的机器配置( CPU/内存/磁盘)和返回字段再细化 `k/num_candidates/size` 的组合,也可以直接给出一套“场景化参数表”。 |
5 chihiro2014 11 小时 42 分钟前 @cxy1234 机械盘,换 ssd 比啥都立竿见影啊 |
6 softnero 11 小时 29 分钟前 ES 的向量化做的比较差,如果要求高性能最好用 VectorDB 比如 Milvus 这种 |
7 softnero 11 小时 27 分钟前 之前我们有实践,300w 的文本 embedding 后分别放在 ES 和 Vector DB 中,响应能差 10 倍左右 |
8 midsolo 8 小时 21 分钟前 “部分查询条件 400ms 左右返回”,可能走的 filesystem 或者返回数据量偏小,"偶尔查询出现 30s",大概率扫的机械硬盘。 在单机部署且是机械硬盘的情况下,ES 想提速就只能想办法把数据扔到 filesystem 中,让查询走 os cache ,可以写个后台预热的程序,定时刷。 我这也是用 ES 做的向量库,一共 2000 多万条数据,一般查询 200ms 左右就能返回响应。 |