PUT /product-vectors { "mappings": {
"properties": {
"title": { "type": "text" },
"title_vector": {
"type": "dense_vector",
"dims": 768,
"index": true,
"similarity": "cosine"
}
}
} }
PUT /products_v1 { "settings": {
"number_of_shards": 3,
"number_of_replicas": 1,
"index.refresh_interval": "30s"
}, "mappings": {
"properties": {
"title": { "type": "text", "analyzer": "ik_max_word" },
"category": { "type": "keyword" },
"price": { "type": "scaled_float", "scaling_factor": 100 },
"on_sale": { "type": "boolean" },
"updated_at": { "type": "date" },
"title_vector": {
"type": "dense_vector",
"dims": 1024,
"index": true,
"similarity": "cosine",
"index_options": {
"type": "int8_hnsw",
"m": 16,
"ef_construction": 100
}
}
}
} }
GET /products_v1/_search { "knn": {
"field": "title_vector",
"query_vector": [0.012, -0.034, "...", 0.088],
"k": 10,
"num_candidates": 100
}, "_source": ["title", "category", "price"] }
GET /products_v1/_search { "retriever": {
"rrf": {
"retrievers": [
{
"standard": { "query": { "match": { "title": "无线充电器" } } }
},
{
"knn": {
"field": "title_vector",
"query_vector": ["..."],
"k": 20,
"num_candidates": 200
}
}
]
}
} }
向量搜索上线头一个月,集群 CPU 就没消停过。查询延迟从测试环境的几十毫秒直接飙到四百多毫秒,业务方天天在群里问是不是搜索坏了。我那阵子盯着 Kibana 的监控面板,才发现性能这事跟功能验证完全是两码事。功能阶段跑通就够了,性能阶段要扒的层次多得离谱:分片怎么切的、参数怎么给的、堆内存够不够、段合并跟不跟得上,一个环节松了都可能把延迟顶上去。这一章就把我这几周折腾出来的东西按维度整理一遍,都是我亲手调过的实打实的数字。
5.1 索引结构、分片与副本对kNN性能影响
最开始建索引的时候我图省事,直接默认 1 个主分片 1 个副本,想着数据量不大应该扛得住。数据涨到两百万条向量以后问题暴露了。单分片意味着所有 kNN 查询都挤在一个 Lucene 段集合上跑,graph 结构的 HNSW 索引在单节点上做全量 navigable 扫描,线程池队列肉眼可见地堆积。我那时候才意识到向量检索跟普通全文检索的分片策略不能照搬。
后来我按数据量重新规划成 6 主分片,副本 1。延迟下来一大截,从四百多毫秒回到一百出头。倒不是说副本本身参与查询计算,副本的价值在这儿是分摊读请求和保证高可用,真正提速的是分片让 HNSW 图分散到了多个独立的段空间里,每个分片只扫自己的那一份候选。这里有个反直觉的点我得说清楚:分片不是越多越好。我试过 12 分片,单次查询要等所有分片都返回再合并,分片越多协调节点做 merge 的开销越大,反而没比 6 分片快多少,集群整体资源倒是多吃了不少。我现在定的经验值是单分片向量数据控制在几十万到一百万条之间,具体还得看维度,维度高的话单分片容量要往下压。
副本数对 kNN 延迟的影响比较间接。我做过一轮对比,副本 0 和副本 1 的查询延迟差不多,副本 2 能再多分担一部分并发,延迟会降一点,但资源成本翻倍很不划算。生产上我一般维持 1 个副本,把并发压力交给查询端做批量合并和控制线程数。真遇到大促流量,临时加副本比加节点快。
还有一个坑是分片键的选择。用 routing 把同一个用户的向量数据固定打到同一个分片,查询时带 routing 能少扫很多分片。业务上如果查询总是限定在某个租户或者某个品类,这个优化收益非常直接。我做过一个多租户场景的对比,带 routing 的查询延迟降了将近一半。
5.2 近似kNN参数调优与召回率延迟权衡
num_candidates 这个参数是我调得最多的一个。它决定了每个分片在 HNSW 图上探索的候选节点数量,数值越大召回越准,延迟也越高。最早我不懂,直接照抄文档示例写了个 100,结果在高维向量上召回率明显偏低,用户搜出来的结果经常漏掉本该靠前的条目。把它往 200、300 提上去,召回率肉眼可见地好转。
我把这个参数从 50 到 500 挨个跑了一遍离线评估集,画了条召回率和 P99 延迟的曲线出来。那个曲线的拐点在 200 附近,再往上召回率涨得很慢,延迟却开始陡增。现在这个集群的默认值我定在 200,遇到关键业务查询放宽到 300。这个值跟数据集规模、向量维度、索引本身的 HNSW 参数都有关系,不能拿来主义。
HNSW 本身的构建参数 m 和 ef_construction 我也调过。这两个参数在映射里定完就不好改,改一次得重建索引,所以我在建模阶段就规划好。默认 m 是 16,ef_construction 是 100,对于高维密集向量,我把 m 提到 32,ef_construction 提到 200,离线召回率大概能提升三四个点,代价是索引构建时间翻了差不多一倍,索引体积也涨了三成左右。这种改动适合对召回质量极其敏感的场景,普通的业务搜索维持默认值基本够用。
有个细节特别容易忽略,就是 k 和 num_candidates 的关系。num_candidates 再小也不能小于 k,否则行为未定义。我在测试环境里手抖写过一次 k=50, num_candidates=20,Elasticsearch 直接报错,这个还是明确给了提示的,不算隐蔽问题。
5.3 内存、缓存与段合并优化
真正把向量搜索性能玩明白,绕不开 JVM 堆内存和 off-heap 的分配。HNSW 图结构默认是存在堆外内存里的,由 index.vector.allocation 之类的配置控制,跟操作系统页缓存关系密切。我一开始以为堆内存给大了就万事大吉,把 JVM 堆拉到 31G,结果向量查询反而慢了。原因是 Lucene 更喜欢用操作系统页缓存加载段文件,堆内存太大挤占了页缓存,HNSW 图没法常驻内存,每次查询都要从磁盘读。
后来我按 Elasticsearch 官方推荐把堆内存压到物理内存的一半左右,剩下的一半留给页缓存和文件系统,向量查询延迟稳定下降了三成多。这个调优思路跟传统数据库完全不一样,我刚开始还按数据库的思路调,走了一大段弯路。
段合并这块对向量搜索的影响比我想象中大。高频写入的场景下,向量段不断生成,HNSW 图会越积越多,查询时要扫的段变多,延迟跟着抖。我做了一个策略:把索引的 refresh_interval 从默认的 1 秒调成 30 秒甚至 60 秒,批量导入期间直接设成 -1,等写完再手动 refresh。这个改动对召回质量没有影响,对延迟的稳定性提升非常明显。批量写入完以后触发一次 _forcemerge,把段数量压到个位数,HNSW 图的查询效率能再上一个台阶。要注意 forcemerge 本身很吃 IO,我一般安排在业务低峰期跑。
文件系统缓存这块我补充一个经验。在 Linux 上用 vmtouch 或者 fincore 查一下热门索引的段文件有没有常驻页缓存,如果没有,考虑给索引做一次预热查询,或者直接跑一次 forcemerge 把文件重写一遍。我接手过一个集群,段文件碎片化严重,光靠配置文件调不出来,做一次完整的重写以后性能曲线彻底变平。
5.4 查询并发、过滤前置与批量检索优化
单次查询优化完以后,多线程压测来了。我最早用 20 个并发去压,延迟还算稳,加到 50 并发以后 P99 直接跳崖。原因不是向量计算本身不够快,是搜索线程池的队列被打满了,请求排队时间反而成了主要开销。search 线程池的 queue_size 和 size 得根据节点核数调整,我一般按核数的 1.5 倍配线程数,队列开到一个能短暂缓冲突发流量的数值,不要开太大,开大了排队延迟会掩盖真实问题,反而更难排查。
过滤条件的前置处理是我认为收益最大的一个优化。向量检索本身只负责找语义相近的候选,如果业务上还有硬过滤条件,比如价格区间、库存状态、租户 ID,这些条件必须走 filter 上下文,让它们在向量打分之前就把候选集砍小。我用过的一个商品搜索场景,把状态过滤前置以后,参与 kNN 计算的文档直接少了一个数量级,延迟从两百多毫秒降到四十多毫秒。这里的原理是 filter 在 Lucene 层面会被缓存成 bitset,HNSW 遍历时能直接跳过被过滤的节点,省掉大量无效计算。
批量检索方面,我给应用层的建议是把短时间内多个用户的查询聚合成一次 _msearch 请求。向量查询的固定开销在协调节点和分片通信上,合并请求能显著摊薄这部分成本。我做过一组对比,同样的 QPS,聚合批量以后集群 CPU 使用率降了大约两成,延迟也更平稳。这个改动对业务代码有侵入,需要改应用层请求组装逻辑,早期就规划进去会省很多事情。
并发控制的另一个思路是分级限流。把查询按重要程度分为关键业务和一般业务,关键业务给足并发资源,一般业务做排队和降级。我在一个内容平台做过这套方案,高峰期一般业务的向量查询自动降级为走 BM25 加缓存,核心接口始终保持低延迟。这套机制上线以后,高峰期的整体故障率降得非常明显。
最后提醒一句,性能优化永远先看监控再动手。没有慢查询日志和线程池指标做依据,所有调参都是猜。我在性能调优初期吃过这个亏,凭感觉改了一堆配置,结果问题没解决反而引入了新问题。指标驱动、单变量改动、有评估基线,这三条是我后来所有优化的铁律。
功能跑通、性能调优做完,我以为最难的阶段过去了。真正把这套向量搜索推上生产、扛住一个完整业务周期以后才发现,落地这件事比搭环境复杂得多。上线只是起点,后面跟着的是容量怎么估、指标怎么看、召回质量怎么证明、出问题怎么复盘这堆琐碎又躲不开的活。这一章我按落地顺序把踩过的坑和沉淀下来的做法整理一遍,都是能直接拿去用的东西。
6.1 容量规划与集群资源配置
容量规划这件事,我最初完全靠拍脑袋。测试环境两百万向量跑得挺顺,就按这个量级乘个三倍给生产配了资源。结果业务真实增长曲线跟预期完全不是一个形状,三个月就逼近了预估容量的上限,磁盘水位数着日子往上涨。后来我换了个估法,先摸清楚向量维度和总条数,算出裸向量占用的磁盘空间,再乘一个跟索引参数相关的膨胀系数。维度 768 的 float 向量,单条大约占 3KB,HNSW 图结构建起来以后整体索引体积能到裸数据的 1.8 到 2.2 倍。我的经验是先用这个倍数把磁盘规划出来,再留 40% 的冗余给段合并和副本。
节点配置上我走过一段弯路。一开始堆内存给得很大,向量查询反而慢,这个在性能那章提过。生产环境我现在固定按物理内存五五分,一半 JVM 堆、一半留给页缓存和 HNSW 图。节点选型上更看重内存和 IO,CPU 核数够用就行,向量检索的瓶颈很少落在计算上。我配过一组 16 核 64G 的节点带 NVMe 盘,跑千万级向量查询延迟 P99 稳定在百毫秒出头,这个配置在中等规模业务里算是一个稳妥的基线。
扩容节奏我是这么定的:磁盘使用率到 70% 就启动扩容流程,查询 P99 连续一周超过预设阈值的 80% 也触发扩容。两个信号分开看,磁盘满了是写不进去,延迟涨了是读不过来,处理方式不一样。我吃过一次只看磁盘的亏,磁盘还很宽裕,延迟已经炸了,原因是段文件碎片化和分片数据分布不均,扩容没解决问题,得先做 forcemerge 和分片重平衡。现在我的巡检清单里这两项是分开监控的。
还有一个容易漏的点是协调节点的配置。向量查询的分片并行度比普通查询高,协调节点要同时收多个分片的候选结果再做归并,内存和网络开销都不小。我现在的做法是至少配两个专用协调节点,不跟数据节点混部,大促期间协调节点扛不住的话整个集群的查询都会受影响。
6.2 性能监控指标与慢查询分析
监控这事我起步很晚,等到线上出过一次 P99 突然翻三倍的故障以后才认真搭。现在回头看,向量搜索的监控跟普通 Elasticsearch 集群不能完全共用一套面板,有几个指标是向量场景特有的。最核心的是各分片的 kNN 查询耗时分布,我按分片维度拆开看,能很快定位到是不是某个分片的数据量或者段数量异常。还有搜索线程池的队列长度和拒绝数,这两个指标一冒头基本就是并发压力上来了。
慢查询日志我配了两级阈值。一级是普通慢查询,超过 200 毫秒记一条;二级是严重慢查询,超过 800 毫秒单独告警。向量查询的慢日志里我会把查询语句、num_candidates、命中分片数和每个分片的耗时都打出来,排查的时候一眼能看出是参数给大了还是某个分片拖了后腿。这套日志帮我在一次召回异常里快速定位到了问题,业务方把 num_candidates 从 200 改成了 2000,延迟涨了十倍召回没提升多少。
JVM 监控里我重点看两块,一个是 GC 频率和耗时,另一个是堆外内存的使用趋势。HNSW 图吃的是堆外内存,堆外涨到接近物理内存上限的时候,操作系统页缓存就会被挤出去,查询延迟会以肉眼可见的方式恶化。我配了一个堆外内存使用率的告警线在 85%,越过这条线就去查是不是有索引没做 forcemerge、段文件膨胀了。
段合并的监控也不能落下。segments.count 和 merges.current 这两个指标能反映索引的健康程度,段数量在几十上百的索引,向量查询延迟一定不稳。我把热门索引的段数量告警线定在 30,超过就安排低峰期做强制合并。这套机制上线以后,那种毫无征兆的延迟抖动基本消失了。
6.3 召回质量评估与A/B测试
召回质量这件事,靠感觉判断是最不靠谱的。我最早的做法是随便搜几个词看看结果像不像,觉得差不多就上线了。后来业务方反馈说搜出来的东西“语义相关但业务不相关”,我才意识到向量搜索的评估必须要有离线数据集和明确的业务口径。现在我会跟业务一起拉一批真实查询,人工标注相关文档,形成一个几百条规模的评估集。每次调参数或者换模型都拿这个集跑一遍,用 Recall@10 和 NDCG@10 两个指标看变化。
embedding 模型换版本的时候评估尤其重要。同一份文本,不同模型的向量空间完全不一样,语义相近的判断标准也会漂移。我做过一次模型升级,离线指标涨了两个点,灰度上线以后用户点击率没什么变化,仔细分析发现新模型在长尾查询上表现变差了,只是被头部查询的提升掩盖了。这件事以后我改成按查询热度分层看指标,头部和长尾分开评估,不再只看整体平均。
A/B 测试这块我踩过流量划分的坑。一开始按用户 ID 哈希分桶,结果同一批用户在不同天被分到了不同组,指标波动大得根本没法看。改成按用户维度做稳定分桶,同一批用户始终落在同一个实验组里,指标才稳定下来。向量搜索的实验我一般看两个层次,线上看的点击率、转化率这些业务指标,线下看的召回率、延迟这些技术指标,两边对得上才认为改动是真有效的。
灰度发布的节奏我也调过几次。现在是 1% 流量、5%、20%、50% 这样阶梯往上走,每一步观察至少一天。向量搜索的回召异常有时候不会立刻反映在业务指标上,要等用户行为数据积累够了才看得出来。那种直接 50% 起步的发布方式我把出过问题,回滚都来不及,用户已经在群里截图了。
6.4 典型行业案例与落地检查清单
我手上做过几个不同的场景,向量搜索的落地形态差别挺大。商品搜索那个项目,本质是 BM25 加 kNN 的混合检索,向量负责语义召回,BM25 兜住精确关键词,两路结果做融合。这个场景里向量字段的维度不高,单条数据也小,容量压力和延迟压力都还好,难点在混合排序的权重调参上,我们前后调了一个多月才把线上效果调稳。
内容推荐那个项目情况完全不一样。文档量大、维度高、更新频繁,索引写入压力是主要矛盾。我们最后放宽了 refresh 间隔,向量数据按批更新,牺牲一点实时性换索引稳定性。那个项目的召回质量评估做得最细,因为推荐场景对语义漂移特别敏感,模型版本、索引参数、排序策略任何一项变化都要跑完整的离线评估。
知识库问答的场景又不一样。向量条数不多,但查询质量要求极高,num_candidates 我们给到了 500,宁可多耗点延迟也要保住召回。这个场景里混合检索的 BM25 部分反而弱化了,因为用户问的是自然语言长句,关键词匹配基本不起作用。
落地检查清单我整理成这么几条,每次新项目上线前过一遍:
数据层面:向量维度与模型输出一致性校验、空向量和异常值过滤、批量导入后的抽样验证。我吃过一次空向量写进索引的亏,查询时排序全乱,排查了大半天。
索引层面:映射参数固化到模板里、分片数按数据量规划好、forcemerge 策略写进运维流程、refresh 间隔按写入模式配置。
查询层面:num_candidates 有明确的默认值、过滤条件全部走 filter 上下文、批量查询接口提前压测、线程池参数按节点规格调好。
监控层面:慢查询日志双阈值配置、分片级耗时面板、堆外内存告警、段数量告警、协调节点负载监控。
质量层面:离线评估集就位、A/B 分桶规则确定、灰度阶梯和回滚方案演练过。回滚这件事我强烈建议上线前实际演练一次,真出事的时候手忙脚乱跟演练过完全是两码事。
这套清单不是一次成型的,是几个项目里被问题打出来的。每上线一个新场景我都会往清单里补两条,现在这份清单比我最初写的那个版本长了一倍。生产落地没有什么一劳永逸的配置,只有持续盯、持续调、持续记录。向量搜索尤其如此,数据和模型都在动,运维策略也得跟着动。