关键词:向量数据库、Milvus、HNSW、IVF、DiskANN、Filtered Search、BM25、RRF、Reranker、RAG、Kubernetes、性能压测
阅读约定:本文以一个匿名化生产案例作为主线。案例中"事故前后"的数据只描述该系统在指定流量回放、版本和硬件配置下的观测;它们不是任何向量库的通用基准。所有参数均为压测起点,发布配置必须由读者在自己的数据集、过滤分布与目标 SLO 下复测决定。
很多团队第一次做 RAG ,会把注意力集中在 Embedding 模型和 Prompt 上。
但系统真正进入生产环境以后,最先把服务拖垮的往往不是大模型,而是检索层。
数据只有几十万条时,几乎任何向量索引都能跑得不错。数据增长到几百万、几千万条以后,问题会集中爆发:
- HNSW 索引越来越大,QueryNode 内存不断上涨
- 增量写入产生越来越多 Segment,查询合并成本持续增加
- 一旦叠加部门、租户、时间范围等过滤条件,P99 延迟突然恶化
- 单纯扩大
ef 可以提高 Recall,却会直接放大 CPU 和延迟
- 只使用 Dense Vector Search,召回结果"语义上相关,但业务上不够准确"
- 上线 Reranker 后准确率提升了,GPU 推理又成为新的延迟瓶颈
- 平均延迟看起来很好,但少量慢查询已经把用户体验打穿
因此,千万级向量检索的优化,本质上不是"调一个参数"。
它是一个包含索引、过滤、分片、召回、重排序、资源隔离、容量规划和观测体系的系统工程。
本文用一个企业知识库检索系统作为主线,完整拆解一套可以真正落地的优化路径。
1. 事故现场:为什么 800 万向量把检索服务拖垮了
下面是经过脱敏的企业内部知识库案例。文档、用户和基础设施名称均已移除;规模、查询形态、故障现象与排查顺序保留,用于说明工程决策如何产生。
文档经过清洗、切分和 Embedding 后,形成约 800 万条 Chunk,每条向量维度为 768。
上线验收要求:
数据规模:8,000,000 chunks
向量维度:768
峰值请求:12,000 QPS
普通检索 P95:< 30 ms
复杂过滤检索 P99:< 80 ms
Recall@10:>= 0.95
服务可用性:99.95%
1.1 这组数据的测量口径
文中后续所有案例指标均遵循以下口径,避免把单次 SDK 调用耗时写成用户体验:
| 项目 |
案例口径 |
| 数据集 |
线上脱敏 chunk 的 embedding 与标量字段分布;不使用随机向量 |
| 查询集 |
最近 7 天日志分层抽样,覆盖无过滤、租户/部门过滤、时间范围和 ACL 组合过滤 |
| 延迟 |
API 网关收到请求至响应写出;统计预热后 30 分钟窗口的 P50/P95/P99 |
| 负载 |
以生产峰值形态回放,同时记录并发、QPS、队列等待和超时 |
| 召回 |
在相同过滤条件下,ANN TopK 与离线 exact KNN TopK 对比 |
| 排序 |
人工标注/审核后的 NDCG@K;不以 ANN Recall 代替业务相关性 |
| 版本 |
每轮记录 Milvus、ES、Embedding、Reranker、SDK、配置哈希与机器规格 |
最初只有 200 万条向量时,系统运行正常。
随着数据增长到 600 万以上,几个问题开始同时出现:
索引构建:
40 min -> 5 h+
QueryNode RSS:
约 30 GB -> 60 GB+
带部门 + 时间过滤的查询:
P99 20~30 ms -> 500~800 ms
部分 Pod:
出现 OOMKilled
检索质量:
语义相关,但精确业务关键词命中不足
这些数字是这次事故的观测快照,不是读者应期待复现的基准结果。若缺少上述测量口径,任何"从 X ms 降至 Y ms"的描述都不应进入上线决策。
如果只看单点,很容易得到几个错误结论:
"内存不够,再加机器。"
"召回不够,把 ef 从 128 调到 512。"
"过滤慢,把过滤条件放到应用层。"
"向量不准,再换一个 Embedding 模型。"
这些做法可能短期有效,却会把问题推向下一个瓶颈。
真正需要先回答的是:
一次检索请求,从进入服务到返回 TopK,到底在哪些阶段消耗了时间?
2. 先拆延迟,而不是先调参数
一个真实的 RAG 检索请求通常不是一次 Vector Search。
完整链路更接近:
Client
|
v
API Gateway
|
v
Query Normalisation
|
+--------------------------+
| |
v v
Embedding BM25
| |
v |
Vector Search |
| |
+------------+-------------+
|
v
RRF Fusion
|
v
Rerank
|
v
Context Build
|
v
LLM
如果检索阶段总预算是 60 ms,可以进一步拆成:
| 阶段 |
目标预算 |
| Query 预处理 |
1~2 ms |
| Query Embedding |
3~10 ms |
| Dense ANN Search |
8~20 ms |
| BM25 Search |
5~15 ms |
| RRF Fusion |
< 1 ms |
| Reranker |
10~30 ms |
| Context Assembly |
1~3 ms |
这里有一个非常重要的原则:
不要用"向量数据库查询耗时"代替"用户真实检索延迟"。
线上应该至少监控:
retrieval_total_latency
embedding_latency
vector_search_latency
bm25_latency
rerank_latency
filter_hit_ratio
candidate_count
recall_k
rerank_k
timeout_count
fallback_count
只有拆开以后,才能知道应该优化索引、过滤、模型,还是网络与调度。
3. HNSW 为什么经常是第一选择,但不是最终答案
HNSW 是生产环境最常见的 ANN 索引之一。
它通过多层近邻图快速缩小搜索空间。
常见参数包括:
M
efConstruction
ef
其中:
M 控制图的连接程度
efConstruction 控制建图阶段候选搜索宽度
ef 控制查询阶段搜索宽度
它们都不是"越大越好"。
更大的参数通常意味着:
更高 Recall
+
更高索引构建成本
+
更高内存占用
+
更高查询 CPU
+
更高延迟
所以生产调优不能这样写:
M = 64
efConstruction = 512
ef = 512
然后希望所有问题自动消失。
正确方式应该是建立自己的 Recall / Latency 曲线。
3.1 参数应该如何压测
假设业务目标是:
Recall@10 >= 0.95
P99 <= 50 ms
可以建立如下参数矩阵:
M_VALUES = [16, 24, 32, 48]
EF_CONSTRUCTION_VALUES = [100, 160, 240]
EF_SEARCH_VALUES = [32, 64, 96, 128, 192, 256]
对每一组配置记录:
index_build_time
index_size
query_qps
p50
p95
p99
recall@10
cpu_usage
rss_memory
最终得到的不是"最佳参数",而是 Pareto Front:
Recall
^
|
* |
* |
* |
* |
----------------------------> Latency
你真正要找的是:
在 Recall 达到业务阈值之后,延迟和资源成本最低的那一组参数。
4. 一套可直接使用的 HNSW 基准测试脚本
下面给出一个可以直接改造的测试框架。
这里不把任何 M 或 ef 写成"标准答案",而是通过压测确定。
import time
import statistics
from dataclasses import dataclass
from typing import List
@dataclass
class BenchmarkResult:
ef: int
p50_ms: float
p95_ms: float
p99_ms: float
qps: float
recall_at_10: float
def percentile(values: List[float], p: float) -> float:
if not values:
return 0.0
values = sorted(values)
index = min(int(len(values) * p), len(values) - 1)
return values[index]
def recall_at_k(actual_ids, ground_truth_ids, k=10):
actual = set(actual_ids[:k])
expected = set(ground_truth_ids[:k])
if not expected:
return 0.0
return len(actual & expected) / len(expected)
def benchmark_search(
search_func,
queries,
ground_truth,
ef: int,
top_k: int = 10,
):
latencies = []
recalls = []
start_all = time.perf_counter()
for query, expected in zip(queries, ground_truth):
start = time.perf_counter()
result_ids = search_func(
query=query,
top_k=top_k,
ef=ef,
)
elapsed_ms = (time.perf_counter() - start) * 1000
latencies.append(elapsed_ms)
recalls.append(
recall_at_k(
actual_ids=result_ids,
ground_truth_ids=expected,
k=top_k,
)
)
total_seconds = time.perf_counter() - start_all
return BenchmarkResult(
ef=ef,
p50_ms=percentile(latencies, 0.50),
p95_ms=percentile(latencies, 0.95),
p99_ms=percentile(latencies, 0.99),
qps=len(queries) / total_seconds,
recall_at_10=statistics.mean(recalls),
)
批量执行:
EF_VALUES = [32, 64, 96, 128, 192, 256]
results = []
for ef in EF_VALUES:
result = benchmark_search(
search_func=vector_search,
queries=test_queries,
ground_truth=ground_truth,
ef=ef,
top_k=10,
)
results.append(result)
print(
f"ef={ef:<3} "
f"P95={result.p95_ms:.2f}ms "
f"P99={result.p99_ms:.2f}ms "
f"Recall@10={result.recall_at_10:.4f} "
f"QPS={result.qps:.1f}"
)
最终选型逻辑可以直接自动化:
def choose_best_config(results):
candidates = [
r for r in results
if r.recall_at_10 >= 0.95
and r.p99_ms <= 50
]
if not candidates:
return None
return min(
candidates,
key=lambda r: r.p99_ms
)
这比在文章里硬写:
ef=128 最好
更接近真实生产环境。
因为 Embedding 模型、维度、数据分布、CPU、NUMA、过滤条件都会改变最终结果。
5. 过滤搜索,才是很多生产事故真正的起点
企业知识库里很少只有:
query = "Kubernetes OOM"
更常见的是:
tenant_id = 10086
department = "engineering"
doc_type IN ["runbook", "postmortem"]
publish_time >= now - 90d
permission_tags CONTAINS "platform"
最终查询类似:
语义接近 "Kubernetes OOM"
并且
属于当前租户
并且
用户有权限
并且
最近 90 天
并且
文档类型是 Runbook 或 Postmortem
这时候最危险的不是 ANN 本身。
而是:
Filter Selectivity 发生巨大变化。
6. 不要只说"预过滤"和"后过滤",先计算 Selectivity
定义:
selectivity =
过滤后候选数 / 总数据量
假设总数据量:
10,000,000
三个过滤条件:
A:
tenant_id = 1
剩余 2,000,000
selectivity = 20%
B:
tenant_id = 1
department = engineering
剩余 180,000
selectivity = 1.8%
C:
tenant_id = 1
department = engineering
doc_type = postmortem
publish_time >= 30d
剩余 8,000
selectivity = 0.08%
这三类查询不应该使用同一个策略。
8.1 一个更实用的策略路由器
from dataclasses import dataclass
from enum import Enum
class FilterStrategy(str, Enum):
ANN_WITH_FILTER = "ann_with_filter"
PREFILTER_EXACT = "prefilter_exact"
PARTITION_ROUTING = "partition_routing"
ANN_OVERFETCH_POSTFILTER = "ann_overfetch_postfilter"
@dataclass
class FilterDecision:
strategy: FilterStrategy
filtered_count: int
selectivity: float
overfetch: int = 1
def choose_filter_strategy(
total_count: int,
estimated_filtered_count: int,
top_k: int,
) -> FilterDecision:
if total_count <= 0:
raise ValueError("total_count must be positive")
selectivity = (
estimated_filtered_count / total_count
)
# 极小候选集:
# ANN 的图遍历收益可能不如直接精确计算
if estimated_filtered_count <= 50_000:
return FilterDecision(
strategy=FilterStrategy.PREFILTER_EXACT,
filtered_count=estimated_filtered_count,
selectivity=selectivity,
)
# 如果租户等字段已经完成物理分区,
# 优先缩小搜索空间
if selectivity <= 0.05:
return FilterDecision(
strategy=FilterStrategy.PARTITION_ROUTING,
filtered_count=estimated_filtered_count,
selectivity=selectivity,
)
# 宽过滤可以 ANN + 过滤
if selectivity >= 0.30:
return FilterDecision(
strategy=FilterStrategy.ANN_WITH_FILTER,
filtered_count=estimated_filtered_count,
selectivity=selectivity,
)
# 中间区域通过 over-fetch 对冲后过滤损失
overfetch = min(
20,
max(
2,
int(1 / max(selectivity, 0.01))
)
)
return FilterDecision(
strategy=FilterStrategy.ANN_OVERFETCH_POSTFILTER,
filtered_count=estimated_filtered_count,
selectivity=selectivity,
overfetch=overfetch,
)
这里的阈值同样只是初始值。
真正生产环境应该从查询日志学习:
filter fingerprint
filtered cardinality
actual latency
actual result count
recall
然后不断修正路由策略。
7. 权限过滤不能为了性能放到最后
这是知识库和企业 RAG 最容易犯的安全错误之一。
错误做法:
1. 全库向量检索
2. Top 100
3. 应用层检查 ACL
4. 删除无权限结果
这样可能造成两个问题。
第一,严格 ACL 下:
Top100
↓
只有 3 条有权限
召回质量会直接崩掉。
第二,更严重的是:
没有权限的数据已经进入了检索服务的候选路径。
对于严格隔离场景,至少应该把以下字段尽可能前置:
tenant_id
workspace_id
security_scope
如果业务允许,还可以按照租户做:
Partition
Collection
Shard
但不要为了"查询快",无限创建物理 Partition。
分区数量本身也会产生元数据和调度成本。
8. Milvus 索引配置:不要把 Demo 配置当生产配置
下面给出一种更适合工程项目的写法。
使用配置中心管理参数:
vector:
index:
type: HNSW
metric: COSINE
hnsw:
m: 24
efConstruction: 160
search:
topK: 50
ef: 128
rerank:
candidateK: 20
finalK: 5
Python 侧:
from pymilvus import MilvusClient
client = MilvusClient(
uri="http://milvus:19530"
)
index_params = client.prepare_index_params()
index_params.add_index(
field_name="embedding",
index_type="HNSW",
metric_type="COSINE",
params={
"M": 24,
"efConstruction": 160,
},
)
client.create_index(
collection_name="knowledge_base",
index_params=index_params,
)
查询:
def search_vector(
query_vector,
filter_expr: str,
top_k: int = 50,
ef: int = 128,
):
return client.search(
collection_name="knowledge_base",
data=[query_vector],
anns_field="embedding",
limit=top_k,
filter=filter_expr,
search_params={
"metric_type": "COSINE",
"params": {
"ef": ef,
},
},
output_fields=[
"doc_id",
"chunk_id",
"title",
"content",
"tenant_id",
"department",
"publish_ts",
],
)
注意:
M=24
efConstruction=160
ef=128
只是示例起点。
正式上线必须经过自己的 Recall / Latency Benchmark。
9. Dense Search 不是最终排序
单纯的 Dense Retriever 有一个结构性问题。
Embedding 会把整段文本压缩成固定维度向量。
因此它很擅长:
语义近似
同义表达
概念关联
但不一定擅长:
精确错误码
产品型号
类名
API 名
订单号
技术术语
缩写
例如用户搜索:
Kubernetes OOMKilled exit code 137
Dense Search 可能把:
"容器资源管理最佳实践"
排在:
"Pod OOMKilled 与 Exit Code 137 排查"
前面。
这不是向量数据库坏了。
而是 Dense Retrieval 本来就不是精排模型。
10. 更稳定的生产方案:Dense + Sparse + Rerank
推荐架构:
Query
|
+-----------+-----------+
| |
v v
Query Embedding BM25
| |
v v
Dense Top 50 Sparse Top 50
| |
+-----------+-----------+
|
v
RRF Fusion
|
Top 20~30
|
v
Cross Encoder
|
Top 3~5
这里每一层解决的问题不同。
Dense:
提高语义 Recall
BM25:
补强关键词、错误码、专有名词
RRF:
融合不同量纲的排名
Reranker:
真正判断 Query 与 Document 的细粒度相关性
11. 为什么推荐 RRF,而不是直接加权分数
假设 Dense 返回:
0.91
0.87
0.83
BM25 返回:
16.2
11.7
8.3
这两个 Score 不是一个量纲。
直接:
final = 0.5 * dense_score + 0.5 * bm25_score
没有稳定含义。
RRF 只看排名:
score(d) =
Σ 1 / (k + rank(d))
实现:
from collections import defaultdict
def reciprocal_rank_fusion(
ranking_lists,
k: int = 60,
):
scores = defaultdict(float)
for ranking in ranking_lists:
for rank, doc_id in enumerate(
ranking,
start=1,
):
scores[doc_id] += (
1.0 / (k + rank)
)
return sorted(
scores.items(),
key=lambda x: x[1],
reverse=True,
)
这样可以避免:
Dense Score
BM25 Score
Sparse Score
之间的归一化陷阱。
12. 一套可落地的两阶段 Retriever
下面给出完整骨架。
重点解决三个原文中经常被忽略的问题:
1. Dense 与 BM25 并行
2. RRF 之后必须回填完整 Document
3. Reranker 必须有 timeout 和 fallback
import asyncio
from dataclasses import dataclass
from typing import Optional
@dataclass
class Candidate:
doc_id: str
content: str = ""
dense_rank: Optional[int] = None
bm25_rank: Optional[int] = None
rrf_score: float = 0.0
rerank_score: Optional[float] = None
class RetrievalService:
def __init__(
self,
vector_store,
keyword_store,
document_store,
reranker,
):
self.vector_store = vector_store
self.keyword_store = keyword_store
self.document_store = document_store
self.reranker = reranker
async def retrieve(
self,
query: str,
filters: dict,
recall_k: int = 50,
rerank_k: int = 20,
final_k: int = 5,
):
dense_task = asyncio.create_task(
self.vector_store.search(
query=query,
filters=filters,
top_k=recall_k,
)
)
bm25_task = asyncio.create_task(
self.keyword_store.search(
query=query,
filters=filters,
top_k=recall_k,
)
)
dense_results, bm25_results = (
await asyncio.gather(
dense_task,
bm25_task,
)
)
fused_ids = self._rrf(
dense_results,
bm25_results,
)
candidate_ids = [
doc_id
for doc_id, _ in fused_ids[:rerank_k]
]
documents = (
await self.document_store.batch_get(
candidate_ids
)
)
candidates = [
Candidate(
doc_id=doc_id,
content=documents[doc_id]["content"],
rrf_score=score,
)
for doc_id, score
in fused_ids[:rerank_k]
if doc_id in documents
]
try:
reranked = await asyncio.wait_for(
self.reranker.rank(
query=query,
candidates=candidates,
),
timeout=0.030,
)
return reranked[:final_k]
except asyncio.TimeoutError:
# Reranker 超时不能拖垮整个 RAG
return candidates[:final_k]
@staticmethod
def _rrf(
dense_results,
bm25_results,
k: int = 60,
):
scores = {}
for rank, item in enumerate(
dense_results,
start=1,
):
scores[item.doc_id] = (
scores.get(item.doc_id, 0.0)
+ 1.0 / (k + rank)
)
for rank, item in enumerate(
bm25_results,
start=1,
):
scores[item.doc_id] = (
scores.get(item.doc_id, 0.0)
+ 1.0 / (k + rank)
)
return sorted(
scores.items(),
key=lambda x: x[1],
reverse=True,
)
相比只写"调用 Reranker API",这里多了一个关键生产设计:
Reranker 必须允许失败。
否则 GPU 服务一次抖动,就会把整个 RAG 请求链拖死。
13. Reranker 不应该吃 100 个候选
Cross Encoder 与 Bi-Encoder 最大的区别,是 Query 与 Document 会共同进入模型。
它更准,但成本更高。
如果:
每次请求 rerank 100 chunks
每个 chunk 800 tokens
QPS = 1000
推理量会迅速爆炸。
因此应该设置:
Dense TopK:50
BM25 TopK:50
RRF Candidate:20~30
Rerank:20
Final:3~5
Rerank 的最佳候选数量也应该通过实验得到。
15.1 Rerank K 的压测矩阵
RERANK_K_VALUES = [
5,
10,
20,
30,
50,
]
记录:
NDCG@5
MRR
HitRate@5
rerank_p95
rerank_p99
GPU utilisation
tokens_per_request
可能得到:
K=5
延迟:8 ms
NDCG:0.78
K=20
延迟:18 ms
NDCG:0.86
K=50
延迟:41 ms
NDCG:0.87
这时候:
20 -> 50
只有很小的质量收益,却增加一倍以上推理成本。
那 rerank_k=20 才是工程上合理的点。
14. 异步写入:Kafka 解耦,但不掩盖一致性问题
同步把一份文档写进正文库、向量库与 Elasticsearch,最危险的是部分成功。推荐以 outbox 事件作为唯一事实来源,让每个消费者按 (chunk_id, content_version) 幂等处理:
Document DB 事务 → outbox 事件 → Kafka
├─ Embedding → Milvus upsert
├─ Lexical writer → ES bulk index
└─ Content writer → 正文存储
↓
记录各路完成水位线
检索侧应仅返回正文、向量与词法索引都达到最低可见版本的 chunk,或明确标记写入处理中。这样可避免"向量已是新版、正文仍是旧版"的错误回答。Kafka 的价值是解耦、削峰、可重放和可观测,不是保证业务自动一致。
Elasticsearch 的 refresh_interval=-1 会关闭自动刷新,适合批量导入;它不能用来解决"写后立即可搜索"。少量强实时写入可评估 refresh=wait_for,并将等待成本纳入写入 SLO。
15. QueryNode 和 IndexNode 必须资源隔离
索引构建通常是:
CPU 密集
内存密集
I/O 密集
在线查询则要求:
低尾延迟
稳定 CPU
稳定内存
稳定 I/O
如果二者混在同一 Kubernetes Node Pool:
Index Build
↓
CPU 100%
↓
Query P99 飙升
推荐:
Node Pool A
QueryNode
Node Pool B
IndexNode
Node Pool C
Embedding / Reranker GPU
16. Kubernetes 资源配置不能只写 requests / limits
至少要考虑:
requests
limits
topologySpreadConstraints
PodDisruptionBudget
antiAffinity
priorityClass
local SSD
NUMA
HugePages(视引擎需求)
一个示意配置:
apiVersion: apps/v1
kind: Deployment
metadata:
name: vector-query
spec:
replicas: 6
selector:
matchLabels:
app: vector-query
template:
metadata:
labels:
app: vector-query
spec:
priorityClassName: online-critical
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: vector-query
containers:
- name: query
image: example/vector-query:1.0.0
resources:
requests:
cpu: "8"
memory: "32Gi"
limits:
cpu: "12"
memory: "40Gi"
readinessProbe:
httpGet:
path: /ready
port: 8080
periodSeconds: 5
livenessProbe:
httpGet:
path: /health
port: 8080
periodSeconds: 10
真正生产环境还应该加入:
PDB
HPA
节点亲和性
存储拓扑
滚动发布保护
结语:把优化变成可验证的工程闭环
这份案例的价值不在某一个 ef、某一种索引或某一个重排模型,而在可复测闭环:用真实日志和相同过滤条件生成真值集;以端到端 P99、Recall、NDCG、资源与写入可见性共同验收;让 Dense、BM25、正文和 ACL 使用同一版本约束;并为重排与依赖故障准备降级路径。
上线前至少完成三项检查:目标并发下重新压测参数曲线;验证复杂 ACL 查询不会少返回或越权;在写入、compaction、节点故障和重排超时同时发生时验证 P99 与降级行为。这样,向量检索才能从一次性 Demo 变成可持续运行的生产能力。这也是云栈社区持续关注的技术方向之一:让工程决策建立在可验证的数据之上,而不是拍脑袋或复制配置。
参考资料
- Milvus HNSW
- Milvus Filtered Search
- Milvus Scalar Index
- Elasticsearch refresh 参数
- Qdrant ACORN