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, bgem3

Schritt 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 automatisch

Wichtig: 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-PatternWarum
Single-Provider-Spalte, Provider-Switch via Re-Embedjeder Switch kostet Re-Embed-Zeit + Bedrock-Cost; kein A/B moeglich
HNSW-Indizes ohne CONCURRENTLY auf grosser Tabelle erstellenACCESS EXCLUSIVE LOCK, Service down waehrend Build
Bake-Off ohne Eval-SetProvider-Wahl wird Bauchgefuehl; nicht reproduzierbar; Kunde fragt „warum genau” und es gibt keine Antwort
Tote Provider-Spalten in Prod lassenStorage + Index-Maintenance pro INSERT, schwer aus tabelle wieder rauszukriegen
Provider-spezifisches Body-Format im Caller statt im Providerjeder Schalter-Punkt im Code muss alle Provider kennen

Verwandte Patterns

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