❝
做 RAG 系统,向量数据库是绕不开的一环。这篇文章从基础概念到生产调优,把向量数据库的设计原理、结构设计、索引选型、检索策略、参数调优讲清楚。
❞
先说结论
如果你只记住三句话:
❝
「关系库管数据,向量库管检索」------双存储、CQRS,别把所有字段都往向量库塞。
「HNSW 是 99% RAG 场景的最优索引」------别纠结了,先用它。
「混合检索 + Rerank 才是生产标配」------单靠语义或关键词都有致命盲区。
❞
结论放这了,下面聊"为什么"和"怎么做"。
一、向量数据库是什么
一句话解释
向量数据库是专门存"一串数字"并快速找到"最像的那几串"的数据库。
在 RAG 场景里:文本 → Embedding 模型 → 变成一串浮点数(向量)→ 存进去。用户提问时,问题也变成向量,然后找语义最接近的文本片段。
和关系库的概念映射
| 关系型数据库 | 向量数据库 | 说明 |
|---|---|---|
| Database | Database | 数据库实例 |
| Table | Collection | 数据集合 |
| Row | Entity / Point | 一条记录(向量 + 元数据) |
| Column | Field | 字段(向量字段或标量字段) |
| Index (B+ 树) | Vector Index (HNSW/IVF) | 底层结构完全不同 |
| Partition | Partition | 分区,减少搜索范围 |
为什么 RAG 非要用向量库?
看个对比:
go
关系库 LIKE: SELECT * FROM chunks WHERE content LIKE '%代码质量%'
→ 只能命中包含"代码质量"的文本,找不到 "提高程序可维护性的方法"
向量库 ANN: SEARCH(embedding("如何提升代码质量"), topK=5)
→ 通过语义相似度找到:
"提高程序可维护性的方法" (cosine=0.92)
"代码重构的最佳实践" (cosine=0.88)
"编写高质量 Go 代码的技巧" (cosine=0.85)
❝
LIKE 是字面匹配,ANN 是语义理解。差距一目了然。
❞
二、Collection 怎么设计
这是最实际的问题:向量库里该存哪些字段?
核心数据结构
go
// ChunkVector 向量数据库中的分片记录结构
type ChunkVector struct {
// 主键
ID string// varchar(36), 与关系库 chunk.id 一致
// 向量字段
DenseVector []float32// 稠密向量,语义检索
SparseVector []float32// 稀疏向量,关键词检索(可选)
// 标量过滤字段(检索前预过滤)
KnowledgeBaseID string// 分区键,按知识库隔离
DocumentID string// 支持按文档过滤
TenantID string// 多租户隔离
Enabled bool // 禁用标记
// 辅助返回字段
Content string// 原文内容,省去回关系库查询
Position int32// 文档内排序,用于上下文窗口扩展
}
设计原则:只冗余检索必须的字段
go
应该存入向量库:
├── id → 桥接关系库
├── vector → 核心检索能力
├── knowledge_base_id → 几乎每次检索都需要过滤
├── document_id → 按文档范围检索
├── tenant_id → 多租户安全隔离
├── enabled → 排除已禁用分片
├── content → 省去回关系库取原文
└── position → 上下文窗口扩展
不需要存入向量库:
├── token_count → 管理统计用,检索不需要
├── word_count → 同上
├── metadata (JSON) → 复杂结构,向量库处理效率低
├── created_at → 审计字段
└── updated_at → 同上
❝
向量库不是关系库的镜像,只放检索时用得上的字段。
❞
双存储架构:CQRS 思想
关系库和向量库是 「CQRS」 架构中的两侧:
-
「关系库 = Command Side」:写入、管理、事务、审计、聚合统计
-
「向量库 = Query Side」:高性能语义检索
两者通过 chunk_id 桥接。关系库是 Source of Truth(唯一真实数据源),向量库是面向检索的数据投影。
冗余标量字段的价值在于避免检索后回关系库二次查询,延迟从 15ms~30ms 降至 ~12ms。一次 IO 搞定,不用来回跑。
三、向量索引:快和准的博弈
为什么需要索引?
暴力搜索有多恐怖?100 万条 1024 维向量,需要约 10 亿次浮点运算(~100ms)。1 亿条?大概 10 秒。
❝
不建索引,等于让搜索引擎挨个翻页。
❞
向量索引通过预处理数据结构加速搜索,用少量精度换数量级的速度提升。
HNSW(首选推荐)
「HNSW(Hierarchical Navigable Small World)」 是多层级的近邻图结构,也是目前 RAG 场景最主流的索引。
go
Layer 2(最稀疏,长距离连接): ● ─────────────── ●
Layer 1(中等密度): ● ── ● ─────────── ●
Layer 0(最底层,所有节点): ● ─ ● ─ ● ─ ● ─ ● ─ ● ─ ●
「搜索过程」:从最高层出发,粗粒度定位(像看国家地图),逐层下降,精度逐步提高(城市→街区→具体位置),在 Layer 0 找到最终结果。
类比:就像导航先定省,再定市,再定街道,不会在全国地图上找门牌号。时间复杂度 O(log N)。
-
优势:查询最快、召回率 >95%、支持增量插入
-
劣势:内存占用大
IVF 系列
先用 K-Means 将向量聚成 N 个簇,查询时只搜最近的 nprobe 个簇:
| 变体 | 特点 | 适用 |
|---|---|---|
| 「IVF_FLAT」 | 簇内暴力搜索 | 百万级,需较高精度 |
| 「IVF_SQ8」 | 簇内 8bit 量化 | 百万级,节省 ~75% 内存 |
| 「IVF_PQ」 | 簇内乘积量化 | 千万级,大幅压缩内存 |
DISKANN
类似 HNSW 但图结构存磁盘,内存只放 PQ 压缩向量做路由。适合 10 亿级且内存有限的场景。
FLAT(暴力搜索)
无索引,100% 召回率,O(N) 复杂度。仅适合 < 10 万条数据或作为精度基准。
索引选型对比
| 索引 | 构建速度 | 查询速度 | 召回精度 | 内存 | 适用规模 |
|---|---|---|---|---|---|
| FLAT | 最快 | 最慢 | 100% | 低 | < 10万 |
| IVF_FLAT | 快 | 较快 | 高 | 中 | 百万级 |
| HNSW | 慢 | 「最快」 | 「很高」 | 「高」 | 千万级 |
| DISKANN | 较慢 | 快 | 高 | 低 | 「十亿级」 |
❝
选型口诀:< 100 万用 IVF_FLAT,100 万~5000 万用 HNSW(首选),> 5000 万用 DISKANN。
❞
四、标量索引:向量搜索的前置过滤器
什么是标量索引?
为非向量字段(字符串、整数、布尔值)建立的索引,在向量搜索之前先缩小范围:
go
Step 1 标量过滤: knowledge_base_id="kb_001" AND enabled=true → 100万→5万
Step 2 向量搜索: 在 5 万条上做 ANN → 返回 Top 5
❝
先筛后搜,100 万变 5 万,搜索快 20 倍。
❞
和关系库索引的区别
| 对比 | 关系库索引 | 向量库标量索引 |
|---|---|---|
| 底层 | B+ 树、Hash | Trie 树、倒排索引 |
| 操作 | =, >, <, BETWEEN, LIKE | =, >, <, IN, AND/OR |
| 目的 | 加速条件查询 | 加速向量搜索前的预过滤 |
| 独立使用 | 可以 | 通常配合向量搜索 |
原理基本一致,角色不同------关系库索引独立工作,标量索引是向量搜索的"前置门卫"。
五、距离度量:怎么定义"相似"
三种主流度量方式:
L2(欧氏距离)
空间中两点直线距离。d = √[Σ(aᵢ - bᵢ)²],值域 [0, +∞),越小越相似。同时考虑方向和长度。
Cosine(余弦相似度)
衡量两个向量方向是否一致。cos(θ) = (A·B) / (|A|×|B|),值域 [-1, 1],+1 最相似。只关心方向(语义),忽略长度(文本长短)。
❝
为什么 RAG 推荐 Cosine?因为文本 Embedding 的方向编码语义信息,长度只与文本长短有关。我们关心的是"说的是不是同一件事",不关心"文本是不是一样长"。
❞
IP(内积)
IP = Σ(aᵢ × bᵢ),值域 (-∞, +∞),越大越相似。当向量已归一化时,IP 等价于 Cosine,但省去除法运算,更快。
选型建议
| 度量 | 适用 | RAG 推荐 |
|---|---|---|
| 「Cosine」 | 文本检索(只关心语义方向) | 首选 |
| 「IP」 | 向量已归一化时(等价 Cosine 但更快) | 可选 |
| 「L2」 | 图像特征、地理位置 | 不推荐文本 |
❝
大部分 Embedding 模型输出已归一化,此时 IP 和 Cosine 效果一致。选 Cosine 不会错。
❞
六、分区策略
底层原理
分区把 Collection 的数据物理分割为多个子集,每个子集拥有独立的向量索引:
go
chunks Collection
├── Partition: kb_001 → 独立 HNSW 图(10万向量)
├── Partition: kb_002 → 独立 HNSW 图(50万向量)
└── Partition: kb_003 → 独立 HNSW 图(2万向量)
检索 kb_002: 只搜索 50 万向量的 HNSW 图,而非全部 62 万
RAG 场景怎么分?
❝
按
knowledge_base_id分区。检索天然按知识库隔离,分区利用率最高。
❞
七、稠密向量 vs 稀疏向量
稠密向量(Dense Vector)
由深度学习 Embedding 模型生成,捕捉语义信息:
go
输入: "机器学习是人工智能的一个子领域"
输出(1024维): [0.23, -0.45, 0.67, 0.12, ..., -0.01]
每一维都有非零值 → "稠密"
特征:
• 维度固定(768/1024/1536),由模型决定
• 几乎每一维都是非零值
• 每一维含义隐式,不可解释
• 擅长:同义词、上下位关系、语义类比
• 存储:1024 维 × 4 bytes = 4 KB / 条
稀疏向量(Sparse Vector)
由 BM25 或学习型模型生成,捕捉关键词匹配信息:
go
词表有 250,000 个词,输入: "机器学习是人工智能的一个子领域"
输出(只展示非零部分):
{机器:2.3, 学习:1.8, 人工智能:3.5, 领域:2.1, 子:0.8, ...}
其余 249,993 维都是 0 → "稀疏"
特征:
• 维度 = 词表大小(3万~25万),大部分为 0
• 非零维度通常只有几十个
• 每一维含义明确(第 N 维 = 第 N 个词的权重)
• 只存非零位置和权重 → ~50 × 8 bytes ≈ 400 bytes / 条
对比
| 对比 | 稠密向量 | 稀疏向量 |
|---|---|---|
| 生成方式 | 深度学习模型 | BM25 / SPLADE |
| 维度 | 768 ~ 3072 | 3万 ~ 25万 |
| 非零比例 | ~100% | < 0.1% |
| 每维含义 | 隐式不可解释 | 明确对应具体词 |
| 擅长 | 语义理解、同义词 | 精确关键词、专有名词 |
| 弱点 | 可能忽略低频专有词 | 无法理解语义等价 |
❝
稠密向量理解"你想问什么"(语义),稀疏向量精确匹配"你说了什么"(关键词)。两者组合,才是完整的检索能力。
❞
八、混合检索 + Rerank:生产标配
混合检索架构
生产级 RAG 同时利用稠密向量和稀疏向量:
go
用户 Query
│
┌──────────┴──────────┐
▼ ▼
Embedding 模型 BM25/SPLADE
│ │
▼ ▼
稠密向量检索 稀疏向量检索
(语义相似度) (关键词匹配)
│ │
└──────────┬──────────┘
▼
融合排序 (RRF)
▼
Rerank 重排序(可选)
▼
最终 Top K
融合排序:RRF 算法
「RRF(倒数排名融合)」 是最常用的融合算法,简单有效:
go
公式: RRF_score(d) = Σ 1/(k + rank_i(d)) k=60(平滑常数)
举个例子:
文档 X: 稠密排第1, 稀疏排第5 → RRF = 1/61 + 1/65 = 0.03177
文档 Y: 稠密排第3, 稀疏排第2 → RRF = 1/63 + 1/62 = 0.03200
→ Y 排前面(两边都靠前 > 一边极前一边较后)
RRF 的好处是只看排名,不看分数,不用操心两路检索分数量纲不同的问题。
另一种方式是**「加权求和」** :score = w₁ × dense_score + w₂ × sparse_score,需要先归一化分数,典型权重 0.7:0.3。
Rerank 重排序
融合后用 「Cross-Encoder」 模型对候选结果精排:
go
Embedding 模型(Bi-Encoder): 分别编码 Query 和 Chunk → 速度快,精度有限
Cross-Encoder(Reranker): 同时编码 Query+Chunk → 速度慢,精度更高
流程: 混合检索取 Top 20 → Rerank 精排 → 输出 Top 5
❝
召回是海选,Rerank 是终面。海选要快要全,终面要准要精。
❞
完整代码实现
go
// HybridSearchWithRerank 混合检索 + 重排序
func (s *SearchService) HybridSearchWithRerank(
ctx context.Context,
knowledgeBaseID string,
query string,
topK int,
) ([]*SearchResult, error) {
denseVec, err := s.embedder.EmbedDense(ctx, query)
if err != nil {
returnnil, fmt.Errorf("embed dense: %w", err)
}
sparseVec, err := s.embedder.EmbedSparse(ctx, query)
if err != nil {
returnnil, fmt.Errorf("embed sparse: %w", err)
}
// 取 4 倍候选量供 Rerank 筛选
candidates, err := s.vectorRepo.HybridSearch(
ctx, knowledgeBaseID, denseVec, sparseVec, topK*4,
)
if err != nil {
returnnil, fmt.Errorf("hybrid search: %w", err)
}
reranked, err := s.reranker.Rerank(ctx, query, candidates, topK)
if err != nil {
return candidates[:topK], nil// 降级:Rerank 挂了就用原始结果
}
return reranked, nil
}
九、HNSW 参数调优
HNSW 是推荐索引,参数怎么调?这里拆解三个关键参数。
构建参数(创建索引时设定,不可更改)
「M(每个节点最大连接数)」:
| M 值 | 图密度 | 内存 | 召回率 | 推荐场景 |
|---|---|---|---|---|
| 4~8 | 稀疏 | 小 | 较低 | 资源受限 |
| 「16」 | 适中 | 中 | 高 | 「通用推荐」 |
| 32~64 | 密集 | 大 | 很高 | 高精度要求 |
❝
M 翻倍,图内存约翻倍。
❞
「efConstruction(构建时搜索宽度)」:
新向量插入时搜索多少候选节点来选邻居。越大 → 图质量越好,构建越慢。推荐 128~512。
❝
构建只做一次,值得多花时间。
❞
搜索参数(每次查询时可调)
「ef / efSearch(动态候选列表大小)」:
这是 HNSW 搜索时维护的一个按距离排序的候选列表,ef 决定列表容量:
go
ef = 10: 搜索范围小 → ~2ms → 召回率 ~85%
ef = 64: 搜索范围中 → ~5ms → 召回率 ~95% ← RAG 推荐
ef = 256: 搜索范围大 → ~15ms → 召回率 ~99%
ef = N: 等价暴力搜索 → 召回率 100%
❝
关键规则:
ef必须 ≥topK。候选列表装不下需要的结果数量,就白搜了。
❞
搜索过程:取列表中最近的未访问节点 → 查看其邻居 → 比列表最差的更近则替换入列表 → 重复直到无法改善 → 取前 K 个。
参数推荐速查
| 场景 | M | efConstruction | ef |
|---|---|---|---|
| 原型/测试 | 8 | 64 | 32 |
| 「生产通用」 | 「16」 | 「256」 | 「64~128」 |
| 高精度要求 | 32 | 512 | 256 |
「调优思路」:
-
先用 FLAT 索引获取 100% 精确结果作为基准
-
调整 HNSW 参数,用 recall@K 衡量召回率
-
在满足延迟 SLA 的前提下最大化召回率
十、数据生命周期管理
写入流程
go
文档上传
→ [关系库] 创建 Document (status=pending)
→ [异步任务] 解析 → 切片
→ [关系库] 批量创建 Chunks
→ [Embedding] 调用模型批量向量化
→ [向量库] 批量 Upsert(每批 500 条)
→ [关系库] 更新 Document.status=completed
go
func (r *VectorRepo) UpsertChunks(ctx context.Context, chunks []*ChunkVectorData) error {
const batchSize = 500
for i := 0; i < len(chunks); i += batchSize {
end := min(i+batchSize, len(chunks))
batch := chunks[i:end]
// 构建列数据并 Upsert...
if _, err := r.client.Upsert(ctx, "chunks", "", columns...); err != nil {
return fmt.Errorf("upsert batch %d: %w", i/batchSize, err)
}
}
return nil
}
删除与更新
go
// 按文档删除
func (r *VectorRepo) DeleteByDocumentID(ctx context.Context, docID string) error {
return r.client.Delete(ctx, "chunks", "", fmt.Sprintf(`document_id == "%s"`, docID))
}
// 更新内容:需要重新向量化
// 1. 更新关系库(Source of Truth)
// 2. 重新 Embedding
// 3. Upsert 到向量库
一致性保证
关系库是 Source of Truth,写操作先写关系库,再同步向量库:
go
func (s *ChunkService) UpdateContent(ctx context.Context, chunkID, newContent string) error {
if err := s.chunkRepo.UpdateContent(ctx, chunkID, newContent); err != nil {
return fmt.Errorf("update db: %w", err)
}
newVec, err := s.embedder.Embed(ctx, newContent)
if err != nil {
_ = s.chunkRepo.MarkNeedResync(ctx, chunkID) // 标记待同步
return fmt.Errorf("re-embed: %w", err)
}
if err := s.vectorRepo.Upsert(ctx, chunkID, newVec, newContent); err != nil {
_ = s.chunkRepo.MarkNeedResync(ctx, chunkID)
return fmt.Errorf("upsert vector: %w", err)
}
returnnil
}
❝
兜底策略:定时对账任务扫描
need_resync标记的分片,重新向量化并同步。不能指望每次都一次成功,关键是有兜底。
❞
十一、向量数据库选型
| 特性 | Milvus | Qdrant | Weaviate | pgvector |
|---|---|---|---|---|
| 部署复杂度 | 高(依赖 etcd/MinIO) | 低(单二进制) | 中 | 最低(PG 插件) |
| 最大数据量 | 十亿级 | 亿级 | 亿级 | 千万级 |
| 混合检索 | 原生支持 | 原生支持 | 原生支持 | 需配合 tsvector |
| 分区/多租户 | Partition Key | Collection | Multi-tenancy | 表级别 |
| 标量过滤 | 表达式 | Payload | Filter | SQL WHERE |
| GPU 加速 | 支持 | 不支持 | 不支持 | 不支持 |
| Go SDK | 有 | 有 | 有 | 标准 SQL |
选型建议
-
「起步 / 数据量 < 1000万」 :
pgvector------一套 PG 搞定,运维最简,适合团队小、预算有限 -
「生产级 / 数据量 > 1000万」 :
Milvus(大规模首选)或Qdrant(轻量高性能) -
「全功能平台」 :
Weaviate------内置模块化能力,开箱即用
❝
如果数据量 < 500 万,pgvector 够用了,少一套系统少一份运维负担。
❞
十二、生产环境最佳实践
容量规划
go
单条向量内存估算(HNSW, M=16, 1024维):
向量数据: 1024 × 4 bytes = 4,096 bytes
HNSW 图: 2 × M × 4 bytes × layers ≈ 512 bytes
标量字段: ~200 bytes(ID + 过滤字段)
──────────────────────────────────────
合计: ~5 KB / 条
100 万条 ≈ 5 GB 内存
1000 万条 ≈ 50 GB 内存
❝
先算账再选机器。不少团队上线后才发现内存不够,已经来不及了。
❞
写入优化
-
「批量写入」:每批 500~1000 条,避免单条插入
-
「异步写入」:文档处理流水线异步执行,不阻塞用户操作
-
「写入后等待」 :Milvus 需要
Flush()确保数据持久化,LoadCollection()确保可搜索
查询优化
-
「预过滤」:利用标量索引在向量搜索前缩小范围
-
「合理设置 ef」:在延迟 SLA 内最大化召回率
-
「限制返回字段」:只返回需要的字段,减少网络传输
-
「连接池」:复用向量库连接,避免频繁建连
监控指标
| 指标 | 含义 | 告警阈值建议 |
|---|---|---|
| 查询延迟 P99 | 99% 请求的响应时间 | > 100ms |
| 召回率 | 与暴力搜索结果的重合度 | < 90% |
| 内存使用率 | 向量索引占用内存比例 | > 80% |
| 写入 QPS | 每秒写入向量数 | 根据业务基线 |
| 查询 QPS | 每秒检索请求数 | 根据业务基线 |
高可用部署
-
「Milvus」:支持多副本、分片,通过 etcd 做元数据管理
-
「Qdrant」:支持分布式部署和副本集
-
「pgvector」:依赖 PostgreSQL 自身的主从复制
十三、常见问题排查 FAQ
Q1: 检索结果不准确(召回率低)
-
检查 Embedding 模型是否适合当前语言和领域
-
提高 HNSW 的
ef参数(如从 64 提到 128) -
检查距离度量是否匹配(文本应用 Cosine,而非 L2)
-
检查分片策略是否合理(分片太大会稀释语义)
Q2: 检索速度慢
-
检查是否加载了向量索引(Milvus 需
LoadCollection) -
利用分区和标量过滤缩小搜索范围
-
降低
ef值(在可接受的召回率范围内) -
检查是否有热点分区导致负载不均
Q3: 内存不足
-
使用 IVF_SQ8 或 IVF_PQ 替代 HNSW 压缩内存
-
降低 HNSW 的 M 参数
-
考虑 DISKANN(磁盘索引)
-
清理不再需要的 Collection 或 Partition
Q4: 数据不一致(关系库和向量库不同步)
-
确保写入顺序:先关系库,再向量库
-
实现
need_resync标记 + 定时对账任务 -
关键操作添加幂等性保证(Upsert 而非 Insert)
-
添加数据校验监控(定期比对两库记录数)
Q5: 新增文档后搜索不到
-
Milvus:确认执行了
Flush()和LoadCollection() -
检查
enabled字段是否正确设置 -
确认向量化是否成功(检查异步任务状态)
-
检查分区键是否正确(错误的分区键会导致搜索不到)
附录:核心术语速查
| 术语 | 全称 | 解释 |
|---|---|---|
| ANN | Approximate Nearest Neighbor | 近似最近邻搜索,以少量精度换取速度 |
| HNSW | Hierarchical Navigable Small World | 多层级近邻图索引,查询速度最快 |
| IVF | Inverted File Index | 基于聚类的倒排文件索引 |
| PQ | Product Quantization | 乘积量化,向量压缩技术 |
| SQ | Scalar Quantization | 标量量化,将 float32 压缩为 int8 |
| RRF | Reciprocal Rank Fusion | 倒数排名融合,多路结果合并算法 |
| CQRS | Command Query Responsibility Segregation | 命令查询职责分离架构模式 |
| ef | Exploration Factor | HNSW 搜索时的候选列表大小 |
| nprobe | Number of Probes | IVF 搜索时探测的簇数量 |
| Embedding | --- | 将文本转为固定维度向量的过程/结果 |
| Cross-Encoder | --- | 同时编码 Query+Doc 的精排模型 |
| Bi-Encoder | --- | 分别编码 Query 和 Doc 的模型(即 Embedding) |
写在最后
向量数据库概念多,但核心逻辑很清晰:
-
「双存储 CQRS」:关系库管数据,向量库管检索,别搞混
-
「HNSW 首选」:99% RAG 场景够用,参数 M=16, ef=64~128 起步
-
「混合检索」:稠密 + 稀疏 + RRF 融合,别只靠语义
-
「Rerank 精排」:从 80 分到 95 分的最后一公里
-
「先算账再选型」:数据量决定你该选 pgvector 还是 Milvus