向量放哪儿、怎么比 — 「取前 k 条」的甜头和它的账
这一章讲三件事: 那一堆数存在什么东西里、怎么在两次运行之间保住它、 以及一个从第 1 章坑到第 11 章的默认行为 —— 你要几条,它就给你几条,哪怕一条都不相关。 它在全书链条里的位置: 这是六站里的第四、第五站(存 + 取)。 前面三站的产物到这里第一次被真正使用,而这一站的默认行为决定了后面所有章节的天花板。
1. 顶层全景
一堆块的向量
│
▼
┌── 向量库 ────────────────────────────────┐
│ ① 存下来 │
│ ② 建一套查得快的索引 │
│ ③ 给一个问题的向量,交出最近的前 k 条 │
└──────────────────────────────────────────┘
│
├─ 落盘:写到磁盘上,下次不用重新算
└─ 重载:从磁盘读回来,接着用
图说:③ 那一行是这一章的全部戏份。
注意它的形状——**输入是「几条」,不是「多像才算」。**
一句话链条: 向量要存起来 → 存了要能查得快 → 查的接口收的是「取几条」→ 所以它永远凑够那几条 → 于是垃圾也会被交上来 → 解药是另加一道门槛,而书没把药和病放在一起说。
2. 向量库管三件事
它解决什么问题
拿一个列表存所有向量,查询时逐个算距离、排序、取前几个 —— 这样也能跑,而且完全正确。
问题是规模。一百条向量这么干没问题;一百万条,每查一次就要算一百万次距离。 而这一站是「每问一次跑一次」的,用户就在屏幕前等着。
它怎么做
三件事,缺一件就不叫向量库:
| 它管什么 | 具体是什么 |
|---|---|
| 存 | 把向量连同原文、标签一起放好 |
| 建索引 | 事先排好一套查找结构,让「找最近的几个」不必逐条比 |
| 取前 k 条 | 给一个查询向量,交出最近的 k 条 |
书里用的两个
| FAISS | Chroma | |
|---|---|---|
| 出身 | Facebook AI Similarity Search,Meta 出品1 | 一个公开了源码的向量数据库(就是本节说的向量库,只是叫法不同)2 |
| 它是什么 | 一个纯索引库 —— 只管建索引和查,不管别的 | 一个数据库 —— 自带落盘、集合、标签管理 |
| 落盘怎么做 | 你自己调「存到这个目录」和「从这个目录读回来」 | 建库时给一个目录名,它自己管 |
这个差别在下面第 4 节的走查里会变成两行完全不同的代码。
3. 「取前 k 条」为什么必须做成库里的一个动作
这一节回答一个容易被跳过的问题:为什么不自己写个循环排序?
因为分数不出库
你能问的: 「给我最近的 3 条」
你问不到的: 「所有条目各自的分数是多少」
图说:向量库为了快,建的那套索引本来就不遍历全部——
它跳过了大部分候选。所以「全部的分数」这份数据根本不存在,不是它不肯给。
这就是这个接口只能收「几条」、不能收「多像才算」的根本原因。 你要它按门槛筛,它得先把所有条目都算一遍 —— 那正是索引想避免的事。
于是形状定死了后果
接口的形状: 取前 k 条
│
└─ 它必须返回 k 条 —— 因为它不知道该在哪儿停
│
└─ 库里只要有 k 条数据,它就凑得出 k 条
图说:这不是 bug,是接口形状的必然结果。
**认清这一点,第 6 节那些「离谱的第二名」就不再是意外了。**
4. 主走查:建库 → 落盘 → 换个脚本重载 → 查
这一节是本章的主走查。上面三件事、下面那个危险参数,都在这条线上占一步。
书里有一处是全书唯一一条从建库一路走到重载再走到查询的完整线,而且它特意分成了两个脚本 —— 这正是真实系统的样子:灌数据是一个程序,查询是另一个程序3。
| 步 | 哪个脚本 | 具体的数 / 字串 |
|---|---|---|
| ① 准备三条文档 | 建库脚本 | 一条讲 LangChain、一条讲 FAISS、一条讲 Hugging Face 上的模型 |
| ② 各变成一串数 | 建库脚本 | 用 all-MiniLM-L6-v2,每条 384 个数(第 04 章那一步) |
| ③ 建索引 | 建库脚本 | FAISS.from_documents(documents, embedding=embedding_model) |
| ④ 落盘 | 建库脚本 | save_local(folder_path="chapter5_faiss_store") → 打印 Vector Store saved to: chapter5_faiss_store |
| ⑤ 换一个脚本 | 查询脚本 | 这里是关键:第二个程序完全不知道第一个程序做过什么,它只有那个目录 |
| ⑥ 重载 | 查询脚本 | load_local(folder_path="chapter5_faiss_store", embeddings=embedding_model, allow_dangerous_deserialization=True) |
| ⑦ 查 | 查询脚本 | 问「What is LangChain?」,要 k=2 条 |
| ⑧ 结果 | 查询脚本 | ① LangChain is a framework for building LLM-powered apps. ② 和 LangChain 无关的那条(见下) |
逐步读
第 ②③ 步:注意查询脚本也要建同一个嵌入模型。
重载时那个 embeddings=embedding_model 参数不是摆设 —— 库里存的是向量,
而你的问题还是一句话,得由查询脚本自己把它变成向量。
这也是第 04 章那条「两头必须同一个模型」在代码上的样子。
第 ④ 步:落盘是把索引整个写成文件。 下次不必重新算 —— 而重新算是这条流水线上最贵的一步 (每条文档都要过一遍模型)。
第 ⑥ 步那个又长又吓人的参数,下一节专讲。
第 ⑧ 步是全章的要害。 我们要的是「What is LangChain?」, 而第 二条返回的是库里另一条完全无关的文档 —— 它讲的是一类叫 Transformer 的模型 (今天几乎所有语言模型的内部构造都属于这一类,你在模型名和论文里会反复撞见这个词)。
那条文档里还有一个词是自然语言处理:让机器读、写、理解人类语言这门行当,英文缩写叫 NLP。
原文如下:
Transformers from Hugging Face are widely used in NLP.
它和 LangChain 没有任何关系。 它被交出来的唯一原因是:我们说了要 2 条。
库里一共只有 3 条文档,第 2 名是「剩下两条里比较不那么差的那条」。 它不是「相关的第二名」,它是「排序的第二名」——这两件事完全不同,而接口分不出来。
5. 那个被照抄了四次、零解释的危险参数
allow_dangerous_deserialization=True 这个参数在书里出现四次,一次解释都没有4。
它长得像个样板参数,读者会照抄。
它到底在说什么
它的意思是:「我知道加载这个索引文件可能会在我的机器上执行任意代码,我信任这个文件。」
落盘时: 内存里的对象 ──► 写成文件(这个过程叫「序列化」)
重载时: 文件 ──► 还原成内存里的对象(叫「反序列化」)
Python 用的那种通用还原方式,允许文件里带上「还原时该跑哪段代码」的指令。
于是:**一个精心构造的索引文件,可以在你加载它的那一刻运行任何东西。**
图说:这不是理论风险。所以这个库默认拒绝加载,逼你手动写上这个参数,
等于让你签一份字:出了事你知情。
判据一句话
| 这个索引文件从哪来 | 能不能加 |
|---|---|
| 你自己的程序刚刚生成的 | 能 |
| 从同事那儿拷来的、从网上下载的、用户上传的 | 不能 —— 重新生成一份,别加载 |
书用了四次,一次都没提这件事。
6. 库会越来越脏:第 01 章那笔账在这里结清
现象:同一条文档返回了两遍
书里有两个配方的输出长这样5:
某个配方的搜索结果:
FAISS performs fast vector searches.
FAISS performs fast vector searches.
另一个配方的搜索结果:
1. ChromaDB is an open-source vector database for AI applications. (source: wiki)
2. ChromaDB is an open-source vector database for AI applications. (source: wiki)
图说:两条一模一样。而这两个程序各自只准备了三条不同的文档。
原因:落盘目录不会自动清空
第一次运行: 建库(目录里 0 条) → 灌进 3 条 → 目录里现在有 3 条
第二次运行: 连上同一个目录(已有 3 条) → 又灌进同样的 3 条 → 目录里现在有 6 条
第三次运行: → 9 条
图说:那个「加文档」的动作只管加,不去重、不覆盖。
作者调试时跑了几遍,库里就有几份重复。
这就解释了第 01 章那个错答案
第 01 章最后那一步:六站全走通,问「这份文档在讲什么」, 答案却是「这份文档似乎是在讲 LangChain」—— 而输入的 PDF 讲的是 RAG。
原因是同一个: 那个配方用的落盘目录,和前面几个配方用的是同一个; 里面还躺着更早那个配方灌进去的四条讲 LangChain 的样例句6。
怎么防
| 做法 | 什么时候用 |
|---|---|
| 每次重灌前先删目录 | 开发调试时最省事 |
| 给每条文档一个稳定的 id,灌入时按 id 覆盖 | 生产环境的标准做法 —— 同一份文档更新了,替换而不是再加一份 |
| 灌完打印一 下总条数 | 最便宜的自检:数字对不上就说明脏了 |
书这三条一条都没提,它甚至没有注意到自己的输出重复了。
7. 那个从头坑到尾的默认行为:它永远凑够 k 条
这一节是全书最要紧的一条,而书从来没有正面说过。
病症
第 4 节的走查已经给了第一个现场。书里还有一地证据,这里只举分数最刺眼的那次7:
查询: How does RAG reduce hallucinations?
语料: 八条短文档
逐条的相似度(余 弦,范围 −1 到 1):
0.6190 RAG reduces hallucinations by grounding answers in retrieved evidence. ← 真正的答案
-0.0185 Dense retrieval uses vector embeddings instead of keyword matching.
0.0139 FAISS is a fast vector similarity library from Meta.
-0.0189 BM25 is a sparse retrieval method based on term frequency statistics.
-0.0296 Sentence-Transformers provides easy embedding models for sentences.
-0.0428 Vector databases store embeddings to enable semantic search.
-0.0015 Cooking recipes often require precise measurements and timing.
0.0383 Traveling to new countries helps you learn about culture and history.
图说:八条里只有第一条真的回答了问题(0.6190)。其余七条全部挤在 0 附近,
其中五条还是**负的**——方向已经相反了。
现在假设你按常规写法取前 3 条:
第 1 名 0.6190 RAG reduces hallucinations by grounding answers… ← 对
第 2 名 0.0383 Traveling to new countries helps you learn about culture and history. ← **出国旅行**
第 3 名 0.0139 FAISS is a fast vector similarity library from Meta.
图说:第二名是「出国旅行长见识」。它和「RAG 怎么减少胡编」毫无关系,
分数只有第一名的十六分之一——可它照样被交给了模型。
0.0383 是什么概念? 上一章说过,真实语料上 0.6 就算很像了; 而 0.04 基本等于「两句话之间没有任何关系」。
为什么这件事比它看起来严重
因为下一站会把这三条原样塞进提示。
模型看到的是三段并列的、看起来同等重要的资料。它没有办法知道第二、三条是凑数的 —— 提示里没 有分数,只有文字。
于是两种坏结果:轻则答案被无关内容带偏,重则模型拿「出国旅行」当依据编出一段话。
8. 解药就在同一本书的另一章:先设一道门槛
书里有这味药,而且用得很对。它只是从没和上面那个病放在一起说过 —— 这个连接是我们做的。
做法
在取前 k 条之后、交给模型之前,加一道判断:低于某个分数的一律丢掉。
用上一节那八条跑一遍,门槛设 0.5,书印出来的结果是8:
Filtered Results (similarity > 0.5):
similarity=0.6190 | RAG reduces hallucinations by grounding answers in retrieved evidence.
图说:八条候选,只有一条过线。**其余七条全部被丢掉,包括「出国旅行」那条。**
注意这时候交给模型的是一条,不是三条——**它敢交白卷。**
为什么这么做有效
因为它换了一个判据。
| 取前 k 条 | 加一道门槛 | |
|---|---|---|
| 判据是什么 | 名次 —— 你比别人强就行 | 绝对分数 —— 你得真的够像 |
| 库里全是垃圾时 | 交出 k 条垃圾 | 交出 0 条 |
| 谁来决定交几条 | 你(写死的 k) | 数据(过线几条就是几条) |
「敢交出 0 条」是这一步全部的价值。 系统说「我没找到」,比编一段话有用得多。
但别把这道门槛和后面那道搞混:这一道拦的是候选资料 —— 分数低的块压根不进模型,模型看都看不到它。 后面还有一道拦的是答案本身 —— 资料已经进去了、话也已经生成出来了, 再判断这段话有几分底气。两道拦的是两样东西,第 09 章第 5 节专门划这条界。