Chapter 01

概念地基:把地图上的名词变成精确定义

起点页给出了两个 threshold——语义→几何、精确→三角权衡。这一章把概念地图上的每个名词,逐个变成能在面试里讲清楚的精确定义。

本章你将建立的 schema

  • embedding 如何把语义变成几何坐标——为什么"相似"被编码成"距离近"。
  • cosine / 内积 / L2 三种度量,以及"归一化后内积与 cosine 排名等价"这一高频考点。
  • 向量数据库与 FAISS 这类索引库、与传统 RDBMS 的本质分界——"库 vs 数据库"那条线划在哪。

概念地图上的名词不止于此,但面试里能不能讲清楚,取决于这六个能不能给出精确定义、说出底层机制、并在追问下不崩。逐个建立。

01Embedding / 向量

模型把文本或图像映射成一串定长浮点数,几何距离近 ≈ 语义相似。

为什么需要它

字符串相等只能判断"字面一样"。"如何重置密码"和"忘记密码怎么办"字面零重叠,语义却几乎相同。要让机器算出这种相似,得先把语义搬进一个能做算术的空间——embedding 就是这次搬运的结果:一个 d 维向量,相似的输入落在相近的坐标。

底层机制(比文档深一层)。"embedding 表征语义"这句话在文档里是结论,机制在训练目标里。模型训练时收到的信号是"哪些样本该相近、哪些该远离"(对比学习 / 下一词预测引出的分布相似性)。优化过程持续把语义相近的样本往一起拉、把无关的往外推,直到几何位置稳定地编码了这种关系。所以向量里每一维并不对应某个人类可命名的属性,单看一维没有意义;有意义的是整组坐标之间的相对位置。正因为语义关系被固化成了几何关系,"语义搜索"在数学上就退化成了一件老问题:在向量集合里求最近邻。后面整章索引技术,本质都是在加速这个最近邻查询。

类比 · 含边界

把 embedding 想成给每个词发一张"语义经纬度":意思相近的词住得近,像同城邻居。边界:地理经纬度是 2 维、各维有物理含义(纬度=南北);embedding 是几百到上千维、单维不可解释,且距离度量未必是直线欧氏(常用 cosine 夹角)。类比到"远近相似"为止,别外推到"每一维代表什么"。

稠密向量 vs 稀疏向量

同样叫"向量",工程上分两族,对应两种检索哲学:

dense vs sparse
维度dense(稠密)sparse(稀疏)
维数 ~100–1000,固定 极大(~10 万 = 词表大小)
非零项 几乎全部非零 绝大多数为零,只在少数词项上有值
编码什么 压缩后的语义 词项权重(哪些词、各多重要)
典型来源 text-embedding-3 等神经模型 BM25 / SPLADE
擅长 同义、改写、跨语言的语义召回 精确词、专有名词、罕见 ID 的命中

稠密向量召得回"忘记密码 ≈ 重置密码",却会漏掉产品型号 X-2200 这种字面 token;稀疏向量恰好相反。生产里 hybrid 检索把两者结果融合,正是因为它们的失败模式互补——这条线索留给 03 章展开。

场景走查 · 一次语义召回

客服知识库有一条文档"重置账户密码的步骤"。用户搜"登不上去 怎么办"。

① embedding:同一个模型分别把文档和 query 编码成两个 768 维 dense 向量。
② 几何位置:训练阶段"登录失败 / 重置密码"这类语义被拉到了相近坐标,于是这两个向量夹角很小。
③ 退化为最近邻:检索系统不"理解"句子,只在向量集合里找离 query 向量最近的若干条——这条文档因为坐标近而排到前面。字面上 query 与文档无一字相同,但语义距离把它召回了。

embed_demo.pypython
from openai import OpenAI
client = OpenAI()

def embed(text: str) -> list[float]:
    # 输入文本,输出定长 dense 向量(这里 1536 维)
    return client.embeddings.create(
        model="text-embedding-3-small",
        input=text,
    ).data[0].embedding

v_doc   = embed("重置账户密码的步骤")
v_query = embed("登不上去 怎么办")
# v_doc 与 v_query 字面无重叠,但几何上夹角很小 —— 这正是语义被编码成坐标的结果
print(len(v_doc), len(v_query))   # 1536 1536
想一想

把一段中文文档和它的英文翻译,用同一个多语言 embedding 模型编码,两个向量的几何距离会近还是远?为什么?

展开

会很近。多语言模型在训练时见过大量平行语料,目标就是让"同一语义、不同语言"落到相近坐标——语言不是被编码的主要维度,语义才是。这也是 dense 向量能做跨语言检索的根因:用中文 query 直接召回英文文档。反例:若用的是只在英文上训练的单语模型,中英向量会落在空间不同区域,距离远、召不回。

02相似度度量

把"两个向量有多像"算成一个数:cosine 看夹角,内积含模长,L2 看直线距离。

为什么需要它

"最近邻"里的"近"必须有定义。同两个向量,用不同度量算出的"谁更近"会不同,召回排名也随之改变。度量不是细枝末节,它直接决定检索结果——而且必须匹配 embedding 模型训练时用的那种度量,否则等于用错尺子量长度。

底层机制(比文档深一层)。三种度量本质是同一个内积的不同归一化。对向量 a、b:dot product 是 Σ aᵢbᵢ,既含夹角又含两者模长;cosine 是 dot / (‖a‖·‖b‖),把模长除掉、只剩夹角的余弦;L2 距离的平方 ‖a−b‖² = ‖a‖² + ‖b‖² − 2·dot,在向量都归一化(‖a‖=‖b‖=1)时变成 2 − 2·dot。最后这条式子是关键:当所有向量都 L2-归一化到单位长,L2 越小 ⟺ dot 越大 ⟺ cosine 越大,三者给出的排名完全一致。这不是巧合,是上面代数的直接推论。

高频考点 · 归一化后 dot 与 cosine 等价

向量做 L2-归一化(除以自身范数,化为单位长)后,dot product 与 cosine 的排名完全等价。既然等价,工程上倾向直接用内积:省掉每次查询的除以范数那步,大规模下更快。Qdrant 对 Cosine 集合会在写入时自动归一化向量,内部按内积算——这就是为什么它的 cosine 不比 dot 慢。

陷阱 · pgvector 的 <#> 返回负内积

pgvector 的内积算子 <#> 返回的是负内积(-(a·b)),不是内积本身。原因在数据库一侧:PG 的索引扫描只支持升序(取最小),而"最相似"对应内积最大;取负号后,"内积最大"就变成"负内积最小",正好能被升序索引扫到。读到 ORDER BY emb <#> query 不要以为是按相似度降序——它在按负内积升序,等价于相似度降序,但数值是负的。

原点 O 猫 狗 汽车 θ 小 θ 大
图 1.1"猫"与"狗"夹角小(cosine 高、语义近),"汽车"夹角大(cosine 低)。注意:cosine 量的是从原点出发两向量的夹角,不是两个端点之间的距离——一个向量再长,只要方向一致,cosine 都是 1。
类比 · 含边界

cosine 像比较两支箭"指向哪",不管箭有多长;dot 像"指向 × 长度"的综合分;L2 像直接拿尺子量两个箭头之间的间距。边界:这个类比在二维三维成立,到高维后"直觉上的距离"会失真(维度灾难,见 02 章)——别用三维直觉去推断高维空间里点的稀疏程度。

O a b θ ‖a−b‖ = L2 cosine:只量夹角 θ dot:夹角 × 两者模长 L2:两端点直线距离 向量都归一化后 cosine 与 dot 排名等价 (L2 也给出同一排名)
图 1.2同两个向量,三种度量各量不同的东西:夹角 / 夹角×模长 / 端点距离。注意:一旦所有向量被 L2-归一化到单位长,三者给出的 top-k 排名完全一致——这就是工程上常用内积替代 cosine 来省一次除法的依据。
场景走查 · 选错度量的后果

团队用一个输出未归一化 dense 向量、且官方说明"用 cosine"的模型,却在 pgvector 里建了 L2 索引。

① 度量不匹配:该模型的语义信息编码在方向上,模长带的是噪声(如文本长度)。
② L2 受模长干扰:L2 同时吃方向和模长,于是一篇长文档因为向量模长大、端点离 query 远,被错误地判为"不相似",掉出 top-k。
③ 现象:召回里短文档系统性偏多、长文档被埋——根因不是模型差,是度量没匹配训练时的 cosine。改用 cosine(或先归一化再用内积)即解。

metrics.pypython
import numpy as np

def cosine(a, b): return a @ b / (np.linalg.norm(a) * np.linalg.norm(b))
def dot(a, b):    return a @ b
def l2(a, b):     return np.linalg.norm(a - b)

a = np.array([3.0, 0.0])
b = np.array([6.0, 0.0])   # 与 a 同方向,模长翻倍

print(cosine(a, b))        # 1.0  —— 方向相同,夹角为 0
print(dot(a, b))           # 18.0 —— 受模长放大
print(l2(a, b))            # 3.0  —— 端点有距离

# 归一化后,dot 与 cosine 一致
an, bn = a / np.linalg.norm(a), b / np.linalg.norm(b)
print(dot(an, bn))         # 1.0  == cosine(a, b)

03score / recall@k / "近似"

查询返回带分数的结果;recall@k 衡量近似结果命中真实 top-k 的比例。

为什么需要它

一旦放弃"逐个算遍所有向量"的暴力查法,结果就不再保证是真正的 top-k——会漏掉一些真近邻。"漏了多少"必须能量化,否则无法判断索引调得好不好。recall@k 就是这把尺:把近似的代价变成一个 0–1 的数,让"快"和"准"能放在同一张表上比较。

底层机制(比文档深一层)。"approximate" 不是"算得粗糙",而是"只检查向量集合的一小部分"。暴力 KNN(exact)把 query 和全部 N 个向量都算一遍距离,复杂度 O(N·d),recall 恒等于 1.0。ANN(approximate nearest neighbor)索引——图结构(HNSW)或聚类(IVF)——让查询只走访其中很小一撮候选,把 N 降到 log N 量级或某个常数桶,于是快了几个数量级;代价是真正的近邻偶尔不在被走访的那撮里,被漏掉。recall@k = (近似返回的 top-k 中,确实属于真实 top-k 的个数) / k。所以 recall 损失不是 bug,是设计上换来的、可调的代价:旋钮(如 02 章的 ef_search)开大 → 走访更多候选 → recall 升、延迟涨;开小则反之。

exact KNN vs approximate NN
维度exact KNN(暴力)approximate NN(索引)
查法算遍全部 N 个向量只走访一小撮候选
复杂度O(N·d)~O(log N) 或常数桶
recall@k恒 1.0< 1.0,可调
适用规模< 10 万向量10 万以上

规模是分水岭:10 万向量以下,暴力 exact 往往够快(现代 CPU 上几毫秒),还省掉建索引和 recall 调参的复杂度;上了这个量级,ANN 才开始物有所值。本章只建立"近似 = 可量化的召回损失"这一概念;图/量化索引到底怎么做到只走访一小撮,留给 02 章。

类比 · 含边界

exact 像把图书馆每本书都翻一遍找最像的——绝不漏,但慢。ANN 像先按分类区直奔几个书架、只翻这几架——快得多,偶尔漏掉放错架的那本。recall@k 就是"该找到的 10 本里实际找到了几本"。边界:这个类比帮你理解"为何会漏",但别据此以为 recall 低就是索引坏了——recall 是被旋钮故意调出来的取舍点,0.95 往往正是为了把延迟压进预算而主动选的。

场景走查 · 一次 recall@10 的测量

要验收一个 HNSW 索引在 100 万向量上调得够不够好。

① 建 ground truth:取 1000 条测试 query,对每条用暴力 exact 算出真实 top-10(慢没关系,离线一次性)。
② 跑近似:同样 1000 条 query 走 ANN 索引,各取 top-10。
③ 算 recall@10:逐条比对——近似的 10 条里有几条落在 exact 的 10 条内,取平均。比如平均 9.3 条命中,则 recall@10 ≈ 0.93。
④ 读数:0.93 意味着平均每次查询漏掉约 0.7 个真近邻。够不够,取决于业务;不够就调大 ef_search 重测,看 recall 与延迟一起怎么动。

想一想

一个数据集只有 5 万条向量。同事坚持要上 HNSW 索引"以保证性能"。这个决定一定对吗?

展开

不一定,多半是过度设计。5 万向量在现代 CPU 上暴力算全表通常只要几毫秒,recall 还恒为 1.0。上 HNSW 反而引入三笔成本:建图的内存与时间、为达标 recall 调 ef 的工程量、以及永远 < 1.0 的召回损失。ANN 的收益要到十万级以上、暴力开始扛不住延迟预算时才显现。"保证性能"在这个规模是个伪需求——先量一下暴力到底多慢,再决定要不要索引。

04向量数据库 vs RDBMS vs 索引库

数据库 = ANN 索引 + 持久化 + 增量 CRUD + 过滤 + 分布式 + API;少了这些就只是一个索引库。

为什么需要它

面试常问"有了 FAISS,为什么还要 Milvus / Qdrant?"——答不上来,说明没分清"索引库"和"数据库"。RDBMS 解决不了语义检索;FAISS 能算最近邻,却不是一个能跑在生产里的存储系统。中间这条分界线,正是向量数据库存在的理由。

底层机制(比文档深一层)。把三者按"能力栈"摊开看,差距不在算法而在系统工程:

  • RDBMS(MySQL/PG 原生):索引是 B-tree,只支持精确等值与范围比较,没有"按几何距离排序"的算子。它的世界里没有"相似",只有"相等 / 大于 / 小于"。(注:PG 装上 pgvector 扩展后获得向量能力,那时它就跨到了"向量数据库"一侧。)
  • FAISS 是库不是服务:它是一坨内存里的索引结构,靠 FFI(进程内函数调用)使用。代价是——无持久化(进程退出索引就没了,得自己 dump 文件);无增量 CRUD(多数索引类型近乎不可变,加删向量往往要重建);无 metadata 过滤(只认向量,不认"category=A");无分布式(单机内存放不下就到头了);无网络 API(别的语言/服务用不了)。
  • 向量数据库(Milvus/Qdrant/Weaviate…)= 在 ANN 索引之上,补齐一个存储系统该有的everything:durability(WAL + 快照,崩溃可恢复)、增量 CRUD(随时 upsert/delete,索引在线维护)、metadata filtering(向量 + 标量条件联合查)、sharding / replication(横向扩展与高可用)、HTTP/gRPC API(跨语言、跨服务调用)。

所以这条分界线一句话:FAISS 是"一个索引算法的实现",向量数据库是"把这个算法变成能 7×24 在线服务的系统"。多出来的全是数据库该干、而库不干的活。

FAISS(索引库) ANN 索引 内存 · 进程内调用 + 向量数据库 ANN 索引 持久化 WAL · 崩溃恢复 增量 CRUD 在线 upsert/delete metadata 过滤 向量 + 标量条件 分布式 分片 · 副本 HTTP / gRPC API 跨语言调用
图 1.3"库 vs 数据库"的分界:FAISS 只有左边那个索引盒,向量数据库是把它包进一个完整存储系统。注意:多出来的五块(持久化 / CRUD / 过滤 / 分布式 / API)没一个是检索算法,全是系统工程——这正是选型时"要不要专用库"真正在权衡的东西。
类比 · 含边界

FAISS 像一台引擎,向量数据库像一台整车:整车在引擎外还有油箱(持久化)、变速箱(CRUD)、仪表盘(API)、底盘(分布式)。边界:类比到"库是组件、数据库是系统"为止。别外推成"整车一定比引擎好"——若你只需在单机离线批处理里算一次最近邻,直接用引擎(FAISS)更轻;整车的价值只有在要上线、要增删改查、要扩容时才兑现。

场景走查 · 增量写入暴露分界线

一个 RAG 系统上线后,知识库每天新增几百篇文档、偶尔下架几篇。

① 若底座是 FAISS:新增向量要么攒批后重建整个索引(期间检索受影响),要么用可变索引类型但牺牲查询效率;下架文档更尴尬——多数索引不支持真删,只能标记或重建。持久化也得自己写 dump/load。
② 若底座是向量数据库:直接 upsert 新文档、delete 下架文档,索引在线增量维护,WAL 保证崩溃后不丢;这些全是内建能力。
③ 结论:恰恰是"每天增删"这种最朴素的生产需求,把 FAISS 顶到了它的设计边界外——这就是为什么有了 FAISS 还需要 Milvus/Qdrant。

05核心操作

四个动词:upsert 写入、query 检索 top-k、metadata filtering 按条件筛、delete 删除。

为什么需要它

不同产品的 API 命名五花八门,但抽象操作就这四类。抓住它们,读任何向量库的 SDK 都能立刻对号入座,不被术语差异绊住。

底层机制(比文档深一层)。四个操作各自压着一个机制细节:

  • upsert / insert:upsert = update + insert——ID 已存在则覆盖,不存在则新建。机制上"覆盖"通常不是原地改,而是写新版本 + 标记旧版本待回收(与索引的增量维护、后台 compaction 绑定)。所以幂等:同一 ID 重复 upsert 不会产生重复记录。
  • query / search:给一个 query 向量,返回距离最近的 top-k,每条带 score。底层就是 02/03 章的 ANN 查询 + 度量打分。
  • metadata filtering:除了向量,每条记录还挂标量字段(category、price、时间戳…)。过滤即"先满足标量条件、再在其中找最近邻",支持嵌套字段(a.b.c)。这步看似简单,却是生产里最难的地方——pre-filter 还是 post-filter、过滤后候选不足,全是典型失败模式,留给 03 章。
  • delete:常是软删——先标记墓碑、查询时跳过,空间由后台异步回收。所以删完立刻看存储未必变小。
场景走查 · 一条记录的生命周期

文档 id=doc-42 走完增删改查全套。

① upsert:写入 {id: "doc-42", vector: [...], metadata: {category: "billing", lang: "zh"}}。
② 编辑后再 upsert 同一 id:旧版本被覆盖,记录不重复(幂等)。
③ query + filter:用户在"billing"分类下搜问题——query 向量取 top-5,同时 metadata filtering 限定 category = "billing",doc-42 在候选内并按相似度排序返回。
④ delete:文档下架,delete(id="doc-42") 打墓碑,后续 query 不再返回它,物理空间稍后回收。

crud.pypython
# 以 Qdrant 风格为例,四类操作一一对应(其它库换个方法名而已)
from qdrant_client import QdrantClient
from qdrant_client.models import PointStruct, Filter, FieldCondition, MatchValue

c = QdrantClient(url="http://localhost:6333")

# 1) upsert:ID 存在则覆盖,幂等
c.upsert("docs", points=[
    PointStruct(id="doc-42", vector=v_doc,
                payload={"category": "billing", "lang": "zh"})
])

# 2) query + 3) metadata filtering:top-k 且只在 category=billing 内
hits = c.query_points(
    "docs", query=v_query, limit=5,
    query_filter=Filter(must=[
        FieldCondition(key="category", match=MatchValue(value="billing"))
    ]),
).points

# 4) delete:软删,后续查询不再返回
c.delete("docs", points_selector=["doc-42"])

06组织单位与术语地雷

collection ≈ 表;但 "index" 一词两义、各家术语不一,是面试与读文档的高频绊脚石。

为什么需要它

同一个概念,七家产品七个名字;更糟的是同一个词在不同产品指不同东西。术语没对齐,读文档会误解、面试会被一个名词问住。这一节把名词地雷一次扫清。

底层机制(比文档深一层)。术语混乱不是随意的,根在各家抽象层次的切法不同。最大的雷是 "index" 一词两义:

术语对照 · 同义词与歧义词
抽象概念各家叫法说明
记录的容器(≈ 表) collection(Milvus / Qdrant / Chroma / Weaviate) Weaviate 旧称 class;语义都是"一组同 schema 的向量+元数据"
"index" 的两义 ① Pinecone:index = 顶层容器(≈ 别家 collection)
② 别处:index = 底层 HNSW/IVF 结构
面试地雷:同一个词,一个指"放数据的库",一个指"加速查询的数据结构"。听到 "index" 先问是哪一义
容器内的分区 namespace(Pinecone)
partition / segment(Milvus)
Milvus 的 partition 有 1024 个上限,按高基数字段(如 user_id)分区会撞墙
挂在向量上的标量数据 payload(Qdrant)
metadata(其余多数)
同一回事:用于过滤的非向量字段
记录标识 / 相似度分 id / primary key;score 这两个跨家基本一致
面试地雷 · "index" 到底指什么

当对方问"你的 index 怎么设计的",先分清语境:在 Pinecone 语境,index 是你创建的顶层容器,等价于别家的 collection,问的是"放哪些数据、几维、什么度量";在通用语境,index 是 HNSW / IVF 这类底层数据结构,问的是"建图参数、recall/延迟取舍"。把这两义混为一谈,是后端工程师初学向量库最常露的怯。

类比 · 含边界

"index 两义"像编程里 class 这个词:在 OOP 里是"类型蓝图",在 CSS 里是"选择器"——同形不同义,靠上下文区分。边界:类比只为帮你接受"一词多义很正常、要看语境",别据此推断 Pinecone 的 index 和 HNSW 的 index 有任何实现上的关系——它们恰恰是两个抽象层次的东西。

场景走查 · 一句话被术语绊倒

面试官:"你这个 collection 用的什么 index,namespace 怎么切的?"——句子里三个名词全是地雷。

① collection:稳,≈ 表,答"一组同 schema 的向量+payload"。
② index:此处与 collection 并列出现,故指底层结构(HNSW/IVF),答建图参数与 recall 取舍——若误以为是 Pinecone 那种顶层容器,就答串了。
③ namespace:Pinecone 的分区概念;若用的是 Milvus 该叫 partition、且要提 1024 上限。答错产品的术语,会被看出没真正用过。
把三个词分别对上正确的抽象层次和产品,这道题才算稳稳接住。

自测

先合上教程,在脑子里或纸上把答案讲完整,再展开对照。讲不出来的地方,就是还没真正掌握的地方。

  1. 用一句话说清:为什么"语义搜索"在数学上等于"求最近邻"?(要点到 embedding 训练把什么编码成了什么)
  2. 向量全部 L2-归一化之后,cosine 与 dot product 给出的 top-k 排名是什么关系?为什么工程上倾向用 dot?
  3. recall@k = 0.9 具体是什么意思?它是越高越好、必须追到 1.0 吗?
  4. (设计题)给你一个每天新增/下架文档的 RAG 知识库,要在 FAISS 和一个向量数据库之间选底座。说出决定性的那一两个能力差异,并给出选择。
参考答案

1. embedding 模型训练时把"语义相近"编码成"几何坐标相近"——相似的输入被优化到相邻位置。于是判断"哪条最相似"就等于"哪个向量离 query 向量最近",搜索退化为最近邻查询。

2. 排名完全等价。因为归一化后 dot = cosine(分母 ‖a‖‖b‖ 都是 1)。工程上用 dot 是为省掉每次查询除以范数那一步,大规模下更快;Qdrant 对 cosine 集合就是写入时先归一化、内部按 dot 算。

3. 近似检索返回的 top-k 里,平均有 90% 确实属于真实 top-k(暴力算出的那批);即平均每 10 个结果漏掉 1 个真近邻。不必追到 1.0——recall 是用延迟/内存换来的可调取舍点,0.9 往往正是为压住延迟预算主动选的;追 1.0 要付不成比例的延迟代价。

4. 决定性差异是增量 CRUD 与持久化:FAISS 多数索引近乎不可变,每天增删要重建索引、且要自己管 dump/load;向量数据库内建在线 upsert/delete + WAL 崩溃恢复。"每天增删"这个需求直接把 FAISS 顶出设计边界,应选向量数据库。(若是一次性离线批处理、不增删,则 FAISS 更轻。)

挑战 · 刚好够不着

给定一个把向量都归一化到单位长的 embedding 模型,cosine 索引和 dot 索引在召回结果上有何不同?在性能上呢?

提示

从 ‖a‖=‖b‖=1 时 cosine = dot 这条代数出发,想"召回结果"问的是排名——排名只依赖打分的相对大小。再想"性能":cosine 比 dot 每次查询多算了什么?有的库(如 Qdrant)对 cosine 集合做了什么预处理,会让这个差异消失?把"逻辑上等价"和"实现上是否多一步"分开回答。