Im Einstiegs-Artikel habe ich gezeigt, dass meine 1.500 Notizen semantisch durchsuchbar wurden. Hier zeige ich, wie: Wie Text zu einem 1024-dimensionalen Vektor wird, warum normalisierte Vektoren plus Skalarprodukt die Bedeutung messen, wie man sinnvoll chunkt — und warum reines numpy bei dieser Größe völlig reicht. Plus die wichtigste Lektion: semantisch ist nicht numerisch.
Was ist ein Embedding?
Ein Embedding-Modell nimmt einen Text und gibt eine feste Zahlenreihe zurück — bei bge-m3 sind das 1024 Fließkommazahlen, ein Punkt in einem 1024-dimensionalen Raum. Das Entscheidende ist nicht die Zahl selbst, sondern die Geometrie: Das Modell ist so trainiert, dass Texte mit ähnlicher Bedeutung nah beieinander landen, unähnliche weit auseinander.
Damit wird Bedeutung zu Abstand. „Leute mit Reichweite überzeugen“ und ein Text über Influencer-Anschreiben teilen kein einziges Schlüsselwort — aber ihre Vektoren zeigen in fast dieselbe Richtung, weil das Modell ihren Sinn gelernt hat. Genau dieser Treffer war mein Semantik-Beweis: Das Wort „Influencer“ kam in der Frage nicht vor, der richtige Text wurde trotzdem gefunden.
Cosine-Similarity: warum normalisieren?
Wie misst man „zeigt in dieselbe Richtung“? Über den Winkel zwischen zwei Vektoren — die Cosine-Similarity. Zwei Vektoren mit Winkel 0 sind inhaltlich maximal ähnlich (Cosinus = 1), stehen sie senkrecht, ist die Ähnlichkeit 0.
Der Cosinus zwischen zwei Vektoren ist ihr Skalarprodukt geteilt durch das Produkt ihrer Längen. Der Trick: Wenn ich jeden Vektor einmalig auf Länge 1 normalisiere, fällt der Nenner weg — das blanke Skalarprodukt ist dann schon der Cosinus. Das spart bei jeder Suche eine Division pro Chunk und macht die ganze Suche zu einer einzigen Matrix-Multiplikation.
def embed(texts):
body = dict(model="bge-m3", input=texts)
vecs = ollama_post("/api/embed", body) # laeuft auf dem Mac mini
a = np.array(vecs, dtype=np.float32)
# auf Laenge 1 bringen -> Skalarprodukt == Cosinus
return a / np.linalg.norm(a, axis=1, keepdims=True) Genau deshalb steht in der Suche unten nur mat @ qv — kein Normalisieren mehr zur Laufzeit, weil schon alles auf Länge 1 liegt.
Chunking: warum nicht eine Datei = ein Vektor?
Man könnte jede Notiz als Ganzes einbetten. Macht man aber nicht, aus zwei Gründen. Erstens hat jedes Embedding-Modell ein Kontext-Limit — eine lange Datei wird abgeschnitten, der Rest fällt einfach unter den Tisch. Zweitens, und wichtiger: Ein einzelner Vektor über eine ganze Datei ist ein Durchschnitt aller Themen darin. Eine Notiz, die Marketing und Routing-Code behandelt, ergäbe einen verwaschenen Mittelpunkt, der zu keiner der beiden Fragen gut passt.
Deshalb schneide ich an Überschriften. Jeder Abschnitt wird ein eigener, thematisch scharfer Vektor — und ich speichere zu jedem Chunk Metadaten: Dateiname und die Überschrift, unter der er stand. So weiß ich beim Treffer sofort, woher er kommt.
def chunk_md(text, fname, overlap=1):
chunks, header, buf = [], "Anfang", []
def flush():
if buf:
body = "\n".join(buf)
chunks.append(dict(file=fname, header=header, text=body))
for line in text.split("\n"):
if line.startswith("#"):
flush()
# letzte Zeile als Overlap mitnehmen -> Kontext ueber Grenze
tail = buf[-overlap:] if buf else []
header, buf = line.strip("# "), list(tail)
else:
buf.append(line)
flush()
return chunks Das Overlap ist eine kleine Versicherung: Ein Gedanke, der genau auf einer Abschnittsgrenze steht, würde sonst zerrissen. Indem der neue Chunk die letzte Zeile des vorigen mitnimmt, bleibt der Zusammenhang über die Grenze hinweg erhalten.
Die Suche selbst
Mit normalisierten Vektoren in einer Matrix mat (eine Zeile pro Chunk) ist die Suche fast schon banal: Frage einbetten, mit der ganzen Matrix multiplizieren, die höchsten Werte zurückgeben.
def search(query, k=4):
qv = embed([query])[0] # Frage -> Vektor (Laenge 1)
scores = mat @ qv # Cosinus zu allen Chunks
top = np.argsort(-scores)[:k]
return [meta[i] for i in top] # Dateiname + Header + Text Über 916 Chunks aus dem Prototyp-Lauf läuft dieses Skalarprodukt in Millisekunden. Eine Suche ist eine einzige Matrix-Multiplikation gegen ein paar Megabyte Zahlen — dafür braucht es keine Infrastruktur, nur RAM.
Die wichtigste Lektion: semantisch ist nicht numerisch
Hier ist die Falle, über die jeder stolpert. Embeddings erfassen Thema und Bedeutung hervorragend — aber sie verstehen keine Zahlengrößen. Frage ich „welche Tour hat die meisten Haarnadelkurven?“, findet die Vektorsuche zuverlässig alle Texte, in denen es um viele Haarnadelkurven geht. Welche davon nun konkret die meisten hat, sagt die Geometrie nicht — „viele“ und „die meisten“ liegen im Bedeutungsraum praktisch übereinander.
Die Lösung heißt Hybrid-Retrieval: Vektorsuche fürs Thema, klassische Filter und Sortierung für alles Zählbare. Man legt zählbare Fakten als Metadaten neben den Chunk und kombiniert beides — erst semantisch eingrenzen, dann numerisch ranken.
def hybrid(query, k=4, min_kurven=0):
qv = embed([query])[0]
scores = mat @ qv
order = np.argsort(-scores)
hits = []
for i in order:
m = meta[i]
if m.get("kurven", 0) >= min_kurven: # harter Metadaten-Filter
hits.append(m)
if len(hits) == k:
break
# numerisch nachsortieren, was die Semantik nicht kann
return sorted(hits, key=lambda m: -m.get("kurven", 0)) Merksatz: Die Vektorsuche bringt die richtigen Kandidaten, die Metadaten bringen die richtige Reihenfolge. Wer beides verwechselt, bekommt überzeugend klingende, aber falsche Treffer.
Warum numpy reicht — und wann nicht
Bei rund 1.500 Notizen lebt der gesamte Index bequem im Arbeitsspeicher. Im Prototyp waren es 916 Chunks, der komplette Index ~4 MB. Ein mat @ qv über so eine Matrix ist von einer echten Vektor-Datenbank zeitlich nicht zu unterscheiden — der Brute-Force-Vergleich gegen alle Vektoren ist hier schlicht schnell genug. Jede zusätzliche Abhängigkeit wäre Ballast.
Eine echte Vektor-DB wie sqlite-vec oder ChromaDB lohnt sich, wenn drei Dinge zusammenkommen: die Chunk-Zahl geht in die Hunderttausende oder Millionen (dann braucht es approximative Nachbarsuche statt Brute Force), der Index passt nicht mehr in den RAM, oder man will Vektoren, Metadaten und Filter transaktional in einem persistenten Store halten statt in einer .npy-Datei. Bis dahin ist numpy nicht der Kompromiss, sondern die richtige Wahl.
Inkrementelles Re-Indexing
Der Index einmal in 67 Sekunden zu bauen ist akzeptabel — bei jeder Suche neu einbetten wäre Verschwendung. Embedding ist der teure Schritt, also bette ich nur ein, was sich geändert hat. Pro Datei speichere ich einen Hash ihres Inhalts; beim nächsten Lauf wird nur neu eingebettet, wessen Hash sich verschoben hat.
def needs_reindex(path, cache):
h = hashlib.sha256(read_bytes(path)).hexdigest()
if cache.get(path) == h:
return False # unveraendert -> Embedding wiederverwenden
cache[path] = h
return True # neu oder geaendert -> neu einbetten So kostet der Erstaufbau einmal ein paar Minuten über alle Notizen, danach zieht ein Update nur die wenigen geänderten Dateien nach — in Sekunden statt Minuten.
Wo die Wahrheit liegt
Die ganze Mechanik kocht auf einen Satz herunter: Bedeutung wird zu Richtung, Ähnlichkeit zu einem Skalarprodukt, und alles Zählbare gehört in die Metadaten daneben. Das Sprachmodell, das daraus am Ende einen Satz formt, ist nur die Kür — die eigentliche Intelligenz steckt in guten Chunks und guten Vektoren. Datenschutz, Hardware und die Dienst-Architektur dahinter sind eigene Geschichten; hier ging es um den Motor.
Transparenzhinweis: Dieses Projekt ist selbst finanziert. Die genannten Werkzeuge (Ollama, bge-m3, numpy) sind quelloffen; die Hardware habe ich selbst angeschafft. Es bestehen keine bezahlten Kooperationen mit den genannten Herstellern. Alle Zahlen stammen aus einem echten Testlauf am 17. Juni 2026.