上一章的记忆系统有一个致命的软肋:它只认字,不认意思。
客人说「我不吃折耳根」,你存的是「我不吃鱼腥草」------同一个东西,一个字都对不上。
这一章,我们让机器学会按「意思」找东西。
一、先讲一个储藏室的故事
接着上一章的厨房。
冰箱收拾利索了,大厨能记住「客人不吃香菜」了。但这家店还有一间储藏室------里面堆着十万份食材、配方和历史记录。
客人推门进来,说:「今天想吃点提鲜的。」
大厨走进储藏室,傻眼了。因为货架上贴的标签是「干贝」「火腿」「菌菇」「味精」------没有一样东西叫「提鲜的」。
按名字找,永远找不到。
客人又说:「上次那道又酸又辣的菜,再来一份。」
大厨翻遍货架,找出了「酸菜」「辣椒」「醋」「花椒」------但「又酸又辣」这个感觉,不在任何一个标签上。
这就是关键词检索的死穴:它匹配的是字,可人说的是意思。
一个真正的储藏室,应该能这样工作:我描述一种「味道」,你把最接近的几样东西挑出来。
这就是向量库要做的事。
二、先把一件事说清楚:向量库 ≠ Embedding 模型
在动手之前,必须先破除一个常见的混淆。
「向量库」这个词,其实包含了两件完全不同的事:
- 第一件事 (文字 → 坐标)今天已经彻底商品化了。你有三种选择:调 API、跑本地模型、或者用最原始的统计方法自己造。它不难,也不需要你懂。
- 第二件事 (存、找、快)才是向量库的本体,也是这一章的主攻方向。
为什么要这么分?因为很多教程把这两件事搅在一起讲,结果是读者学完了也不知道哪部分是自己该写的,哪部分是调个接口就行的。
这一章的代码会用一个零依赖的字面量向量化器 顶替第一件事,好让整个向量库能跑起来。它当然比不上真实 embedding,但它在演示里的失败,恰好能让你看清真实 embedding 的价值在哪里。
三、Embedding 到底是什么?一个被神化了的词
3.1 词向量的秘密:意思相近的词,出现在相似的上下文里
先看一个事实,它会颠覆你对「AI 语义理解」的想象:
词向量不需要神经网络,用最原始的统计方法就能造出来。
原理只有一句话:
意思相近的词,出现在相似的上下文里。
「番茄」和「西红柿」,这两个词一个字都不重合。但它们出现在什么样的句子里?
- 番茄炒蛋 、番茄沙拉 、番茄鸡蛋汤
- 西红柿炒蛋 、西红柿沙拉 、西红柿鸡蛋汤
上下文几乎一样。
那么,只要统计「每个词都和谁一起出现」,就能得到一个向量;上下文相似的词,向量自然就靠得近。
这就是 word2vec 的本质------它做的也是这件事,只是用了更聪明的方式去压缩和降维。
3.2 用 20 行代码把它算出来
python
def build_word_vectors(corpus: list[str], window: int = 2) -> dict[str, list[float]]:
"""用「共现统计」造词向量 ------ 词向量最原始、也最本质的形态。"""
docs = [d.split() for d in corpus]
co: dict[str, Counter] = defaultdict(Counter)
vocab: Counter = Counter()
for tokens in docs:
vocab.update(tokens)
for i, tok in enumerate(tokens):
lo = max(0, i - window)
hi = min(len(tokens), i + window + 1)
for j in range(lo, hi):
if i != j:
co[tok][tokens[j]] += 1 # ★ 统计「谁和谁一起出现」
words = sorted(vocab)
row_sum = {w: sum(co[w].values()) for w in words}
col_sum: Counter = Counter()
for w in words:
for k, v in co[w].items():
col_sum[k] += v
total = sum(row_sum.values()) or 1
vectors: dict[str, list[float]] = {}
for w in words:
row = []
for c in words:
# PPMI:正的点互信息。负数一律压成 0,噪音太大。
pmi = math.log(
(co[w][c] / total)
/ ((row_sum[w] / total) * (col_sum[c] / total) + 1e-12)
+ 1e-12
)
row.append(max(0.0, pmi))
vectors[w] = normalize(row)
return vectors
喂给它 8 句刻意构造的句子,看看算出来什么:
text
═══ 演示一 · 词向量:意思相近的词,出现在相似的上下文里 ═══
cos(番茄 , 西红柿 ) = +0.644
cos(炒蛋 , 沙拉 ) = +0.351
cos(番茄 , 数据库 ) = +0.000
cos(索引 , 检索 ) = +0.308
cos(索引 , 番茄 ) = +0.000
↑ 「番茄」和「西红柿」一个字都不重合,向量却明显靠近 ------
因为它们总出现在同样的上下文里(炒蛋、沙拉、鸡蛋汤)。
而「番茄」和「数据库」从不共现,相似度直接是 0。
★ 这就是词向量的全部秘密:意思相近的词,出现在相似的上下文里。
「番茄」和「西红柿」相似度 0.644,「番茄」和「数据库」相似度 0.000。
一个只用了 8 句话、纯统计、没有一行神经网络的程序,居然真的把「语义」算出来了。
3.3 所以,真实的 embedding 贵在哪?
真实的 embedding 模型(比如各家 API 提供的那些,或者本地能跑的开源模型)做了同一件事,只是:
| 维度 | 我们这 20 行 | 真实 embedding 模型 |
|---|---|---|
| 语料规模 | 8 句话 | 万亿级 token |
| 上下文窗口 | 前后 2 个词 | 整段文本 |
| 维度 | 词表大小 | 几百到几千维(压缩过) |
| 能处理的单位 | 词 | 整句、整段 |
| 「折耳根」=「鱼腥草」 | ❌ 算不出来 | ✅ 大概率能 |
从「统计共现」到「理解语义」,中间隔的是数据量,不是魔法。
这个概念一旦立住,后面的一切就都好理解了------embedding 就是坐标,向量库就是一套在坐标空间里找邻居的索引结构。
而找邻居这件事,跟文字已经没关系了。 它就是纯粹的几何问题。
四、余弦相似度:两个坐标有多像?
有了坐标,怎么衡量「像不像」?
答案是看两个向量的夹角:
为什么用夹角而不是距离?因为夹角只看方向,不看长度。
一篇 10000 字的长文和一句 10 个字的短句,讲的是同一件事。它们的向量长度 差很远,但方向应该一致。用夹角衡量,长短就不影响了。
python
def normalize(vec: list[float]) -> list[float]:
"""把向量拉成单位长度 ------ 之后算余弦就只要点积。"""
norm = math.sqrt(sum(v * v for v in vec))
return vec if norm == 0 else [v / norm for v in vec]
def cosine(a: list[float], b: list[float]) -> float:
"""余弦相似度。因为都归一化过了,点积就是余弦。"""
return sum(x * y for x, y in zip(a, b))
▍一个工程上的小技巧:入库前先把所有向量归一化。
归一化之后,向量长度恒为 1,两个单位向量的点积就等于它们的余弦。于是查询时只需要一次乘加,省掉了开根号和除法。
百万级库上,这个优化能省掉可观的算力。这就是「工程」和「原理」的差别------原理一样,落地时处处是细节。
五、真正的难题:怎么找得够快?
5.1 暴力检索:准,但慢得没法用
最直接的做法是:把库里每一个向量都算一遍相似度,然后排序。
python
class BruteForce:
def search(self, query, vectors, k=3):
scored = [(i, cosine(query, v)) for i, v in enumerate(vectors)]
scored.sort(key=lambda pair: -pair[1])
return scored[:k]
这个做法叫暴力检索 ,它的优点是绝对准确------你找到的一定是最近的 k 个。
但它有个致命的问题:复杂度是 O(n)。
- 1 万条向量:还行;
- 100 万条向量:每次查询要算 100 万次点积,几百毫秒;
- 1000 万条:几秒钟。Agent 卡在那儿等检索结果,体验直接崩了。
而真实的 RAG 场景,知识库动辄百万级 chunk。暴力检索根本撑不住。
5.2 三条加速路线
工业界有三条思路,通常组合使用:
这一章我们手写第一条路线里最经典的 IVF 索引------因为它最好理解,也最能体现「近似最近邻」的精髓。
5.3 IVF:先分桶,再找邻居
IVF(倒排文件索引)的思路,用储藏室打比方就是:
不要每次都在十万个货架里翻。先按大类分成 100 个区,每个区选一个「区代表」。
客人来了,先看哪个区的代表最对味,只去那两三个区翻。
对应到算法上:
- 建索引:用 k-means 把所有向量聚成 N 个簇,每个簇算一个「中心点」;
- 查询 :先找和查询向量最接近的几个中心点,只在这些簇里暴力扫。
python
class IVFIndex:
"""倒排文件索引:先聚类分桶,查询时只扫最近的几个桶。
用一点点准确率,换几十倍的速度。这就是「近似最近邻」的全部思路。
"""
def __init__(self, n_clusters: int = 4, n_probe: int = 2, iters: int = 15):
self.n_clusters = n_clusters
self.n_probe = n_probe # 查询时扫几个桶
self.iters = iters
self.centroids: list[list[float]] = []
self.buckets: list[list[int]] = []
def build(self, vectors: list[list[float]]) -> None:
if not vectors:
return
n = min(self.n_clusters, len(vectors))
# ── 初始化:均匀挑 n 个点当初始质心(确定性,方便复现)──
step = max(1, len(vectors) // n)
self.centroids = [list(vectors[i * step]) for i in range(n)]
# ── 手写 k-means ──
for _ in range(self.iters):
buckets: list[list[int]] = [[] for _ in self.centroids]
for idx, vec in enumerate(vectors):
best = max(
range(len(self.centroids)),
key=lambda c: cosine(vec, self.centroids[c]),
)
buckets[best].append(idx) # ★ 每个向量归到最近的簇
moved = False
for c, members in enumerate(buckets):
if not members:
continue
dim = len(vectors[0])
merged = [
sum(vectors[m][d] for m in members) / len(members)
for d in range(dim)
]
new_centroid = normalize(merged) # ★ 把簇心挪到成员的均值处
if cosine(new_centroid, self.centroids[c]) < 0.999999:
moved = True
self.centroids[c] = new_centroid
if not moved: # 簇心不再移动 → 收敛
break
self.buckets = buckets
def search(self, query, vectors, k: int = 3):
# ① 先找最近的 n_probe 个质心
nearest = sorted(
range(len(self.centroids)),
key=lambda c: -cosine(query, self.centroids[c]),
)[: self.n_probe]
# ② 只在这些桶里暴力扫
candidates = [i for c in nearest for i in self.buckets[c]]
scored = [(i, cosine(query, vectors[i])) for i in candidates]
scored.sort(key=lambda pair: -pair[1])
return scored[:k], len(candidates)
这段代码只做了两件事:把相近的向量放到同一个桶里,查询时只翻几个桶。
效果如何?用 200 条分属 8 个主题的文档实测:
text
═══ 演示四 · 索引:用一点点准确率换速度 ═══
向量总数:200,聚成 8 个桶、每次查 2 个桶
暴力检索:扫描 200 个向量,耗时 3.82 ms
IVF 检索:扫描 50 个向量,耗时 1.31 ms
→ 扫描量降到 25%,速度提升 2.9 倍
→ 结果是否一致:是
★ 这就是「近似最近邻」:少扫 3/4 的向量,结果依然对。
真实场景的 HNSW 索引在百万级数据上,能省掉 99% 以上的扫描。
扫描量降到 25%,速度快了近 3 倍,而结果一模一样。
注意这里的结果「碰巧」完全正确------因为我们的测试数据聚类结构非常干净。真实数据上,IVF 会有一定概率漏掉真正的最近邻。
这就是「近似」二字的代价:拿一点点召回率,换几十倍的速度。 在 RAG 场景里,这个交换几乎永远是划算的------因为后面还有模型兜底。
5.4 HNSW 是什么?
工业界目前最主流的索引是 HNSW(分层可导航小世界图)。它的思路完全不同,但同样优美:
想象你要在一个陌生城市里找一个地址。
你不会一条街一条街地扫。你会先看全国地图(粗粒度),定位到大概的省;再看市区地图,定位到大概的区;最后才看街道图,找到具体的门牌号。
HNSW 就是把向量组织成多层图:上层稀疏、跨度大(全国地图),下层密集、跨度小(街道图)。查询时从最上层开始,一层层往下逼近。
它的优点是又快又准 ,缺点是建索引慢、内存占用大。
| 索引 | 建库速度 | 查询速度 | 召回率 | 内存 |
|---|---|---|---|---|
| 暴力 | 不用建 | 最慢 | 100% | 最小 |
| IVF | 快 | 快 | 高(可调) | 小 |
| HNSW | 慢 | 最快 | 很高 | 大 |
| IVF + PQ | 中等 | 快 | 中 | 极小 |
生产环境的选型经验:数据量 < 10 万,直接暴力检索就够;10 万 ~ 1000 万,IVF 或 HNSW;上亿,IVF + PQ。
六、比索引更重要的事:切块
讲完了索引,必须回头讲一件更容易被忽略、却更影响效果的事。
6.1 切菜的大小,决定了这锅菜好不好炒
向量库通常不存整篇文档,而是存切好的小块(chunk)。原因很简单:
- 一整篇文章的向量,是「平均」出来的------什么都像,又什么都不像;
- 小块才可能有明确的语义焦点,检索时才分得清。
但切多大?这是一个没有标准答案、却处处是坑的问题:
| 切法 | 后果 |
|---|---|
| 切太小(50 字) | 每块信息不全,模型拿到手也不知道上下文 |
| 切太大(2000 字) | 语义被稀释,检索精度暴跌 |
| 硬切、不重叠 | 一句话被拦腰截断,两半都读不懂 |
6.2 重叠区:防止句子被腰斩
我们的实现加了最简单也最有效的一招------相邻块之间留一段重叠:
python
def chunk(text: str, size: int = 100, overlap: int = 20) -> list[str]:
"""把长文本切成小块。
overlap 是「重叠区」------上一块的尾巴会出现在下一块的开头。
为什么需要它?因为一句话可能正好被切断,重叠能保证它至少完整地
出现在某一块里。
"""
text = text.strip()
if len(text) <= size:
return [text] if text else []
pieces, start = [], 0
while start < len(text):
end = start + size
pieces.append(text[start:end])
if end >= len(text):
break
start = end - overlap # ★ 回退 overlap 个字符
return pieces
跑起来看:
text
═══ 演示二 · 切块:重叠区保证句子不被拦腰截断 ═══
第 1 块 │ 后厨的流程是这样的。首先由配菜工位负责清洗和切配,把食材处理成可以直接下锅的状态
第 2 块 │ 成可以直接下锅的状态。然后交给炒锅工位,由大厨掌勺完成烹饪。最后是装盘工位,负责
第 3 块 │ 最后是装盘工位,负责摆盘和出餐。整个流程中,传菜单是唯一的共享状态。
看第 1 块和第 2 块的衔接处------「成可以直接下锅的状态」这半句,完整地出现在了第 2 块的开头。
如果没有重叠,「把食材处理成可以直接下锅的状态」这句会被切成「把食材处理成可以直」和「接下锅的状态」,两块都读不通,检索出来也没法用。
切块是 RAG 里最不性感、却最影响效果的一步。 索引决定了「找得快不快」,切块决定了「找得准不准」------而后者往往更重要。
七、混合检索:向量不是万能的
7.1 向量检索的两个软肋
向量检索擅长「意思相近」,但在两种情况下会翻车:
- 专有名词、编号、代码 。你搜「错误码 E1024」,向量检索可能给你返回一堆「错误码 E2048」「错误码 E1024 的变体」------它分不清这几个数字的差别;
- 长尾词。语料里从没出现过的词,embedding 模型没见过,向量质量很差。
而这两类,恰恰是关键词检索(BM25)最擅长的。
7.2 两路合并
所以工业界的标准做法是混合检索:
BM25 是经典的关键词打分算法,核心思想是:一个词在这篇文档里出现得越多、在整个库里出现得越少,它就越重要。
python
class BM25:
"""经典关键词检索。补向量检索的短板:专有名词、编号、精确匹配。"""
def __init__(self, docs: list[str], k1: float = 1.5, b: float = 0.75):
self.k1, self.b = k1, b
self.docs_tokens = [ngrams(d, ns=(1, 2)) for d in docs]
self.doc_len = [len(t) for t in self.docs_tokens]
self.avg_len = (sum(self.doc_len) / len(self.doc_len)) if self.doc_len else 1
self.n = len(docs)
self.tf: list[Counter] = [Counter(t) for t in self.docs_tokens]
df: Counter = Counter()
for counter in self.tf:
df.update(counter.keys())
# ★ IDF:越罕见的词,权重越高
self.idf = {
term: math.log(1 + (self.n - freq + 0.5) / (freq + 0.5))
for term, freq in df.items()
}
def scores(self, query: str) -> list[float]:
q_terms = ngrams(query, ns=(1, 2))
out = []
for i in range(self.n):
score = 0.0
for term in q_terms:
f = self.tf[i].get(term, 0)
if f == 0:
continue
# ★ 词频饱和:同一个词出现 10 次和 100 次,不该差 10 倍
denom = f + self.k1 * (1 - self.b + self.b * self.doc_len[i] / self.avg_len)
score += self.idf.get(term, 0.0) * f * (self.k1 + 1) / denom
out.append(score)
return out
合并分数就一行:
text
最终分数 = alpha × 向量分数 + (1 - alpha) × 关键词分数
alpha 一般在 0.5 ~ 0.7 之间------向量路稍占主导,关键词路负责兜底。
7.3 组装成向量库
python
class VectorStore:
def __init__(self, embedder=None, use_index: bool = True, n_clusters: int = 4):
self.embedder = embedder or HashingEmbedder()
self.use_index = use_index
self.n_clusters = n_clusters
self.chunks: list[str] = []
self.vectors: list[list[float]] = []
self.index: IVFIndex | None = None
self.bm25: BM25 | None = None
self.last_scanned = 0
def add_text(self, text: str, size: int = 100, overlap: int = 20) -> int:
"""把一篇长文切块、向量化、入库。返回切了多少块。"""
pieces = chunk(text, size=size, overlap=overlap)
for piece in pieces:
self.chunks.append(piece)
self.vectors.append(self.embedder.embed(piece))
self._rebuild()
return len(pieces)
def search(self, query: str, k: int = 3, alpha: float = 0.5):
"""混合检索:向量分数 × alpha + 关键词分数 × (1-alpha)。"""
if not self.chunks:
return []
q_vec = self.embedder.embed(query)
# ── 向量路 ──
if self.index is not None:
vec_hits, self.last_scanned = self.index.search(
q_vec, self.vectors, k=len(self.chunks)
)
else:
vec_hits = BruteForce().search(q_vec, self.vectors, k=len(self.chunks))
self.last_scanned = len(self.vectors)
vec_scores = dict(vec_hits)
# ── 关键词路 ──
raw_bm25 = self.bm25.scores(query) if self.bm25 else [0.0] * len(self.chunks)
top_bm25 = max(raw_bm25) or 1.0
bm_scores = {i: s / top_bm25 for i, s in enumerate(raw_bm25)}
# ── 合并 ──
merged = []
for i in range(len(self.chunks)):
s = alpha * vec_scores.get(i, 0.0) + (1 - alpha) * bm_scores.get(i, 0.0)
merged.append((i, s))
merged.sort(key=lambda pair: -pair[1])
return [(self.chunks[i], round(s, 4)) for i, s in merged[:k]]
八、跑起来看看
往库里放四篇文档,然后问它三个问题:
text
═══ 演示三 · 混合检索:向量 + 关键词 ═══
📥 后厨流程:切成 3 块
📥 记忆系统:切成 2 块
📥 沙盒隔离:切成 2 块
📥 向量检索:切成 2 块
库中共 9 块向量,维度 512
问:怎么保证搞砸了不影响主机?
[0.614] 换掉,宿主机毫发无损。长连接保证状态可以继承。...
[0.601] Agent 要伸进真实世界,需要隔离。每条命令都在独立的沙盒里执行,搞砸了就整间换掉...
问:记忆什么时候该忘记?
[0.732] 记忆的难点不是存,而是三个决策:什么时候写、捞哪条出来、什么时候忘。检索时用相关性、...
[0.063] 后厨的流程是这样的。首先由配菜工位负责清洗和切配,把食材处理成可以直接下锅的状态。然...
问:怎么加快检索速度?
[0.609] HNSW 这类近似最近邻索引,用一点点准确率换几十倍的速度。...
[0.570] 向量库把文本变成坐标,意思相近的文本坐标也相近。为了加速检索,通常用 IVF 或 H...
三个问题全部命中了正确的文档。
特别注意第一个问题 ------「怎么保证搞砸了 不影响主机 」,这两个词在原文档里出现的是「宿主机」和「整间换掉」。字面上并不完全对应,但混合检索依然把它找出来了。
也特别注意第二个问题的第二条结果 ------分数只有 0.063,却依然被返回了。
这是一个必须提醒的坑:向量检索永远会给你返回 Top-K,哪怕它其实一个都不相关。
所以真实系统里必须设一个最低分阈值------低于这个分数就返回「没找到」,而不是硬塞几条不相关的内容给模型。
让模型读到「我没找到」,远比读到「一堆看似相关其实是噪音」的内容要好。
8.1 诚实地说说这个实现的短板
必须坦白:这一章的向量化器是字面量哈希,不是真正的语义 embedding。它带来两个明确的失败:
| 场景 | 我们这个实现 | 真实 embedding |
|---|---|---|
| 查「折耳根」,文档里写的是「鱼腥草」 | ❌ 完全找不到 | ✅ 能找到 |
| 查「怎么让系统更稳」,文档里写「提升稳定性」 | ❌ 字面对不上 | ✅ 能找到 |
| 查「E1024 错误码」 | ✅ 比真实 embedding 还准 | ⚠️ 可能混淆相近编号 |
看第三行 ------这就是为什么混合检索是标配:关键词路补的,恰好是向量路的短板;反过来也一样。
真实项目里,把
HashingEmbedder换成一次 embedding API 调用即可,向量库的其余部分一个字都不用改。这正是第二节那个「两件事要分开」的用意:embedding 是可以替换的零件,向量库才是你要造的机器。
九、小结
9.1 这一章我们造了什么
| 零件 | 作用 | 关键那一行 |
|---|---|---|
| 切块 | 把长文切成可检索的小块 | start = end - overlap |
| 向量化 | 文字 → 坐标 | crc32(gram) % dim |
| 余弦相似度 | 衡量两个坐标有多像 | 归一化后,点积即余弦 |
| 暴力检索 | 准,但 O(n) | 逐个比 |
| IVF 索引 | 快,用一点准确率换 | 先找簇心,只扫几个桶 |
| BM25 | 关键词路的兜底 | IDF × 词频饱和 |
| 混合检索 | 两路加权合并 | α×向量 + (1-α)×关键词 |
9.2 一句话总结这一章
向量库的本质,是「把找相似」变成「找最近的邻居」。
一旦文字变成了坐标,剩下的就全是几何问题------而几何问题,是可以用朴素的索引结构、在毫秒之间解决的。
9.3 生产环境怎么做
真实项目里,这一章的每个零件都有成熟的替代品:
| 这一章 | 生产环境 | 什么时候该换 |
|---|---|---|
| 字面量哈希向量 | embedding API / 本地模型 | 立刻。这是差距最大的一环 |
| 手写 IVF | FAISS / Milvus / Qdrant / pgvector | 数据量 > 10 万 |
| 手写 BM25 | Elasticsearch / 数据库全文索引 | 需要复杂过滤和聚合 |
| 内存存储 | 向量数据库 + 元数据过滤 | 需要持久化和多租户 |
| 固定切块 | 语义切块(按段落 / 按标题) | 文档结构清晰时 |
但请先跑通这一章的版本,再去换。 因为一旦用上现成的库,你就很难再体会到「为什么需要索引」「为什么要混合检索」------你会以为这些是理所当然的。
9.4 下一章
现在,Agent 有了记忆(第五章),记忆有了语义检索能力(这一章)。
但还有一个问题没解决:它依然只有一只「会查资料」的手,没有一只「会干活」的手。
下一章,我们讲工具------模型的手究竟该长什么样:工具的描述怎么写模型才不选错、参数怎么校验、执行失败了怎么把错误变成有效信号、多个工具同时调用怎么不出乱子。
本章核心结论 :向量库要拆成两件事看------「把文字变成向量」是可以替换的零件,「在百万坐标里毫秒级找邻居」才是向量库的本体。 而后者,全部是朴素的几何与索引结构,没有一丝魔法。
完整可运行代码 :
code/06_vector/mini_vectordb.py