主题
14.10-Milvus实践
要点
- 数据量超过 10 亿向量,或需要强隔离的多租户架构时,Milvus 才值得上;低于这个规模,pgvector 或 Qdrant 更快上线
- 存储计算分离——查询、写入、索引节点独立扩缩容,代价是 etcd + MinIO + Pulsar 三个外部依赖
- Collection schema 显式定义所有字段类型,没有 JSON 类型,用动态字段兜底非结构化 metadata
- 索引选型:默认 HNSW(快但费内存),内存不够换 IVF_SQ8(压缩到 1/4),有 GPU 用 GPU_IVF_FLAT
- 混合查询:标量过滤 + 向量检索在一条请求中完成,partition key 自动路由多租户数据
- 写入后必须 loadCollection 才能查询——这是和 pgvector、Qdrant 最大的行为差异
- 生产部署用 Kubernetes + Milvus Operator 或 Zilliz Cloud 托管
1. 什么时候值得上 Milvus
pgvector 和 Qdrant 都能处理百万级向量。如果你的数据量在 1000 万以下,这两者足够——pgvector 少一个组件,Qdrant 单机能跑。Milvus 的价值区间在十亿级向量,或者你需要分布式架构提供的独立扩缩容能力。
三种方案的规模阈值:
| 维度 | pgvector | Qdrant | Milvus |
|---|---|---|---|
| 数据规模 | 百万级 | 千万级 | 十亿级 |
| 部署复杂度 | 低(复用 PostgreSQL) | 中(单进程 + 可选集群) | 高(etcd + MinIO + Pulsar) |
| 扩展模型 | 受 PostgreSQL 单机限制 | 支持分布式,但运维较简单 | 各节点独立扩缩容 |
| 运维成本 | 低 | 中 | 高 |
| 适合团队 | 全栈 / 小团队 | 中型团队 | 有专职基础设施团队 |
如果不确定是否需要 Milvus,先用 pgvector 或 Qdrant。 从简单方案迁移到 Milvus 的成本,远低于维护一个你不需要的基础设施。
这一篇带你走完 Milvus 的完整流程:部署、schema 设计、索引选型、写入、查询、分区和性能调优。每一步会说清楚操作、原因和验证方式。
2. 分布式架构:为什么需要这些组件
Milvus 和 pgvector、Qdrant 最大的区别是存储计算分离。pgvector 的数据和查询都在 PostgreSQL 进程里,Qdrant 也是单进程承载全部功能。Milvus 把查询、写入、索引构建拆到不同节点,各层可以独立扩展。
┌─────────────────────────────────────────────────────────┐
│ Access Layer │
│ (SDK / REST / gRPC) │
├─────────────────────────────────────────────────────────┤
│ Coordinator Service │
│ ┌──────────┬──────────┬──────────────┐ │
│ │ Root │ Query │ Data │ │
│ │ Coord │ Coord │ Coord │ │
│ └──────────┴──────────┴──────────────┘ │
├─────────────────────────────────────────────────────────┤
│ Worker Nodes │
│ ┌──────────┬──────────┬──────────────┐ │
│ │ Query │ Data │ Index │ │
│ │ Nodes │ Nodes │ Nodes │ │
│ └──────────┴──────────┴──────────────┘ │
├─────────────────────────────────────────────────────────┤
│ Storage Layer │
│ ┌──────────┬──────────┬──────────────┐ │
│ │ etcd │ MinIO/S3 │ Pulsar/ │ │
│ │ (meta) │ (object) │ Kafka (log) │ │
│ └──────────┴──────────┴──────────────┘ │
└─────────────────────────────────────────────────────────┘三层各司其职:
- Access Layer——SDK 入口,支持 gRPC 和 REST。你的应用代码只和这一层打交道
- Coordinator Service——集群大脑。Root Coord 管 DDL 和元数据(创建 collection、管理 schema 变更);Query Coord 管查询拓扑和负载均衡(决定哪个 Query Node 处理哪些请求);Data Coord 管数据分配和 Compaction(决定数据写到哪个 Data Node,定期合并小文件)
- Worker Nodes——干活的节点。Query Node 执行向量检索——从对象存储加载索引到内存,处理查询请求。Data Node 处理写入——消费 WAL(Write-Ahead Log),把数据刷写到对象存储。Index Node 专门构建索引——索引构建是 CPU 密集型任务,单独拆出来避免影响查询延迟
- Storage Layer——三个外部依赖各有分工。etcd 存元数据(schema、索引配置、checkpoint);MinIO/S3 存向量数据和索引文件;Pulsar/Kafka 存 WAL 和变更日志
这种架构的代价:你需要运维 etcd、MinIO、Pulsar 三个外部组件。好处是查询密集就加 Query Node,写入密集就加 Data Node,不用整体扩容。
如果团队没有运维分布式系统的经验,建议先用 Zilliz Cloud(Milvus 的托管版)或从 Standalone 模式开始。
3. 部署:三种方式
Milvus 提供三种部署方式,按场景选择:
| 方式 | 适用场景 | 组件数 | 运维成本 |
|---|---|---|---|
| Docker Compose | 开发 / 测试 | 4(etcd + MinIO + Pulsar + Milvus) | 低 |
| Standalone | 本地开发 / 小规模 | 1(全部在一个进程) | 极低 |
| Kubernetes | 生产 | 完整集群 | 高 |
3.1 Docker Compose(开发环境)
Milvus 依赖 etcd、MinIO、Pulsar,Docker Compose 一次启动所有组件:
yaml
# docker-compose.yml
services:
etcd:
image: quay.io/coreos/etcd:v3.5.18
environment:
ETCD_AUTO_COMPACTION_MODE: revision
ETCD_AUTO_COMPACTION_RETENTION: "1000"
ETCD_QUOTA_BACKEND_BYTES: "4294967296"
command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd
minio:
image: minio/minio:RELEASE.2024-09-22T00-33-43Z
environment:
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
command: minio server /minio_data --console-address ":9001"
pulsar:
image: apachepulsar/pulsar:2.11.0
command: bin/pulsar standalone --no-functions-worker --no-stream-storage
milvus:
image: milvusdb/milvus:v2.5.4
depends_on:
- etcd
- minio
- pulsar
environment:
ETCD_ENDPOINTS: etcd:2379
MINIO_ADDRESS: minio:9000
PULSAR_ADDRESS: pulsar:6650
ports:
- "19530:19530" # gRPC
- "9091:9091" # metricsbash
docker compose up -d原因:Docker Compose 把四个容器放在同一个网络,Milvus 自动发现 etcd、MinIO、Pulsar 的地址。
验证:
bash
# 检查容器是否全部运行
docker compose ps
# 检查 Milvus 是否就绪
curl http://localhost:9091/healthz
# 预期:返回 "ok"3.2 Standalone(轻量开发)
如果不想跑四个容器,Standalone 模式把所有组件放在一个进程里:
bash
docker run -d --name milvus-standalone \
-p 19530:19530 \
-p 9091:9091 \
milvusdb/milvus:v2.5.4 standaloneStandalone 功能完整,但不支持分布式扩展。适合本地开发和小规模数据(百万级以下)。生产环境不要用。
3.3 生产部署
生产环境建议 Kubernetes + Milvus Operator:
bash
# 安装 Milvus Operator
kubectl apply -f https://raw.githubusercontent.com/milvus-io/milvus-operator/main/manifests/namespace.yaml
kubectl apply -f https://raw.githubusercontent.com/milvus-io/milvus-operator/main/manifests/deployment.yaml
# 创建集群
kubectl apply -f milvus-cluster.yamlMilvus Operator 自动处理故障恢复、滚动升级和扩缩容。如果不想运维 K8s 集群,用 Zilliz Cloud(Milvus 官方托管服务)——省去 etcd、MinIO、Pulsar 的全部运维工作。
4. Schema 设计:显式定义每一个字段
Milvus 的 schema 比 pgvector 和 Qdrant 严格——所有字段类型必须显式声明,没有 JSON 类型。
typescript
import { MilvusClient, DataType } from '@zilliz/milvus2-sdk-node'
const client = new MilvusClient({ address: 'localhost:19530' })
// 创建 collection
await client.createCollection({
collection_name: 'documents',
description: '文档向量集合',
fields: [
{
name: 'id',
data_type: DataType.VarChar,
is_primary_key: true,
type_params: { max_length: 64 },
},
{
name: 'document_id',
data_type: DataType.VarChar,
type_params: { max_length: 64 },
},
{
name: 'document_title',
data_type: DataType.VarChar,
type_params: { max_length: 256 },
},
{
name: 'chunk_index',
data_type: DataType.Int64,
},
{
name: 'content',
data_type: DataType.VarChar,
type_params: { max_length: 65535 },
},
{
name: 'embedding',
data_type: DataType.FloatVector,
type_params: { dim: 768 },
},
{
name: 'user_id',
data_type: DataType.VarChar,
type_params: { max_length: 64 },
},
{
name: 'tenant_id',
data_type: DataType.VarChar,
type_params: { max_length: 64 },
},
{
name: 'created_at',
data_type: DataType.Int64, // Milvus 没有原生时间类型,用毫秒时间戳
},
],
// 开启动态字段——存储未预定义的字段
enable_dynamic_field: true,
})原因:显式 schema 让 Milvus 在写入前就知道每个字段的类型和长度,可以在存储层做列式优化。这和 Qdrant 的 JSON payload 思路不同——Qdrant 灵活但查询时需要动态解析。
预期结果:collection 创建成功后,Milvus 为每个字段分配存储空间。向量字段在创建索引后才会构建索引结构。
验证:
typescript
const collections = await client.showCollections()
console.log(collections.data) // 应该看到 'documents'几个关键区别:
- 没有原生时间类型——用 Int64 存毫秒时间戳,查询时手动转换
- 没有 JSON 类型——如果 metadata 结构不固定,开启
enable_dynamic_field兜底。动态字段有性能开销,高频查询的字段还是应该显式定义 - 主键可以是 VarChar 或 Int64——VarChar 主键更灵活,但 Int64 主键在范围查询时更快
5. 索引选型:按数据规模决定
索引类型决定检索性能和资源消耗。先说结论:不确定时,先用 HNSW。
5.1 HNSW(默认推荐)
typescript
await client.createIndex({
collection_name: 'documents',
field_name: 'embedding',
index_type: 'HNSW',
metric_type: 'COSINE',
params: { M: 16, efConstruction: 256 },
})- M(每个节点的最大连接数):越大越精确,索引越大。16 是多数场景的合理起点
- efConstruction(构建时的搜索范围):越大索引质量越好,但构建越慢。256 是常用值
- 查询速度快,但内存占用高——所有向量数据都在内存中
原因:HNSW 用多层图结构组织向量,查询时从顶层向下逐层搜索,时间复杂度接近 O(log n)。不需要调太多参数就能获得好效果。
5.2 IVF_SQ8(内存受限时的降级选择)
typescript
await client.createIndex({
collection_name: 'documents',
field_name: 'embedding',
index_type: 'IVF_SQ8',
metric_type: 'COSINE',
params: { nlist: 1024 },
})每个维度从 32 bit float 压缩到 8 bit,内存占用降到 1/4,精度略有损失。nlist 是聚类中心数量,数据量大时设为 sqrt(向量数) 到 4 * sqrt(向量数) 之间。
5.3 标量字段索引
标量字段的过滤查询也需要索引,否则过滤会全表扫描:
typescript
await client.createIndex({
collection_name: 'documents',
field_name: 'tenant_id',
index_type: 'Trie', // 适合基数较低的字符串字段
})
await client.createIndex({
collection_name: 'documents',
field_name: 'created_at',
index_type: 'STL_SORT', // 适合数值和时间范围查询
})5.4 索引类型选择表
| 索引类型 | 内存占用 | 查询速度 | 精度 | 适用场景 |
|---|---|---|---|---|
| HNSW | 高 | 快 | 近似 | 默认选择,延迟敏感 |
| IVF_FLAT | 中 | 中 | 精确 | 百万级,内存适中 |
| IVF_SQ8 | 低 | 中 | 近似 | 大规模,内存有限 |
| IVF_PQ | 极低 | 中 | 近似 | 超大规模,精度要求不高 |
| GPU_IVF_FLAT | 中 | 快 | 精确 | 有 GPU 资源 |
| GPU_CAGRA | 中 | 极快 | 近似 | 有 GPU,极低延迟 |
如果 HNSW 内存不够,换 IVF_SQ8。 如果数据量超过十亿且精度要求不高,换 IVF_PQ。如果有 GPU 资源,GPU_IVF_FLAT 可以大幅加速检索。
6. 数据写入与加载
6.1 批量写入
Milvus 单次写入限制默认 100MB。大批量数据需要分批:
typescript
export async function bulkInsert(
client: MilvusClient,
collectionName: string,
chunks: Array<{
id: string
document_id: string
content: string
embedding: number[]
tenant_id: string
}>
): Promise<void> {
// 500 条一批——示例可用,生产环境根据单条大小调整
const BATCH_SIZE = 500
for (let i = 0; i < chunks.length; i += BATCH_SIZE) {
const batch = chunks.slice(i, i + BATCH_SIZE)
await client.insert({
collection_name: collectionName,
data: batch.map((chunk) => ({
id: chunk.id,
document_id: chunk.document_id,
content: chunk.content,
embedding: chunk.embedding,
tenant_id: chunk.tenant_id,
})),
})
}
}原因:分批写入避免单次请求超过 100MB 限制。如果单条向量维度很高(如 1536 维),把 BATCH_SIZE 调小到 200-300。
6.2 Upsert
Milvus 2.3+ 支持 upsert——如果主键已存在则更新,否则插入:
typescript
await client.upsert({
collection_name: 'documents',
data: [
{
id: 'doc-1-chunk-0',
document_id: 'doc-1',
content: '更新后的内容...',
embedding: newEmbedding,
tenant_id: 'tenant-456',
},
],
})6.3 写入后必须加载
Milvus 写入数据后,必须手动加载到内存才能查询——这是和 pgvector、Qdrant 最大的行为差异:
typescript
await client.loadCollection({
collection_name: 'documents',
})原因:Milvus 数据存在 MinIO/S3(对象存储),查询时需要把索引加载到 Query Node 内存。不 load 就查询会返回空结果或报错。
验证:
typescript
const info = await client.getCollectionStatistics({
collection_name: 'documents',
})
console.log(info.data.row_count) // 应该等于写入的条数生产环境注意:数据量大时不要一次加载整个 collection。按 partition 分批加载,只加载需要查询的 partition。
7. 混合查询:标量过滤 + 向量检索
Milvus 支持在一条请求中同时做标量过滤和向量检索。
7.1 基础查询
typescript
const results = await client.search({
collection_name: 'documents',
vectors: [queryVector],
limit: 5,
output_fields: ['document_id', 'content', 'chunk_index'],
// 搜索参数和索引类型对应:HNSW 用 ef,IVF 用 nprobe
params: { ef: 128 },
})预期结果:返回 5 条最相似的结果,每条包含 id、score(相似度分数)和 output_fields 中指定的字段。
7.2 带过滤的查询
typescript
const results = await client.search({
collection_name: 'documents',
vectors: [queryVector],
limit: 5,
filter: 'tenant_id == "tenant-456" && created_at >= 1704067200000',
params: { ef: 128 },
})Milvus 的过滤语法类似 SQL:
typescript
// 等于 / 不等于
'tenant_id == "tenant-456"'
'status != "archived"'
// 范围
'created_at >= 1704067200000 && created_at < 1735689600000'
// IN
'document_id in ["doc-1", "doc-2", "doc-3"]'
// LIKE(模糊匹配)
'document_title LIKE "%退款%"'
// 组合
'tenant_id == "tenant-456" && (status == "active" || status == "draft")'原因:Milvus 的混合查询先做标量过滤缩小候选集,再在候选集上做向量检索。如果过滤后候选集很小,检索速度会显著提升。
生产环境注意:过滤字段的基数影响性能。高基数字段(如 user_id)过滤效率低于低基数字段(如 status)。如果频繁按某个字段过滤,确保给它建了标量索引(参考 5.3)。
7.3 范围搜索和分页
除了 topK,Milvus 还支持范围搜索——返回所有距离在阈值内的结果:
typescript
const results = await client.search({
collection_name: 'documents',
vectors: [queryVector],
params: {
radius: 0.3, // 距离阈值
range_filter: 0.8, // 返回 score 在 [0.8, 0.3) 之间
},
})分页查询用 offset + limit:
typescript
const results = await client.search({
collection_name: 'documents',
vectors: [queryVector],
limit: 5,
offset: 10, // 跳过前 10 条
})8. 多租户:分区与 Partition Key
多租户场景需要数据隔离。Milvus 提供三种策略:
8.1 Partition Key(推荐)
设置 is_partition_key 后,Milvus 自动按字段值把数据路由到不同 partition:
typescript
await client.createCollection({
collection_name: 'documents',
fields: [
{
name: 'id',
data_type: DataType.VarChar,
is_primary_key: true,
type_params: { max_length: 64 },
},
{
name: 'embedding',
data_type: DataType.FloatVector,
type_params: { dim: 768 },
},
{
name: 'tenant_id',
data_type: DataType.VarChar,
type_params: { max_length: 64 },
is_partition_key: true, // 按 tenant_id 自动分区
},
],
})查询时 Milvus 自动路由——不需要手动指定 partition:
typescript
const results = await client.search({
collection_name: 'documents',
vectors: [queryVector],
limit: 5,
filter: 'tenant_id == "tenant-456"', // 自动路由到对应 partition
})原因:partition key 让每个租户的数据物理隔离,查询时只扫描对应 partition,避免跨租户数据泄露和性能干扰。
8.2 显式 Partition
如果需要更细粒度的控制,可以手动管理 partition:
typescript
// 创建 partition
await client.createPartition({
collection_name: 'documents',
partition_name: 'tenant_456',
})
// 写入指定 partition
await client.insert({
collection_name: 'documents',
partition_name: 'tenant_456',
data: chunks,
})
// 查询指定 partition
const results = await client.search({
collection_name: 'documents',
partition_names: ['tenant_456'],
vectors: [queryVector],
limit: 5,
})8.3 三种策略对比
| 策略 | 隔离级别 | 管理成本 | 适用场景 |
|---|---|---|---|
| Partition Key | 逻辑隔离,自动路由 | 低 | 多租户 SaaS,租户数量多 |
| 显式 Partition | 逻辑隔离,手动管理 | 中 | 需要按时间 / 业务手动分区 |
| 独立 Collection | 完全隔离 | 高 | 大客户 / 合规要求 |
大多数 SaaS 场景用 partition key。 显式 partition 适合需要按时间归档或手动控制加载策略的场景。独立 collection 只在大客户需要完全隔离时使用——每个 collection 都有独立的索引和内存开销。
9. 性能调优与运维
9.1 查询参数调优
不同索引的调优参数不同,本质都是精度和速度的权衡:
typescript
// HNSW:ef 越大越精确但越慢(默认 64)
params: { ef: 128 }
// IVF_FLAT / IVF_SQ8:nprobe 越大越精确但越慢(默认 1)
params: { nprobe: 16 }可以按环境变量动态调整——对精度要求高的场景用大值,对延迟敏感的场景用小值:
typescript
params: { ef: parseInt(process.env.HNSW_EF ?? '128') }9.2 容量估算
以 HNSW 为例,内存占用的粗估公式:
内存 ≈ 向量数 × (维度 × 4 + M × 8) 字节以 100 万条 768 维向量、M=16 为例:每条向量约 3.2 KB,总计约 3.2 GB。加上索引图的开销,实际内存在 4-5 GB 左右。
如果内存预算不够,换 IVF_SQ8——同样数据量大约 1 GB。但精度会下降,需要在真实数据上做 recall 测试。
9.3 监控
Milvus 暴露 Prometheus metrics(默认 9091 端口):
http://localhost:9091/metrics关键指标:
milvus_query_latency——查询延迟。P99 超过 100ms 需要关注milvus_insert_rate——写入速率。突然下降可能是 Data Node 瓶颈milvus_collection_count——collection 数量milvus_index_size——索引大小。和容量估算对比
9.4 健康检查
typescript
app.get('/health/milvus', async (c) => {
try {
const res = await client.checkHealth()
return c.json({
status: res.isHealthy ? 'ok' : 'degraded',
reasons: res.reasons,
})
} catch (err) {
return c.json({ status: 'error', error: String(err) }, 503)
}
})生产环境注意:除了 Milvus 本身,还要监控 etcd 的健康状态——etcd 挂掉意味着元数据不可用,整个集群无法工作。MinIO 和 Pulsar 同理。三个外部依赖中任何一个故障,Milvus 都无法正常工作。
10. 完整实现:MilvusVectorStore
生产可用的完整实现——封装 collection 初始化、写入、查询和删除:
typescript
// src/services/rag/milvus-store.ts
import { MilvusClient, DataType } from '@zilliz/milvus2-sdk-node'
interface ChunkWithEmbedding {
id: string
content: string
embedding: number[]
metadata: {
documentId: string
tenantId?: string
}
}
interface SearchResult {
id: string
score: number
content: string
metadata: { documentId: string }
}
export class MilvusVectorStore {
private client: MilvusClient
constructor() {
this.client = new MilvusClient({
address: process.env.MILVUS_ADDRESS ?? 'localhost:19530',
})
}
async initCollection(name: string, dimensions: number): Promise<void> {
const exists = await this.client.hasCollection({ collection_name: name })
if (!exists.value) {
await this.client.createCollection({
collection_name: name,
fields: [
{
name: 'id',
data_type: DataType.VarChar,
is_primary_key: true,
type_params: { max_length: 64 },
},
{
name: 'document_id',
data_type: DataType.VarChar,
type_params: { max_length: 64 },
},
{
name: 'content',
data_type: DataType.VarChar,
type_params: { max_length: 65535 },
},
{
name: 'embedding',
data_type: DataType.FloatVector,
type_params: { dim: dimensions },
},
{
name: 'tenant_id',
data_type: DataType.VarChar,
type_params: { max_length: 64 },
is_partition_key: true,
},
],
enable_dynamic_field: true,
})
await this.client.createIndex({
collection_name: name,
field_name: 'embedding',
index_type: 'HNSW',
metric_type: 'COSINE',
params: { M: 16, efConstruction: 256 },
})
// 必须加载才能查询
await this.client.loadCollection({ collection_name: name })
}
}
async upsertChunks(
collectionName: string,
chunks: ChunkWithEmbedding[]
): Promise<void> {
const BATCH_SIZE = 500
for (let i = 0; i < chunks.length; i += BATCH_SIZE) {
const batch = chunks.slice(i, i + BATCH_SIZE)
await this.client.insert({
collection_name: collectionName,
data: batch.map((chunk) => ({
id: chunk.id,
document_id: chunk.metadata.documentId,
content: chunk.content,
embedding: chunk.embedding,
tenant_id: chunk.metadata.tenantId ?? '',
})),
})
}
}
async search(
collectionName: string,
queryVector: number[],
options: { topK?: number; tenantId?: string } = {}
): Promise<SearchResult[]> {
const { topK = 5, tenantId } = options
const filter = tenantId ? `tenant_id == "${tenantId}"` : undefined
const results = await this.client.search({
collection_name: collectionName,
vectors: [queryVector],
limit: topK,
filter,
output_fields: ['document_id', 'content'],
params: { ef: 128 },
})
return results.results.map((r) => ({
id: String(r.id),
score: r.score,
content: r.content,
metadata: { documentId: r.document_id },
}))
}
async deleteDocument(
collectionName: string,
documentId: string
): Promise<void> {
await this.client.delete({
collection_name: collectionName,
filter: `document_id == "${documentId}"`,
})
}
}这个实现区分了「示例可用」和「生产可用」的边界:initCollection 做了幂等检查(hasCollection),upsertChunks 做了分批写入,search 支持 tenant 过滤。生产环境还需要补连接池管理、重试逻辑和 metrics 上报。
11. 验收清单与下一步
到这里,你应该完成了以下验证:
- [ ] Milvus 集群或 Standalone 部署成功,
/healthz返回 ok - [ ] Collection 创建成功,schema 字段类型正确
- [ ] HNSW 索引创建成功
- [ ] 批量写入数据,
getCollectionStatistics显示正确的 row_count - [ ]
loadCollection执行成功 - [ ] 基础向量查询返回正确结果
- [ ] 带过滤的混合查询正常工作
- [ ] Partition key 配置正确,多租户查询隔离
- [ ] 健康检查端点正常响应
- [ ] Prometheus metrics 可访问
Milvus 的选择判断:如果你的数据量还在百万级,回退到 pgvector(08 篇)或 Qdrant(09 篇)。Milvus 的运维成本在数据量不够大时不值得。
下一步:向量检索的准确率不只看 topK 结果。检索质量还取决于混合检索策略、重排序和 Query 改写。下一篇讲相似度检索的进阶优化。