從零寫一個向量檢索
用 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 點。內容經審核後公開。
正在載入討論…