从零写一个向量检索
用 numpy 写一个几十行的向量检索:把文档块变成向量矩阵,查询时算相似度取前几名。再用 20 道题评估它,结果只有一半能找对,看看问题出在哪。
- 约 45 分钟
- 难度:进阶
- 实测:2026-09-14 bge-small-zh-v1.5,multilingual-e5-small
第 01 模块第 5 课讲过向量嵌入:意思相近的文字,向量也相近。上一课把 httpx 的文档切成了 196 块。把这两件事合起来,就是一个语义搜索引擎:提前算好每一块的向量,用户提问时算出问题的向量,找出和它最相似的几块。
很多教程会直接让你装一个向量数据库。这一课先不用,自己用 numpy 写一个,一共几十行。写完你会发现,向量检索的核心就是一次矩阵乘法。然后我们用 20 道题评估它,看它到底好不好用。
建索引
import numpy as np
from sentence_transformers import SentenceTransformer
class VectorIndex:
def __init__(self, model_name):
self.model = SentenceTransformer(model_name)
# e5 系列要求给查询和文档分别加上前缀,这是它训练时的约定
self.q_prefix, self.d_prefix = ("query: ", "passage: ") if "e5" in model_name else ("", "")
self.chunks = [] # [(文件名, 文本), ...]
self.matrix = None # 每一行是一个块的向量
def build(self, chunks):
self.chunks = chunks
texts = [self.d_prefix + text for _, text in chunks]
self.matrix = self.model.encode(texts, normalize_embeddings=True, batch_size=32)
build 把所有块一次性交给嵌入模型,得到一个矩阵:196 块,每块一个 512 维的向量,就是一个 196 行、512 列的矩阵。normalize_embeddings=True 把每个向量的长度缩放成 1,这样后面算相似度只需要做点积。
每个块都记下了它来自哪个文件,后面要用来判断检索得对不对,第 5 课还要用它告诉用户答案出自哪里。
检索
def search(self, query, k=5):
q = self.model.encode([self.q_prefix + query], normalize_embeddings=True)[0]
scores = self.matrix @ q # 向量都归一化过了,点积就是余弦相似度
top = np.argsort(-scores)[:k]
return [(float(scores[i]), *self.chunks[i]) for i in top]
self.matrix @ q 是一次矩阵乘以向量:196 行,每一行和问题向量做一次点积,一次算出问题和所有块的相似度。np.argsort(-scores) 按相似度从高到低排序,取前 k 个。
这就是一个完整的向量检索。试一下:
BAAI/bge-small-zh-v1.5:196 个块,向量 (196, 512),建索引用了 15.7 秒
示例:「怎么关闭 SSL 证书校验?」最相似的块来自 advanced/ssl.md,相似度 0.628
### Enabling and disabling verification
By default httpx will verify HTTPS connections, and raise an error for invalid SSL cases...
中文问题,找到了英文文档里讲证书校验的那一节。建索引用了 15.7 秒,大部分时间花在第一次加载模型和计算 196 个向量上。检索本身几乎不花时间。
怎么知道它好不好
试一个问题找对了,不能说明什么。要系统地评估,需要一组有标准答案的题目。
我准备了 20 道中文问题,放在 code/04-rag/eval_qa.jsonl 里。每道题标注了答案应该在哪个文件里,以及一个关键词:取回的块既来自这个文件、又包含这个关键词,才算找对了。
{"question": "怎么把超时完全关掉,让请求一直等下去?", "file": "advanced/timeouts.md", "keyword": "timeout=None"}
{"question": "服务器要求 Digest 认证怎么办?", "file": "advanced/authentication.md", "keyword": "DigestAuth"}
{"question": "有些域名不想走代理,环境变量怎么设置?", "file": "environment_variables.md", "keyword": "NO_PROXY"}
……
用"文件 + 关键词"而不是"块的编号"来判断,是因为切分方法一变,块的编号就全变了,而文件和关键词不会变。这样换一种切法,同一套题可以直接拿来评估。每个关键词我都用脚本确认过,确实出现在对应的文件里。
然后统计几个指标:
- 第 1 名命中率:排第一的块是对的,占多少比例。
- 前 3 名、前 5 名命中率:前几名里有对的,占多少比例。RAG 通常会取前几块一起交给模型,所以这个指标更重要。
- MRR(平均倒数排名):对的块排第 1 名得 1 分,第 2 名得 1/2,第 3 名得 1/3,没找到得 0 分,所有题取平均。它综合反映了"找没找到"和"排得靠不靠前"。
def evaluate(search, questions, k=5):
"""返回第 1 名命中率、前 3 名命中率、前 5 名命中率、MRR,以及没找到的题。"""
ranks, misses = [], []
for qa in questions:
results = search(qa["question"], k)
rank = next((i + 1 for i, r in enumerate(results) if is_hit(r, qa)), None)
ranks.append(rank)
if rank is None:
misses.append((qa, results[0]))
n = len(questions)
hit = lambda top: sum(1 for r in ranks if r and r <= top) / n
mrr = sum(1 / r for r in ranks if r) / n
return hit(1), hit(3), hit(5), mrr, misses
evaluate 接收的是一个检索函数,而不是某个具体的索引。下一课的关键词检索、混合检索,都能用它来评估,结果可以直接比较。
结果:只有一半
20 道题:第 1 名命中 20%,前 3 名命中 40%,前 5 名命中 50%,MRR 0.303
没找到:怎么知道一个响应实际用的是 HTTP/1.1 还是 HTTP/2?(应在 http2.md)→ 第 1 名是 advanced/clients.md:!!! hint
没找到:服务器要求 Digest 认证怎么办?(应在 advanced/authentication.md)→ 第 1 名是 advanced/ssl.md:### Enabling and disabling verification
没找到:怎么让请求走 HTTP 代理?(应在 advanced/proxies.md)→ 第 1 名是 advanced/clients.md:!!! hint
没找到:下载很大的文件时,怎么一块一块地读,而不是一次读进内存?(应在 quickstart.md)→ 第 1 名是 advanced/clients.md:## Multipart file encoding
没找到:响应是 404 或 500 时,怎么让它直接抛异常?(应在 quickstart.md)→ 第 1 名是 advanced/timeouts.md:HTTPX is careful to enforce timeouts eve
没找到:异步发请求应该用哪个类?(应在 async.md)→ 第 1 名是 async.md:# Async Support
没找到:httpx 和 requests 在处理重定向上有什么不一样?(应在 compatibility.md)→ 第 1 名是 advanced/clients.md:!!! hint
没找到:写测试时,怎么不真的发网络请求,而是返回一个假的响应?(应在 advanced/transports.md)→ 第 1 名是 advanced/clients.md:!!! hint
没找到:想在每个请求发出之前和收到响应之后都执行一段代码,比如打日志,怎么做?(应在 advanced/event-hooks.md)→ 第 1 名是 advanced/timeouts.md:HTTPX is careful to enforce timeouts eve
没找到:有些域名不想走代理,环境变量怎么设置?(应在 environment_variables.md)→ 第 1 名是 advanced/clients.md:!!! hint
20 道题,前 5 名里能找到对的块的只有一半。这个效果放进 RAG 里,一半的问题模型拿到的都是不相关的资料。
换一个嵌入模型试试。intfloat/multilingual-e5-small 是一个专门为多种语言训练的嵌入模型:
python code/04-rag/vector_search.py intfloat/multilingual-e5-small
20 道题:第 1 名命中 30%,前 3 名命中 50%,前 5 名命中 65%,MRR 0.418
好一些,前 5 名命中率从 50% 升到 65%,但仍然不理想。
问题出在哪
跨语言。问题是中文,文档是英文。bge-small-zh-v1.5 主要是为中文训练的,理解英文的能力有限。multilingual-e5-small 是为多语言训练的,所以好一些。但把中文问题和英文文档映射到同一个向量空间里,本来就比同一种语言难。
"万能块"。仔细看没找到的那些题,第 1 名反复出现同一个块:advanced/clients.md 里的 !!! hint。它的全文是:
!!! hint
If you are coming from Requests, `httpx.Client()` is what you can use instead of `requests.Session()`.
只有 115 个字符,里面同时出现了 httpx、requests、Client 这些词,讲的是"怎么用 httpx"这件事本身。对嵌入模型来说,它和几乎任何一个"httpx 怎么……"的问题都有点像。它又很短,没有别的内容稀释这种"泛泛的相关",于是在很多问题上都排到了第一。用 e5 时,这个角色换成了 logging.md 的开头,也是一段泛泛地讲 httpx 的文字。
这种现象很常见:很短的、概括性的块,容易在向量检索里霸榜。可以在切分时把太短的块合并掉,也可以用下一课的方法补救。
意思对了,但不精确。"服务器要求 Digest 认证怎么办"排第一的是讲 SSL 证书校验的块。在嵌入模型看来,"认证"和"证书校验"都属于"安全、身份验证"这一类,意思挺近。但用户问的是一个具体的认证方式 Digest,这个词本身才是关键。第 01 模块第 5 课也见过同样的问题:嵌入搞不清是 httpx 还是 requests。向量擅长抓大意,不擅长对准一个具体的名字。
评估规则本身也有局限。"异步发请求应该用哪个类"排第一的其实就是 async.md,只是那一块是这篇文档的开头,里面恰好没有出现 AsyncClient 这个词,按规则判为没找到。它算不算找对了,是可以讨论的。评估的规则定得越严,分数越低,但也越可信。
第 6 课会更系统地讲怎么评估,下一课先解决精确匹配和跨语言的问题。
什么时候需要向量数据库
我们的 196 个向量,放在一个 numpy 数组里,占不到 1MB 内存,每次检索是一次矩阵乘法,瞬间完成。
向量多到几十万、上百万个,或者需要这些功能时,才值得用专门的向量数据库(比如 Chroma、Qdrant、Milvus,或者 PostgreSQL 的 pgvector 扩展):
- 数据量大。几百万个向量一个个比较太慢,数据库用近似最近邻(ANN)算法,牺牲一点点准确度换来快得多的速度。
- 需要持久化和增量更新。文档不断增加、修改、删除,每次都重建整个矩阵不现实。
- 需要按条件过滤。比如"只在 2.0 版本的文档里搜"、"只搜这个用户有权限看的文档"。
几千、几万个块的规模,numpy 就够了,把矩阵用 np.save 存到文件里,下次直接加载,省掉重新计算向量的时间。先用最简单的办法,等真的遇到了上面这些问题再换。
练习
- 在
vector_search.py里,把建好的矩阵用np.save存到文件,下次启动时如果文件存在就直接加载,比较两次的启动时间。 - 修改切分函数,丢掉或者合并短于 200 个字符的块,重新运行评估。万能块的问题有没有减轻?前 5 名命中率变了多少?
- 给
eval_qa.jsonl再加 5 道你自己的题,每道题都要去文档里确认答案的位置和关键词。
自测
1. 为什么把向量都归一化之后,用点积就能算出余弦相似度?
余弦相似度等于两个向量的点积除以它们长度的乘积。归一化之后每个向量的长度都是 1,分母是 1,点积就直接等于余弦相似度。这样整个检索只需要一次矩阵乘法。
2. 评估检索效果时,为什么用"文件 + 关键词"判断是否命中,而不用块的编号?
块的编号取决于切分方法,换一种切法编号就全变了。文件名和关键词和切分方法无关,同一套评估题可以用来比较不同的切分方法和检索方法。
3. 什么是"万能块"?它为什么会在向量检索里排得很靠前?
一些很短、内容很概括的块,比如一句"用 httpx.Client() 代替 requests.Session()"的提示。它和很多问题都有一点点相关,又没有其他内容稀释这种相关,所以在大量不同的问题下都排到了前面,挤掉了真正相关的块。
提问与讨论
这一课没看懂的地方,在这里问。看到别人的问题,也欢迎你来回答。
提问 +3 积分,回答别人 +6 积分。内容经审核后公开。
正在加载讨论…