Multi-Embedding-Bake-Off
Standard-Pattern wenn du fuer einen Such-Use-Case zwischen mehreren Embedding-Providern entscheiden musst und keine harten Daten hast. Vermeidet die typische Falle „im Plan auf Provider X commited, eingebaut, danach gemerkt dass Y besser gewesen waere — Re-Embed teuer”.
Erstmals dokumentiert aus dem Icking-Rebuild. Im Bake-Off (Unit 6 dort) laufen Cohere v3, Cohere v4, Titan v2 und BGE-M3 fine-tuned parallel gegen 200-300 kuratierte Queries.
Wann dieses Pattern einsetzen
- Du baust eine neue Such-Pipeline und weisst nicht welcher Provider gewinnt
- Recall ist business-kritisch (10 % Differenz = signifikant fuer Kunde)
- Wechsel-Cost spaeter ist hoch (Re-Embed von 30k+ Records dauert + kostet)
- Mehrere Provider sind realistisch verfuegbar (z.B. via Bedrock EU)
Nicht einsetzen wenn:
- Single-Provider klar gesetzt (z.B. nur Cohere verfuegbar in Region)
- Datensatz so klein dass Re-Embed billig ist (< 5k Records)
- Recall-Unterschiede empirisch < 2 % zu erwarten
Das Pattern
Schritt 1: Parallele Spalten in Migration
-- db/migrations/002_create_leistungskatalog.sql (Auszug)
CREATE TABLE app.leistungskatalog (
id BIGINT PRIMARY KEY,
-- Stammdaten ...
-- 4 parallele Embedding-Spalten, alle 1024 dim, alle nullable
langtext_embedding_cohere_v3 VECTOR(1024),
langtext_embedding_cohere_v4 VECTOR(1024),
langtext_embedding_titan_v2 VECTOR(1024),
langtext_embedding_bgem3 VECTOR(1024),
-- Multi-Field-Fusion-Test (optional gefuellt)
kurztext_embedding_cohere_v3 VECTOR(1024),
suchwort_embedding_cohere_v3 VECTOR(1024),
-- Hybrid-Search Foundation
tsv_combined tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('german', coalesce(kurztext,'')), 'A') ||
setweight(to_tsvector('german', coalesce(suchwort,'')), 'A') ||
setweight(to_tsvector('german', coalesce(rtftext,'')), 'B')
) STORED
);
-- HNSW pro Provider — Partial-Index spart Cost solange Spalte NULL
CREATE INDEX ON app.leistungskatalog
USING hnsw (langtext_embedding_cohere_v3 vector_cosine_ops)
WITH (m=16, ef_construction=64)
WHERE langtext_embedding_cohere_v3 IS NOT NULL;
-- ... analog fuer v4, titan_v2, bgem3Schritt 2: Strategy-Interface fuer Provider
Ein EmbeddingProvider-Protocol mit embed_query + embed_documents. Pro Provider eine Implementierung — Provider-spezifisches (Cohere input_type, Titan dimensions, BGE normalize_embeddings) wandert in den Provider, der Aufrufer sieht nur das Interface.
Siehe ~/source/a-icking/inference-service/app/embedding/ als Referenz.
Schritt 3: Bake-Off-Loader (Unit 6)
Pro Provider durch alle 32k Records:
- Embeddings berechnen (Cohere intern sub-batched 96, Titan 25, BGE unlimitiert)
- in die Provider-spezifische Spalte schreiben
- Resume-faehig (—offset N) wenn der Loader crasht
Strategie gegen HNSW-Maintenance-Cost: Indizes VOR Bulk-Load droppen, danach CREATE INDEX CONCURRENTLY neu — 4-10× schneller als HOT-Updates auf bestehendem Index.
Schritt 4: Eval-Set + Recall@k messen
- 200-300 kuratierte Queries mit Ground-Truth-Treffern
- Pro Provider: Recall@1, Recall@5, Recall@10 (Anteil der Queries wo ground-truth im Top-K)
- Latenz pro Query (p50, p95) — Provider mit besserem Recall aber 3× Latenz ist nicht automatisch der Gewinner
- Cost pro 1M Tokens (Bedrock-Provider) bzw. Container-Memory (BGE lokal)
Ergebnis als Tabelle in einem Run-File:
| Provider | Recall@1 | Recall@5 | p50 Latenz | Cost/1M tok |
|---|---|---|---|---|
| cohere_v3 | 0.78 | 0.94 | 120ms | $0.10 |
| cohere_v4 | 0.81 | 0.95 | 180ms | $0.12 |
| titan_v2 | 0.72 | 0.90 | 90ms | $0.02 |
| bgem3_local | 0.85 | 0.96 | 30ms | (Container-Mem) |Schritt 5: Cutover-Switch via Env
# app/config.py
class EmbeddingProvider(str, Enum):
COHERE_MULTILINGUAL_V3 = "cohere_multilingual_v3"
COHERE_V4 = "cohere_v4"
TITAN_V2 = "titan_v2"
BGE_M3_LOCAL = "bge_m3_local"
class Settings(BaseSettings):
embedding_provider: EmbeddingProvider = EmbeddingProvider.COHERE_MULTILINGUAL_V3
# ...Wahl via EMBEDDING_PROVIDER Env-Var im Container. Factory laedt den passenden Provider, Search-Layer schaltet auf die passende DB-Spalte.
Schritt 6: Cleanup-Migration
Nach Cutover: separate Migration die nicht-gewaehlte Spalten + Indizes droppt.
-- db/migrations/003_drop_unused_embedding_columns.sql
ALTER TABLE app.leistungskatalog DROP COLUMN langtext_embedding_cohere_v4;
ALTER TABLE app.leistungskatalog DROP COLUMN langtext_embedding_titan_v2;
ALTER TABLE app.leistungskatalog DROP COLUMN langtext_embedding_bgem3;
-- Indizes werden mit-gedropped automatischWichtig: das Skeleton-File jetzt schon einchecken (auch leer mit Reminder-Comment) — sonst lebt das Schema mit toten Spalten ewig weiter.
Was zu vermeiden ist
| Anti-Pattern | Warum |
|---|---|
| Single-Provider-Spalte, Provider-Switch via Re-Embed | jeder Switch kostet Re-Embed-Zeit + Bedrock-Cost; kein A/B moeglich |
| HNSW-Indizes ohne CONCURRENTLY auf grosser Tabelle erstellen | ACCESS EXCLUSIVE LOCK, Service down waehrend Build |
| Bake-Off ohne Eval-Set | Provider-Wahl wird Bauchgefuehl; nicht reproduzierbar; Kunde fragt „warum genau” und es gibt keine Antwort |
| Tote Provider-Spalten in Prod lassen | Storage + Index-Maintenance pro INSERT, schwer aus tabelle wieder rauszukriegen |
| Provider-spezifisches Body-Format im Caller statt im Provider | jeder Schalter-Punkt im Code muss alle Provider kennen |
Verwandte Patterns
- psycopg3-asyncpool-pgvector — wie der Pool die vector-Adapter registriert
- fastapi-lifespan-background-warmup — wie der Provider beim Container-Start initialisiert wird
- _index — die konkrete Bake-Off-Implementierung
Quellen
- Plan-Doku:
~/source/a-icking/docs/plans/2026-05-14-003-feat-bedrock-rebuild-plan.md, Unit 6 - Implementierung Strategy-Interface:
~/source/a-icking/inference-service/app/embedding/ - Schema:
~/source/a-icking/inference-service/db/migrations/002_create_leistungskatalog.sql