Vektorsuche von Grund auf
Mit numpy in ein paar Dutzend Zeilen eine Vektorsuche schreiben – Chunks in eine Vektormatrix verwandeln, bei der Anfrage Ähnlichkeiten berechnen und die besten nehmen. Dann mit 20 Fragen bewerten: Nur die Hälfte wird richtig gefunden, und wir sehen, woran es liegt.
- Etwa 45 Minuten
- Niveau: Fortgeschritten
- Getestet: 2026-09-14 bge-small-zh-v1.5, multilingual-e5-small
Code und Programmausgaben stehen genau so da, wie sie gelaufen sind – Kommentare und Ausgaben sind daher auf Chinesisch.
Modul 01, Lektion 5 hat Embeddings erklärt: Texte mit ähnlicher Bedeutung haben ähnliche Vektoren. Die letzte Lektion hat die httpx-Dokumentation in 196 Chunks zerlegt. Beides zusammen ergibt eine semantische Suchmaschine: vorab den Vektor jedes Chunks berechnen, bei einer Frage deren Vektor berechnen und die ähnlichsten Chunks finden.
Viele Tutorials lassen dich direkt eine Vektordatenbank installieren. Diese Lektion verzichtet vorerst darauf und schreibt die Suche selbst mit numpy, ein paar Dutzend Zeilen. Danach wirst du sehen, dass der Kern der Vektorsuche eine einzige Matrixmultiplikation ist. Anschließend bewerten wir sie mit 20 Fragen und schauen, wie brauchbar sie wirklich ist.
Den Index bauen
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 gibt alle Chunks auf einmal an das Embedding-Modell und bekommt eine Matrix zurück: 196 Chunks mit je einem 512-dimensionalen Vektor, also eine Matrix mit 196 Zeilen und 512 Spalten. normalize_embeddings=True skaliert jeden Vektor auf Länge 1, damit die Ähnlichkeit später nur ein Skalarprodukt ist.
Zu jedem Chunk wird festgehalten, aus welcher Datei er stammt; damit beurteilen wir später, ob die Suche richtig lag, und Lektion 5 nutzt es, um dem Nutzer die Quelle der Antwort zu nennen.
Suchen
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 ist eine Matrix mal einem Vektor: jede der 196 Zeilen bildet ein Skalarprodukt mit dem Fragevektor, und die Ähnlichkeit der Frage zu allen Chunks ist auf einmal berechnet. np.argsort(-scores) sortiert absteigend nach Ähnlichkeit, und man nimmt die ersten k.
Das ist eine vollständige Vektorsuche. Probieren wir es:
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...
Eine chinesische Frage hat den Abschnitt der englischen Dokumentation über Zertifikatsprüfung gefunden. Der Indexaufbau dauerte 15,7 Sekunden, der größte Teil davon für das erste Laden des Modells und die Berechnung der 196 Vektoren. Die Suche selbst kostet praktisch keine Zeit.
Woran erkennt man, ob sie gut ist
Dass eine Frage richtig gefunden wird, beweist nichts. Für eine systematische Bewertung braucht man eine Reihe von Fragen mit Musterlösung.
Ich habe 20 chinesische Fragen in code/04-rag/eval_qa.jsonl vorbereitet. Zu jeder ist markiert, in welcher Datei die Antwort stehen sollte, und ein Stichwort: Ein gefundener Chunk zählt nur als richtig, wenn er aus dieser Datei stammt und dieses Stichwort enthält.
{"question": "怎么把超时完全关掉,让请求一直等下去?", "file": "advanced/timeouts.md", "keyword": "timeout=None"}
{"question": "服务器要求 Digest 认证怎么办?", "file": "advanced/authentication.md", "keyword": "DigestAuth"}
{"question": "有些域名不想走代理,环境变量怎么设置?", "file": "environment_variables.md", "keyword": "NO_PROXY"}
……
„Datei + Stichwort“ statt „Chunk-Nummer“, weil sich bei einer anderen Zerlegung alle Chunk-Nummern ändern, Datei und Stichwort aber nicht. So kann man dasselbe Fragenset für eine andere Zerlegung direkt wiederverwenden. Jedes Stichwort habe ich per Skript geprüft; es kommt tatsächlich in der jeweiligen Datei vor.
Dann zählt man ein paar Kennzahlen:
- Trefferquote auf Platz 1: Anteil der Fragen, bei denen der erstplatzierte Chunk richtig ist.
- Trefferquote in den Top 3 und Top 5: Anteil, bei dem unter den ersten ein richtiger ist. RAG gibt meist mehrere Chunks gemeinsam an das Modell, daher ist diese Kennzahl wichtiger.
- MRR (Mean Reciprocal Rank): Ein richtiger Chunk auf Platz 1 bringt 1 Punkt, auf Platz 2 1/2, auf Platz 3 1/3, nicht gefunden 0, gemittelt über alle Fragen. Das fasst „gefunden oder nicht“ und „wie weit vorn“ zusammen.
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 nimmt eine Suchfunktion entgegen, nicht einen bestimmten Index. Auch die Stichwort- und die hybride Suche der nächsten Lektion lassen sich damit bewerten, und die Ergebnisse sind direkt vergleichbar.
Ergebnis: nur die Hälfte
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
Bei 20 Fragen findet sich nur bei der Hälfte in den Top 5 ein richtiger Chunk. In einem RAG-System bekäme das Modell also bei der Hälfte der Fragen irrelevante Unterlagen.
Probieren wir ein anderes Embedding-Modell. intfloat/multilingual-e5-small ist speziell für viele Sprachen trainiert:
python code/04-rag/vector_search.py intfloat/multilingual-e5-small
20 道题:第 1 名命中 30%,前 3 名命中 50%,前 5 名命中 65%,MRR 0.418
Etwas besser: Die Top-5-Trefferquote steigt von 50 % auf 65 %, ist aber immer noch nicht zufriedenstellend.
Woran es liegt
Sprachgrenzen. Die Fragen sind chinesisch, die Dokumente englisch. bge-small-zh-v1.5 ist vor allem für Chinesisch trainiert und versteht Englisch nur begrenzt. multilingual-e5-small ist mehrsprachig trainiert, daher etwas besser. Chinesische Fragen und englische Dokumente in denselben Vektorraum abzubilden, ist aber grundsätzlich schwerer als innerhalb einer Sprache.
Der „Allzweck-Chunk“. Schaut man sich die nicht gefundenen Fragen genau an, steht immer wieder derselbe Chunk auf Platz 1: ein !!! hint aus advanced/clients.md. Sein vollständiger Text:
!!! hint
If you are coming from Requests, `httpx.Client()` is what you can use instead of `requests.Session()`.
Nur 115 Zeichen, darin zugleich httpx, requests und Client, und es geht um „wie man httpx benutzt“ an sich. Für das Embedding-Modell ähnelt er fast jeder Frage der Art „wie mache ich in httpx …“. Und weil er so kurz ist, verwässert kein anderer Inhalt diese „vage Relevanz“, also landet er bei vielen Fragen auf Platz 1. Mit e5 übernimmt der Anfang von logging.md diese Rolle, ebenfalls ein Abschnitt, der allgemein über httpx spricht.
Das ist ein häufiges Phänomen: sehr kurze, allgemeine Chunks dominieren leicht die Vektorsuche. Man kann zu kurze Chunks schon beim Zerlegen zusammenlegen oder mit den Methoden der nächsten Lektion gegensteuern.
Sinngemäß richtig, aber ungenau. Bei „was tun, wenn der Server Digest-Authentifizierung verlangt“ steht der Chunk über SSL-Zertifikatsprüfung auf Platz 1. Für das Embedding-Modell gehören „Authentifizierung“ und „Zertifikatsprüfung“ beide zur Kategorie „Sicherheit, Identitätsprüfung“, die Bedeutungen sind nah. Der Nutzer fragt aber nach einem konkreten Verfahren, Digest, und genau dieses Wort ist entscheidend. Dasselbe Problem gab es schon in Modul 01, Lektion 5, als Embeddings httpx und requests nicht auseinanderhielten. Vektoren erfassen den Grundsinn gut, treffen aber einen konkreten Namen schlecht.
Auch die Bewertungsregel hat Grenzen. Bei „welche Klasse sollte man für asynchrone Anfragen nehmen“ steht tatsächlich async.md auf Platz 1, nur ist dieser Chunk der Anfang der Seite und enthält zufällig das Wort AsyncClient nicht, also gilt er nach der Regel als nicht gefunden. Ob das richtig ist, lässt sich diskutieren. Je strenger die Bewertungsregel, desto niedriger die Punktzahl, aber auch desto glaubwürdiger.
Lektion 6 behandelt die Bewertung systematischer; die nächste Lektion löst zuerst die Probleme mit exakten Treffern und Sprachgrenzen.
Wann man eine Vektordatenbank braucht
Unsere 196 Vektoren liegen in einem numpy-Array, belegen weniger als 1 MB Speicher, und jede Suche ist eine Matrixmultiplikation, sofort erledigt.
Eine eigene Vektordatenbank (etwa Chroma, Qdrant, Milvus oder die PostgreSQL-Erweiterung pgvector) lohnt sich erst bei Hunderttausenden oder Millionen Vektoren oder wenn man diese Funktionen braucht:
- Große Datenmengen. Millionen Vektoren einzeln zu vergleichen, ist zu langsam; Datenbanken nutzen Algorithmen für approximative nächste Nachbarn (ANN) und tauschen ein kleines bisschen Genauigkeit gegen viel höhere Geschwindigkeit.
- Persistenz und inkrementelle Aktualisierung. Werden Dokumente ständig hinzugefügt, geändert, gelöscht, ist es unrealistisch, jedes Mal die ganze Matrix neu zu bauen.
- Filtern nach Bedingungen. Etwa „nur in der Dokumentation zu Version 2.0 suchen“ oder „nur Dokumente durchsuchen, die dieser Nutzer sehen darf“.
Bei einigen tausend bis zehntausend Chunks reicht numpy; die Matrix mit np.save in eine Datei schreiben und beim nächsten Mal direkt laden, spart die Neuberechnung. Nimm zuerst das Einfachste und wechsle erst, wenn du wirklich auf die genannten Probleme stößt.
Übungen
- Speichere in
vector_search.pydie gebaute Matrix mitnp.savein eine Datei und lade sie beim nächsten Start direkt, falls die Datei existiert. Vergleiche die Startzeiten. - Ändere die Zerlegungsfunktion so, dass Chunks unter 200 Zeichen verworfen oder zusammengelegt werden, und lass die Evaluation erneut laufen. Ist das Problem mit dem Allzweck-Chunk kleiner geworden? Wie verändert sich die Top-5-Trefferquote?
- Füg
eval_qa.jsonlfünf eigene Fragen hinzu und prüf bei jeder in der Dokumentation, wo die Antwort steht und welches Stichwort passt.
Selbsttest
1. Warum ergibt das Skalarprodukt die Kosinus-Ähnlichkeit, sobald alle Vektoren normiert sind?
Die Kosinus-Ähnlichkeit ist das Skalarprodukt zweier Vektoren geteilt durch das Produkt ihrer Längen. Nach der Normierung hat jeder Vektor die Länge 1, der Nenner ist 1, und das Skalarprodukt ist direkt die Kosinus-Ähnlichkeit. So braucht die ganze Suche nur eine Matrixmultiplikation.
2. Warum beurteilt man bei der Bewertung der Suche Treffer nach „Datei + Stichwort“ statt nach Chunk-Nummer?
Chunk-Nummern hängen von der Zerlegungsmethode ab; mit einer anderen Zerlegung ändern sie sich alle. Dateinamen und Stichwörter sind davon unabhängig, sodass man mit demselben Evaluationsset verschiedene Zerlegungs- und Suchmethoden vergleichen kann.
3. Was ist ein „Allzweck-Chunk“, und warum steht er in der Vektorsuche so weit vorn?
Ein sehr kurzer, allgemein gehaltener Chunk, etwa ein einzeiliger Hinweis „httpx.Client() statt requests.Session() verwenden“. Er ist mit vielen Fragen ein bisschen verwandt, und kein anderer Inhalt verwässert diese Verwandtschaft, also landet er bei sehr vielen Fragen weit vorn und verdrängt die wirklich relevanten Chunks.
Fragen und Diskussion
Hängst du in dieser Lektion fest? Frag hier. Und wenn du die Frage von jemandem beantworten kannst, tu es gern.
Eine Frage bringt 3 Punkte, eine Antwort 6. Beiträge erscheinen nach der Prüfung.
Diskussion wird geladen…