yangxin0
2025 年 11 月 3 日
下面给出一个**可直接落地的优化方案**,按“先易后难”的顺序:先把**延迟稳定**下来,再逐步把**平均耗时**做低。你现在的规模(单机、≈200 万文档,过滤后 1–20 万、1024 维向量、int8 HNSW 、k=200 、num_candidates=1000 )在 8.15 完全可以做到**稳定 < 300–500 ms**;出现偶发 30s ,大概率是**冷数据/段太多/合并或 I/O 抖动**叠加**不必要的计算量**导致。
---
## 一、立刻可做(无需重建索引)
### 1) 把“取多少”和“算多少”对齐
* **让 `k` 接近你实际返回的 `size`**(例如前端只需要 20 条,那就 `k=40~60`;最多 `k<=2×size`)。
现在 `k=200` 很可能远大于你最终 `size`,会放大 HNSW 探索和重排序成本。
* **把 `num_candidates` 控制在 `3–6 × k`**。先用 `3×k` 起步,再按召回调。你现在 `num_candidates=1000` 配 `k=200` 还算合理,但如果把 `k` 降到 50 ,就把 `num_candidates` 降到 200–300 ,可显著降时延。
### 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, // 3–5×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` 设为 `3–6×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` 的组合,也可以直接给出一套“场景化参数表”。