向量检索深度解读
向量检索深度解读:原理、相似度与距离的等价关系、索引加速全解析
当你在大模型应用里问一句话,系统是如何在亿级文档中”秒级”找到最相关的那几段的?答案藏在一个词里——向量检索。本文从原理到工程实现,一次性讲透。
一、先说清楚:什么是向量检索
1.1 一句话定义
向量检索(Vector Retrieval),是指在一段高维实数向量空间中,给定一个查询向量 ,从预先建好的向量集合 中,快速找出与 “最接近”的 Top-K 个向量的过程。
1.2 为什么需要向量
传统关键词检索(BM25 等)依赖词项精确匹配:查询里有”苹果”,文档里也得有”苹果”才命中。但它解决不了语义层面的匹配:
- 查询”智能手机耗电快怎么办”,文档写的是”手机续航差如何优化”——没有词项重叠,但语义高度相关。
- 同义词、上下文理解、跨语言匹配,都是关键词检索的天花板。
向量检索的核心思路:用一段稠密向量 (常见 等)来”编码”一段文本的语义。语义越接近的文本,它们在向量空间中的距离就越近。于是”找相关文档”就变成了”找空间中最近的向量”。
1.3 典型流程
┌──────────┐ Embedding ┌──────────┐ 建索引 ┌──────────┐
│ 原始文本 │ ──────────────▶ │ 向量集合 │ ──────────────▶ │ 索引结构 │
└──────────┘ └──────────┘ └────┬─────┘
│
┌──────────┐ 检索 Top-K │
│ 查询向量 │ ◀───────────────────────┘
└──────────┘
- 离线阶段:把所有文档切块、过 Embedding 模型,得到向量集合,并建立索引。
- 在线阶段:用户 Query → 同一 Embedding 模型 → 查询向量 → 在索引里找 Top-K。
看起来简单,但里面藏着三个关键问题:怎么度量”接近”?相似度和距离怎么对等?索引怎么加速? 下面逐一拆解。
二、原理篇:怎么度量”接近”
“接近”需要一个数学度量。常见有三类:相似度、距离、还有一些变形度量。我们分别看。
2.1 余弦相似度(Cosine Similarity)
最常用于文本向量。定义为两个向量夹角的余弦值:
- 取值范围 ,越大越相似。
- 只关注方向,不关注模长。这在语义检索中合理:一段长摘要和它的核心句,方向应该一致,模长不同没关系。
- 工程关键:如果向量事先 L2 归一化(即 ),则 ,退化成一次点积。这就是为什么几乎所有的向量库(FAISS / Milvus / Qdrant…)都默认”归一化 + 内积”等价于”余弦相似度”。
2.2 点积(Dot Product / Inner Product)
- 如果向量未归一化,点积同时受模长和方向影响,是一个”模长加权”的相似度。
- 如果向量已归一化,点积 = 余弦相似度。
- 实际应用中,很多 Embedding 模型输出就直接是归一化向量(如 OpenAI
text-embedding-3-*),因此检索时直接用点积即可。
2.3 欧氏距离(L2 Distance)
- 取值范围 ,越小越接近。
- 同时考虑方向和模长。对图像、音频等模态的向量更常用。
2.4 其他度量(了解即可)
| 度量 | 适用场景 | 特点 |
|---|---|---|
| 曼哈顿距离 L1 | 稀疏/离散数据 | 对异常值更鲁棒 |
| 汉明距离 | 二值/哈希向量 | 位级比较,超快 |
| 杰卡德距离 | 集合/稀疏二值 | 集合差异度量 |
实际工程里,99% 的场景就是 余弦 / 内积 / L2 三选一。下面专门讲清楚它们的相互关系,这是很多人最容易绕晕的地方。
三、核心答疑:相似度与距离是怎么”对等”的
读者常困惑:检索系统一会儿说”相似度越大越好”,一会儿说”距离越小越好”,一会儿又说”距离和相似度可以互相转换”,到底怎么转?我们从公式严格推导。
3.1 先厘清两个概念
- 相似度(Similarity):越大越像。典型如余弦相似度、点积。
- 距离(Distance):越小越像。典型如欧氏距离、曼哈顿距离。
排序方向是反的:相似度要 降序 取 Top-K,距离要 升序 取 Top-K。
3.2 关键结论:归一化下三者等价
命题:当向量 都满足 (L2 归一化)时,“余弦相似度最大”、“点积最大”、“欧氏距离最小”三者给出完全相同的 Top-K 排序。
证明分两步。
第一步:余弦 = 点积。
所以归一化后,按余弦降序 ⇔ 按点积降序,排序结果完全一致。
第二步:点积与 L2 距离的单调关系。
展开 L2 距离的平方:
归一化时 ,代入:
也就是说:L2 距离的平方 与 点积 是严格的线性关系(斜率 ,截距 )。
- 越大 ⇔ 越小 ⇔ 越小。
而平方函数在非负区间单调递增,所以平方前后的排序也完全一致。
综上,归一化条件下:
三者给出完全相同的 Top-K。
3.3 一张图记住转换关系
余弦相似度 CosSim (归一化时) 点积 IP
↑ = ↑ =
| |
(1 - Cos)/2 ←—— 单调线性 ——→ L2² ←—— 单调线性 ——→ (归一化)
| |
L2 距离 L2 距离²
(越小越相似) (越小越相似)
记忆口诀:
- 归一化前:三种度量各有侧重,结果可能不同。
- 归一化后:三种度量数学等价,排序完全一致,只是”写法”不同。
3.4 工程上为什么要做这种转换
- 统一算法实现:很多 ANN 算法(如 HNSW)是基于”距离”设计的”近邻图”。如果用余弦,要么算法内部把向量归一化后当 L2 用,要么把相似度转成距离 喂进去。有了上面的等价关系,我们就可以放心地”用 L2 跑出余弦的结果”。
- 省存储/算力:归一化向量只需存方向,且距离比较可省去开方(直接比平方距离)。
- 跨库迁移:理解了等价性,从 FAISS 的
IndexFlatIP切到IndexFlatL2、或从 Milvus 的COSINE切到L2,归一化后结果完全一致,便于选型比较。
一句话总结:只要向量做了 L2 归一化,余弦相似度、内积、欧氏距离这三种”看起来不同”的度量,在排序意义上完全等价。这是向量检索里最容易被忽略、却最关键的一个数学事实。
3.5 补充答疑:余弦相似度是 [-1,1],为什么人脸识别产品显示 0~100%?
这是工程落地时一个非常典型的”分数映射”问题。
问题根源:余弦相似度的理论取值范围是 。但在人脸识别这类场景里,Embedding 模型输出的特征向量经过训练后,不同人脸之间的余弦相似度几乎不会出现负值——两个不同人的特征向量夹角通常落在 内,对应的余弦值在 。于是实际有效区间就被压缩成了 。
但 这个范围直接给用户看体验很差:一张陌生人脸比对结果”0.42”是什么意思?过不过阈值?所以产品侧会做一层分数映射,把它转成更直观的 0~100。常见做法有几类:
① 线性映射(最简单)
直接把 线性拉伸到 :
- 余弦 1.0 → 100 分(完全相似)
- 余弦 0.0 → 0 分(完全不相似)
简单粗暴,但有个问题:人脸识别中”同人”的余弦往往已经很高(比如 0.6 以上),而”非同人”普遍在 0.3 以下。线性映射后,同人显示 60 分、非同人显示 30 分,区分度看起来不强。
② 截断 + 线性映射(更常见)
只取实际有区分度的区间 做线性拉伸:
比如取 :
- 余弦 0.95 → 100 分
- 余弦 0.2 → 0 分
- 中间的 0.6 → 约 53 分
这样”同人/非同人”的分数差距被拉大,用户观感更清晰。很多商业 SDK 的相似度百分比就是这种做法,只是参数 是各自调出来的经验值。
③ 平滑/非线性映射
为了进一步放大区分度,有的产品会套一层非线性函数,比如:
这是一条 Sigmoid 曲线, 是阈值拐点, 控制陡峭程度。在阈值附近分数变化最剧烈,远离阈值则快速饱和到 0 或 100。好处是”过/不过”的决策感更强。
④ 直接归一化到百分比
也有产品把余弦相似度做”归一化”语义解读:认为负值在实际中不出现,于是约定 ,即”相似度 = 余弦值 × 100%“。这是①的特例。
关键认知:
| 看到的分数 | 背后的本质 |
|---|---|
| 0~100% 的相似度 | 几乎都是余弦相似度经过映射函数变换后的展示值 |
| 不同厂商的”85分” | 不可直接横向比较——映射函数、阈值、训练模型都不同 |
| 真正决定”是不是同一人” | 不是绝对分数,而是该产品阈值(threshold) 在映射后对应的值 |
所以下次看到人脸 SDK 说”相似度 92%“,要明白:它底层大概率是一个 区间的余弦相似度,经过截断+线性拉伸(或 Sigmoid)后映射到 的结果。算法层面排序没变,变的只是展示刻度。
拓展一句:这种”原始分数 → 友好分数”的映射不只用于人脸。声纹识别、指纹比对、甚至 RAG 系统里给用户展示”相关度”时,都会做类似的标定。本质都是把一个抽象的相似度数值,映射到用户能直观理解的语义刻度上。
四、索引加速篇:亿级向量怎么秒级检索
到这里都是”暴力检索”(Brute Force)的范畴:把 和库里每个 算距离,排序取 Top-K。复杂度 ,库大了根本跑不动。
真实系统靠的是 近似最近邻搜索(ANN, Approximate Nearest Neighbor):以”放弃一点点精度”换”几个数量级的速度提升”。下面讲最常见的几种索引加速方案。
4.1 暴力检索 Flat(基线)
- 精确,但 。
- 库小(< 10万)时完全够用,没必要上 ANN。
- FAISS 中对应
IndexFlatL2/IndexFlatIP。
4.2 量化压缩:PQ / IVF-PQ
乘积量化(Product Quantization, PQ) 的思路:把 维向量切成 段,每段独立做 K-Means 聚成 个簇心,用簇心编号代替原始向量。一个 1024 维 float32 向量原本占 4KB,PQ 后可能只占几十字节。
- 压缩:从存原始向量 → 存”每段属于哪个簇心编号”。
- 距离近似:查表算距离。预先算好每个簇心之间的距离表,检索时查表求和即可。
- IVF-PQ:在 PQ 之上加 IVF(倒排+聚类)粗筛,进一步加速。经典的大规模检索方案。
代价:有量化误差,召回率下降;适合”海量 + 内存敏感”场景。
4.3 倒排索引 IVF
思路:先对全部向量做 K-Means 聚成 个簇,检索时只算 所在簇(及邻近几个簇)内的距离,把 降到 量级。
- 优点:实现简单,可控性强(
nprobe调粗细)。 - 缺点:簇边界附近会漏掉邻近簇里的近邻;高维时聚类效果衰减。
- FAISS 中对应
IndexIVFFlat/IndexIVFPQ。
4.4 图索引:HNSW(当前 SOTA 之一)
HNSW(Hierarchical Navigable Small World) 是目前最常用的图索引算法,思路分层跳表 + 近邻图:
- 构建一个”小世界图”:每个向量是节点,连到它的若干近邻。
- 分多层:上层稀疏、跨度大用于”远距离跳跃”;下层稠密、精度高用于”局部细化”。
- 检索时从顶层入口点出发,逐层贪心向 靠近,底层做精细近邻搜索。
特点:
- 召回率/延迟平衡极佳,百万级库毫秒级。
- 内存占用大(要存图),不适合超大规模 + 低内存场景。
- 关键参数:
M(每个节点的连接数,影响精度和内存)、efConstruction(建图时候选数,影响建索引质量和速度)、efSearch(检索时候选数,影响召回和延迟)。 - FAISS 对应
IndexHNSWFlat,Milvus / Qdrant 默认都支持。
4.5 各索引方案对比一览
| 索引类型 | 原理 | 召回 | 延迟 | 内存 | 适用场景 |
|---|---|---|---|---|---|
| Flat | 暴力 | 100% | 高 | 中 | 小库、需精确 |
| IVF-Flat | 聚类粗筛 | 中高 | 中 | 中 | 中等规模、可调召回 |
| IVF-PQ | 聚类+量化 | 中 | 低 | 极低 | 海量、内存敏感 |
| HNSW | 分层图 | 高 | 极低 | 高 | 中大规模、低延迟 |
| ** DiskANN** | 图+磁盘 | 高 | 中 | 低 | 超大规模、磁盘存储 |
4.6 选型经验法则
- 库 < 10万:直接
Flat,省心。 - 库 10万~1000万、延迟敏感:
HNSW。 - 库 > 1000万、内存吃紧:
IVF-PQ或DiskANN。 - 同时要精确 Top-K:上重排(Rerank):先 ANN 召回 Top-100,再用精确距离重排取 Top-10。这是 RAG 系统的标配。
五、把整条链路串起来:一次向量检索的全过程
以 RAG(检索增强生成)场景为例:
-
离线建库
- 文档切片 → Embedding 模型 → 得到向量集合。
- 若用余弦:向量 L2 归一化(关键,决定后续等价性)。
- 选择索引:百万级选 HNSW,建好后落盘。
-
在线检索
- 用户 Query → 同一 Embedding 模型 → 查询向量(同样归一化)。
- 进入 HNSW,
efSearch控制”在图里探索多深”,返回 Top-100 候选。 - 粗排:HNSW 返回的就是近似的 Top-100。
- 精排 Rerank:对这 100 个候选用精确距离(点积/L2)重新打分排序,取 Top-5。
- 把 Top-5 的原文片段拼进 Prompt,交给大模型生成回答。
整条链路里,“归一化”这一步决定了你能否在余弦/内积/L2 之间自由切换;“ANN 索引 + Rerank”这一组合决定了你能否在亿级数据上做到毫秒级响应。
六、进阶答疑:向量数据库怎么在不重启的情况下动态更新?
前面讲的索引(HNSW、IVF-PQ 等)一旦”建好”,看起来像是静态的。但实际业务里向量是持续新增的——新文档入库、新用户注册人脸、新商品上架。向量数据库(如 Milvus、Qdrant、Weaviate)是怎么做到一边在线检索、一边实时写入新向量,还不用重启服务的?
这背后是一套”分层存储 + 增量合并 + 读写分离”的机制。下面以 Milvus 为例拆解。
6.1 核心矛盾:索引不好改,但数据要常更
先理解为什么”动态更新”是个难题:
- HNSW 的图结构是”贪心构建”出来的,每加一个节点要重新连边、维护分层。可以增量加,但删除很麻烦——图里一个节点连着很多邻居,硬删会破坏图的连通性。
- IVF-PQ 的簇心是离线 K-Means 算好的,新数据来了不一定落进合适的簇,重算 K-Means 代价巨大。
- PQ 量化表 也是预计算的,数据变了表就得重算。
结论:成熟的 ANN 索引几乎都是”适合批量建、不适合频繁改”的。但业务又要求实时写入。怎么破?
6.2 解法:Growing Segment + Sealed Segment + 后台合并
Milvus(以及大多数向量数据库)采用的是类似 LSM-Tree 的思路,把”写”和”查”解耦:
写入路径 合并/落盘路径
┌──────────┐ 新向量 ┌────────────┐ 攒够 ┌──────────────┐
│ 客户端 │ ────────▶ │ Growing │ ────▶ │ Sealed │
│ insert() │ │ Segment │ │ Segment │
└──────────┘ │ (内存/可写) │ │ (落盘/只读) │
└──────┬─────┘ └──────┬───────┘
│ │
│ 检索时同时查两处 │
▼ ▼
┌─────────────────────────────────┐
│ Query: 合并 Growing + Sealed │
└─────────────────────────────────┘
① Growing Segment(增长段,可写)
新写入的向量先进入一个内存中的 Growing Segment。它还没有建成正式 ANN 索引,就是一个可追加的列表(本质上是 Flat 暴力结构)。写入极快,直接 append。
- 检索时,Growing 段里用暴力计算(Flat)参与 Top-K。
- 因为这段数据量小(比如攒到一定条数或一定时间),暴力算也很快,不影响整体延迟。
② Sealed Segment(封闭段,只读 + 索引)
当 Growing Segment 攒够量(比如达到 maxInsertBatchSize 或触发 Compaction),后台任务会把它”封口”:
- 对这批数据正式建 ANN 索引(HNSW / IVF-PQ)。
- 连同索引一起落盘,变成不可变(immutable)的 Sealed Segment。
- 此后该段走索引加速检索,不再可写。
③ 检索时合并两段结果
一次查询同时扫描:
- 所有 Sealed Segment → 走 ANN 索引,快。
- 当前 Growing Segment → 走 Flat 暴力,因为数据少也快。
各自拿到 Top-K 候选后,做一次归并排序取全局 Top-K 返回。对用户完全透明,感觉就是在查一个统一的库。
这就是”不重启也能更新”的核心:写入只碰 Growing 段,索引只建在 Sealed 段上,两者分离,互不阻塞。
6.3 删除怎么办:不真删,先标记
HNSW 图里直接摘节点会破坏连通性,所以向量数据库普遍用标记删除(Soft Delete):
- 删除时只在元数据里打一个 tombstone(墓碑)标记。
- 索引结构原封不动,检索时拿到结果后再过滤掉带墓碑的向量。
- 物理清理放到后台 Compaction 阶段:重建该 Segment 时跳过被标记的向量,新 Segment 里它们就自然消失了。
代价:被删向量在物理清理前仍占内存、仍可能被 ANN 召回(只是最后被过滤)。所以删除有”延迟生效”的特性。
6.4 更新 = 删 + 插
向量本身不可变(改了向量值,索引里的位置就不对了),所以”更新一条向量”实际操作是:
- 删旧向量(打墓碑)。
- 插入新向量(进 Growing 段)。
6.5 Compaction:后台整理碎片
随着 Growing→Sealed 的不断转换,会产生很多小 Segment(碎片),影响检索效率。Milvus 会定期做 Compaction:
- 把多个小 Sealed Segment 合并成大 Segment。
- 重建合并后的索引。
- 丢弃墓碑标记的向量。
- 全程在后台进行,不影响在线读写。
这就是为什么向量库跑久了需要”compact”一下——和 LSM-Tree 数据库(如 RocksDB、HBase)的 compaction 思想一脉相承。
6.6 一张表总结动态更新机制
| 操作 | 怎么做 | 对检索影响 | 生效时机 |
|---|---|---|---|
| 插入 | 进 Growing Segment(Flat 追加) | 立即参与检索(暴力算) | 实时 |
| 删除 | 打墓碑标记,结果过滤 | 召回后被过滤 | 实时可见 |
| 更新 | 删旧 + 插新 | 同上 | 实时 |
| 封口建索引 | Growing→Sealed,建 ANN | 该段切到索引加速 | 后台异步 |
| Compaction | 合并小段、清理墓碑 | 提升检索效率 | 后台定时 |
6.7 工程上的几个认知点
- “写进去立即能查到”是有代价的:刚写入的向量走 Flat 暴力检索,Growing 段太大时会拖慢查询。所以有
maxInsertBatchSize类参数控制”何时封口建索引”。 - “删除不是立即物理消失”:墓碑在 Compaction 前一直占空间,高频率删改场景要注意监控 Segment 数量和内存。
- “索引质量在动态场景下会波动”:每个 Sealed Segment 的索引是独立建的,段越多、越小,整体召回质量越接近各段召回的”平均”。Compaction 合并后召回通常会回升。这也是为什么生产环境要定期 compact。
- Milvus 2.x 的存算分离更进一步:Query Node(检索)和 Data Node(写入/建索引)分开部署,Sealed Segment 存在共享对象存储上,Query Node 按需加载。写和查彻底解耦,扩缩容互不影响。
6.8 补充答疑:检索时是把所有向量都加载到内存吗?
这是实际使用 Milvus 时最高频的疑问之一。答案是:不一定,取决于加载策略,但绝不是”无脑全装”。
背景:为什么关心内存
向量很”吃”内存。1000 万条 1024 维 float32 向量,原始数据就是 。加上 ANN 索引结构(HNSW 的图边、IVF 的簇心表等),总占用可能更高。如果每次查询都把全量向量塞进内存,机器根本扛不住,也不经济。所以 Milvus 有一套精细的加载/释放(Load/Release)机制。
数据存储的三层位置
┌──────────────────────────────────────────────────┐
│ Query Node 内存(检索走这里,最快) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Segment A│ │ Segment B│ │ Segment C│ 已加载 │
│ └──────────┘ └──────────┘ └──────────┘ │
└──────────────────────────────────────────────────┘
▲ load ▼ release
┌──────────────────────────────────────────────────┐
│ 本地磁盘 / 共享对象存储(S3/MinIO,持久化) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Segment A│ │ Segment D│ │ Segment E│ 未加载 │
│ └──────────┘ └──────────┘ └──────────┘ │
└──────────────────────────────────────────────────┘
- 对象存储(S3/MinIO/本地磁盘):所有 Sealed Segment 的原始数据和索引都持久化在这里。这是”全量”的真实存放地,不在内存。
- Query Node 内存:只有被
load进来的 Segment(原始向量 + 索引结构)才驻留在这里,检索直接读内存。 - Growing Segment:在 Data Node 的内存里,因为还没落盘。
三种加载策略
① 全量加载(load 全 Collection)
collection.load()
调用后,Milvus 把这个 Collection 下所有 Sealed Segment 的向量 + 索引都加载到 Query Node 内存。此时检索全部走内存,最快,但内存占用 = 全量数据大小。
- 适合:数据量能装进内存的场景。
- 这就是”是不是全装到内存”的答案之一——如果你主动 load 了整个 Collection,确实就是全装了,但这是你选的,不是默认行为。
② 部分加载 / 按需加载
Milvus 2.3+ 引入了 DiskANN 索引 和部分加载能力:
- 用 DiskANN 时,只有图的”上层导航结构”驻留内存(很小),原始向量和下层图存在磁盘上,检索时按需从磁盘读取相关页(pre-fault 到 page cache)。内存占用大幅降低。
- 也可以只
load某些 Segment、release不常用的,做到”热数据在内存、冷数据在磁盘”。
③ Release(释放)
collection.release()
主动把某个 Collection 从内存里卸载,Segment 数据仍在对象存储上。适合”这批数据暂时不查了,腾内存给别的 Collection”。
那”查询时”具体怎么走
一次检索查询的内存访问路径:
- 查询路由到 Query Node。
- 已 load 的 Sealed Segment:向量 + 索引在内存里,直接走 ANN 检索。
- Growing Segment:在 Data Node 内存里,走 Flat 暴力(数据量小)。
- 未 load 的 Segment:查不到(除非用 DiskANN 走磁盘,但会慢)。
- 各段结果归并 → 全局 Top-K。
所以:“查询时把所有向量都加载到内存吗?“——只有你 load 了的部分在内存,没 load 的根本不参与查询。这是一把”显式开关”,使用者要为”加载多少”负责。
工程上的实操建议
| 场景 | 建议策略 | 理由 |
|---|---|---|
| 数据量 < 内存 | collection.load() 全量加载 | 检索最快,操作最简单 |
| 数据量 > 内存、有热点 | 部分 load + DiskANN | 热数据内存、冷数据磁盘,省内存 |
| 多 Collection 共存、轮流用 | 查时 load、不查时 release | 内存复用,避免 OOM |
| 超大规模、低延迟要求 | 扩 Query Node 节点(横向扩容) | 内存总量不够就加机器 |
关键认知
- “加载”是你显式触发的,不是查询时自动发生的。load 是”准备阶段”,查询是”使用阶段”。没 load 的 Collection 直接查会报错或查不到。
- 内存占用 ≈ 向量原始大小 + 索引结构大小。HNSW 的图边会额外占内存,PQ 量化后只占原始数据的几十分之一。
- 存算分离架构下,load 是把数据从对象存储”拉”到 Query Node 内存,多副本 load 还能分散到多个节点,单机内存不够就加 Query Node 数量。
- Milvus 没有”查询时临时全量读磁盘”的设计(不像传统数据库按页 cache)。要么 load 进内存,要么用 DiskANN 走磁盘索引。这是因为向量检索是”扫全库比距离”的密集计算,临时 IO 撑不起延迟要求。
一句话总结:Milvus 检索时不是无脑把所有向量塞进内存,而是由使用者通过
load/release控制”哪些 Segment 进内存”。进内存的走索引快检索,没进的不参与查询;数据太大装不下时,用 DiskANN 让索引分块驻留磁盘 + 按需读取,或横向扩 Query Node 节点。内存管理是向量数据库运维的核心命题。
七、常见误区 & 避坑指南
- “我用的是余弦,所以不用归一化” —— 错。余弦相似度本身归一化在分母里,但不归一化时,内积 ≠ 余弦,很多索引库默认是内积,结果会不一样。要么显式选
COSINE,要么自己归一化后选IP。 - “HNSW 召回高,所以一定准” —— 不一定。HNSW 是近似算法,
efSearch太小会漏召,需要在召回率和延迟间权衡。 - “距离小就等于相似度高” —— 只在同一度量体系下成立。拿 L2 距离和未归一化的点积比,排序可能完全不同。
- “索引建好就不用调了” —— 数据分布会漂移,定期重建/增量更新索引是必要的。
- “Embedding 模型随便换” —— 换模型 = 向量空间变了,老索引作废,必须全量重建。
八、总结
向量检索的核心问题,可以浓缩成几句话:
- 原理:把语义编码成高维向量,“找相关”变成”找空间最近邻”。
- 相似度与距离的等价:向量 L2 归一化后,余弦相似度 = 内积,且与 L2 距离呈严格线性单调关系,排序完全等价。这是工程上自由切换度量的数学基础。而产品里看到的 0~100% 相似度,几乎都是底层余弦值经过映射函数变换后的展示刻度。
- 索引加速:暴力检索扛不住规模,靠 ANN(IVF / PQ / HNSW / DiskANN 等)在”精度换速度”之间做权衡,配合 Rerank 精排保证最终质量。
- 动态更新:向量数据库靠”Growing 可写段 + Sealed 只读索引段 + 后台 Compaction”的三段式架构,把”索引不好改”转化为”攒批重建”,实现不停机实时增删改。
理解了这几点,再去看 FAISS / Milvus / Qdrant 的文档和参数,就会顺畅很多。
想深入实践?建议路径:先用 FAISS 跑通
IndexFlatL2→ 再试IndexIVFPQ→ 最后上IndexHNSWFlat,亲手调一次efSearch,感受召回率与延迟的此消彼长。理论就到此为止,剩下的都是工程手感。
评论