Hybrid-Suche mit Qdrant: was über BM25 + Dense + Bild keiner sagt
Was du wirklich verkabelst, wenn du Stichwort, Dense-Vektor und Bild-Embedding zu einem Ranker fusionierst — Named Vectors, Fusion, Drift und der Tag, an dem die türkische Suche an Apostrophen scheiterte.
Wir betreiben eine Suchmaschine über einen mehrsprachigen Produktkatalog — Türkisch, Englisch, Mazedonisch — mit etwa 7,8 Millionen aktiven SKUs und in der Spitze rund 90.000 Anfragen pro Stunde. Das Frontend hat eine Suchleiste. Das Backend führt drei Retrieval-Signale parallel aus und fusioniert sie. Keines der drei ist allein gut genug. Zusammen sind sie ordentlich.
Dieser Beitrag sind die Feldnotizen, die mir vor achtzehn Monaten jemand hätte schreiben sollen.
Warum ein Signal nicht reicht
Die saubere Erzählung lautet: Dense-Vektoren schlagen Stichwortsuche, weil sie Semantik verstehen. Die saubere Erzählung ist in Produktion meist falsch.
- BM25 gewinnt bei Markennamen, Modellnummern, SKU-Codes — überall, wo der Nutzer einen exakten String eintippt, an den er sich erinnert.
- Dense-Vektoren gewinnen bei Intent ("warme Jacke fürs Kind" → Fleece-Mäntel, Größe 6–12, Kinderkategorie), verlieren aber bei Apostrophen, Ligaturen und türkischen Suffixen.
- Bild-Embeddings gewinnen bei Anfragen, die du nicht für Anfragen hieltest — ein Foto, ein Screenshot, eine vage visuelle Idee ("so eine Tasche etwa").
Jedes Signal rettet, was die anderen beiden fallen lassen. Die Kunst liegt in der Fusion.
Die Qdrant-Form
Wir speichern alle drei Vektoren auf demselben Point — Qdrants Named-Vectors-Feature. Eine Collection, ein Upsert-Pfad, drei Retrieval-Modi:
from qdrant_client import QdrantClient, models
client = QdrantClient(url=QDRANT_URL, api_key=QDRANT_API_KEY)
client.create_collection(
collection_name="products",
vectors_config={
"text": models.VectorParams(size=512, distance=models.Distance.COSINE),
"image": models.VectorParams(size=512, distance=models.Distance.COSINE),
},
sparse_vectors_config={
"bm25": models.SparseVectorParams(
modifier=models.Modifier.IDF,
),
},
)Die Dense-Vektoren haben 512 Dimensionen, aus einem mehrsprachigen Sentence-Transformer, der auf unseren Kategoriebaum feinabgestimmt ist. Der Bildvektor stammt aus demselben Modell, das im reinen Bildmodus läuft (damit Cross-Modal-Retrieval funktioniert — man kann mit Text auf ein Foto suchen). BM25 ist Qdrants nativer Sparse-Index mit IDF-Re-Weighting.
Drei Vektoren, ein Point, kein JOIN. Das hat mich überrascht — bis ich drei separate Collections pflegen wollte und der Drift zwischen ihnen zwei Wochen meines Lebens fraß.
Retrieval
Eine Anfrage geht als drei parallele Suchen raus, dann fusionieren wir:
async def hybrid_search(query: str, limit: int = 50) -> list[Hit]:
text_vec, sparse, image_vec = await asyncio.gather(
embed_text(query),
sparse_bm25(query),
embed_image_from_text(query), # Text → Bild-Embedding über CLIP-artigen Head
)
results = await client.query_points(
collection_name="products",
prefetch=[
models.Prefetch(query=text_vec, using="text", limit=200),
models.Prefetch(query=sparse, using="bm25", limit=200),
models.Prefetch(query=image_vec, using="image", limit=200),
],
query=models.FusionQuery(fusion=models.Fusion.RRF),
limit=limit,
)
return results.pointsReciprocal Rank Fusion (RRF) ist überraschend schwer zu schlagen. Wir haben gewichtete Score-Fusion, gelernte Reranker und einen kleinen LLM-Rerank-Pass probiert — RRF lieferte 90 % des Gewinns für 5 % der Komplexität. Auf Top-50-Treffern bei Premium-Anfragen läuft ein Gemini-Rerank, aber für den Long Tail ist RRF das, was ausgeliefert wird.
Was tatsächlich bricht
Jetzt der Teil, den die Tutorials überspringen.
Apostrophe. Türkische Nutzer tippen Adidas'in. Dein Sparse-Tokenizer zerlegt das in adidas + in. Dein Dense-Modell bleibt unberührt. Dein Sparse-Score fällt um 60 %. Lösung: Ein Tokenizer-Pre-Pass, der Possessivsuffixe pro Sprache normalisiert.
Synonyme ohne Überlappung. Das englische "sneaker" entspricht semantisch dem türkischen "spor ayakkabı" — aber BM25 sieht sie als unterschiedliche Tokens. Dense rettet dich hier, aber nur wenn dein Modell mehrsprachig trainiert wurde. Wir nutzen einen mehrsprachigen Encoder und akzeptieren, dass BM25 bei türkischen Markennamen die Last trägt.
Bildanfragen, die keine Bilder sind. Tippt ein Nutzer "rotes Kleid für eine Hochzeit", schicken wir den Text durch eine Text→Bild-Projektions-Head und erhalten einen Anfragevektor im Bildraum. Das macht den Bildindex bei Textanfragen nützlich. Ohne diesen Schritt wäre der Bildindex totes Gewicht, sobald der Nutzer kein Foto hochlädt.
Drift. Das schärfste Messer. Das Encoder-Modell, das den Katalog vektorisiert, und das Encoder-Modell, das die Anfrage vektorisiert, müssen dieselbe Version sein. Sobald sie auseinanderlaufen, kollabiert Recall lautlos. Wir pinnen die Modellversion an zwei Stellen — im Batch-Vectorizer-Service und im Search-Service — und re-vektorisieren den gesamten Katalog beim Bump.
// Im Search-Service — Start verweigern, wenn die Embedding-Version
// nicht mit der übereinstimmt, mit der der Katalog indexiert wurde.
func mustEmbedderVersion(t *testing.T) {
expected, err := qdrantPayload.Get("embedder_version").(string)
if err != nil || expected != embedder.Version {
t.Fatalf("embedder drift: catalogue=%s service=%s",
expected, embedder.Version)
}
}Du kannst diesen Check beim Start ausführen (machen wir) oder akzeptieren, dass du den Drift über verärgerte Kunden entdeckst (auch ein gültiger Weg, nur weniger angenehm).
Trade-offs
Hybrid-Suche kostet dich:
- Dreifache Embedding-Compute beim Indexieren. Durch Batching und Async-Pipelines abgemildert, aber echtes Geld.
- Dreifachen Speicher. Qdrant komprimiert gut, doch 7,8M Points × 3 × 512 Floats sind echter Plattenplatz.
- Komplexität zur Query-Zeit. Drei parallele Aufrufe, Fusion, optionales Rerank. Latency ist okay — Fan-Out ist schnell — aber die Failure-Surface ist breiter.
Du brauchst das alles nicht, bis du Folgendes hast:
- Einen mehrsprachigen Katalog
- Einen Anfragemix, bei dem BM25 messbar Dinge übersieht
- Genug Anfragevolumen, das die Engineering-Kosten rechtfertigt
Ist dein Katalog rein englisch und 10.000 SKUs groß, schlägt dich BM25 mit einer ordentlichen Synonym-Tabelle ein Jahr lang. An dem Tag, an dem es das nicht mehr tut, wirst du es wissen.
Was ich neuen Kollegen immer sage
Das Suchproblem ist nicht das Modell. Es ist das Chunking. Es ist der Tokenizer. Es ist der Drift zwischen zwei Services, die dieselbe Version sein sollten. Die Vektoren sind der einfache Teil. Die Pipeline, die sie Tag für Tag synchron hält, während jede Minute neue Produkte eingespielt werden — das ist der Teil, der einen Senior braucht.
Das Modell rechnet mit Zahlen, die ihm gegeben werden. Sind die Zahlen falsch, ist die Rechnung falsch. Mehr ist es nicht.
// wenn du schon hier bist
- 9 Min Lesezeit
Agentische KI ist meistens while(true) mit Vibes
Produktionserfahrungen mit autonomen Agenten in lang laufenden Schleifen, Fallback-Pattern, die wirklich funktionieren, und der Tag, an dem dein Agent 47-mal hintereinander einen Retry beschloss.
agentic-aiproductionengineering-lessons - 8 Min Lesezeit
React Native, der Native Driver und der Jank, den du endlich fühlen kannst
Wann useNativeDriver dir wirklich etwas bringt, wann er dich anlügt, und wie du den verlorenen Frame findest, der deinen Scanner-Bildschirm langsam wirken lässt.
react-nativeperformancemobile