10 min čteníJohnny UnarJohnny Unar

Jak vyměnit encoder, aniž si rozbijete retrieval

Upgrady embedding modelů rozbíjejí search potichu a draze. Tohle je rollout playbook, který používáme, když potřebujeme nasadit nový encoder bez toho, aby retrieval šel do háje.

nejdřív ty tiché průšvihy

Výměna encoderu selže tím nejotravnějším způsobem: nic nespadne, dashboardy zůstanou zelené, p95 se klidně i zlepší, a kvalita search stejně spadne ze skály. Embedding prostory nejsou zaměnitelné a každá ANN struktura, kterou jste postavili nad starými vektory, začne najednou vracet špatné sousedy. Týmy tohle přehlížejí, protože s embedding modelem zacházejí jako s patch releasem. Jenže není. Když přepnete z text-embedding-3-large na novější encoder nebo z lokální varianty bge na gte, najednou se změní cosine distance, vztahy nearest neighbors, distribuce score i vstupy do rerankeru.

Viděl jsem to s pgvector nad PostgreSQL, s Qdrantem, s Pinecone i s Weaviate. Vzorec je pokaždé stejný: někdo znovu spočítá embeddingy pro celý corpus, production query pošle na nový index, infra nehlásí nic divného, a pak začnou chodit support tickety typu „search přestal chápat čísla faktur“ nebo „výsledky teď souvisejí jen tak napůl“. To je na tom to nebezpečné. Regrese v retrieval se neprojeví jako pager, ale jako podivné chování produktu.

Ten playbook je jednoduchý a přísný. Verzujte každý embedding, nikdy nepřepisujte vektory na místě, před cutoverem pusťte shadow traffic z reálného provozu, retrieval validujte proti judged setu pomocí cross-encoderu a rollback držte nezávisle na reindexaci. Jestli rollback znamená, že ve dvě ráno znovu generujete 200 milionů embeddingů, tak žádný rollback nemáte. Ve steezr jsme tenhle pattern používali u systémů na zpracování dokumentů i zákaznických portálů, kde je semantické vyhledávání napojené přímo na business workflow. Když to nastavíte správně, další výměna modelu už je normální operativa, ne rituální oběť.

verzujte vektory

Začněte v databázi, protože právě tady lidi dělají první špatné rozhodnutí: upraví embedding sloupec na místě a zničí si jediný čistý srovnávací bod, který měli. Source record musí zůstat stabilní, sémantická identita taky, embeddingy verzujte zvlášť. Váš semantic key musí přežít změny chunkingu, přejmenování dokumentu i výměny modelů. Obvykle používám deterministický klíč postavený nad canonical source path, pořadím chunku a hashem normalizovaného textu, něco jako sha256(doc_id || ':' || chunk_no || ':' || normalized_text).

Schéma v PostgreSQL s pgvector na to bohatě stačí. Aktuální verze pgvector umí HNSW i IVFFlat dost dobře pro většinu production zátěže a Postgres 17 plus pgvector je pro středně velké corpory úplně v pohodě, pokud víte, kde máte limity paměti.

sql
1create extension if not exists vector;
2
3create table document_chunks (
4 id bigserial primary key,
5 semantic_key text not null unique,
6 document_id uuid not null,
7 chunk_no int not null,
8 content text not null,
9 content_sha256 bytea not null,
10 created_at timestamptz not null default now()
11);
12
13create table embedding_versions (
14 id bigserial primary key,
15 version_key text not null unique,
16 encoder_name text not null,
17 encoder_revision text not null,
18 dimensions int not null,
19 distance_metric text not null check (distance_metric in ('cosine', 'l2', 'ip')),
20 indexed_at timestamptz,
21 created_at timestamptz not null default now(),
22 active boolean not null default false
23);
24
25create table chunk_embeddings (
26 chunk_id bigint not null references document_chunks(id) on delete cascade,
27 embedding_version_id bigint not null references embedding_versions(id) on delete cascade,
28 embedding vector(1536) not null,
29 created_at timestamptz not null default now(),
30 primary key (chunk_id, embedding_version_id)
31);
32
33create index concurrently idx_chunk_embeddings_v1_hnsw
34on chunk_embeddings using hnsw (embedding vector_cosine_ops)
35with (m = 16, ef_construction = 200);

Ten poslední index potřebuje v praxi jednu opravu: vytvořte jeden index pro každou embedding verzi, buď přes partitioning chunk_embeddings podle embedding_version_id, nebo tak, že každou verzi dáte do vlastní fyzické tabulky. pgvector netuší, co jste zamýšleli logicky. Jestli nacpete pět verzí modelu do jedné obří tabulky a doufáte, že planner nějak zázračně přestane sahat do špatného grafu, zaděláváte si na bugy v latenci i recall.

Hosted vector DB chtějí stejnou disciplínu. Oddělené collections nebo namespaces pro každou verzi encoderu, neměnná metadata pro semantic keys a control plane záznam, který říká, která verze dostává shadow traffic, která je candidate a která je active. Nenechávejte aplikaci, aby si tohle domýšlela.

dual write, shadow read

Jakmile storage umí držet víc verzí embeddingů, rollout začne být nudný, což přesně chcete. Nový i existující obsah by se měl nějakou dobu indexovat dvojitě, starý encoder plus candidate encoder, a query traffic by se měl mirrorovat do candidate větve, aniž by candidate výsledky zatím ovlivňovaly uživatele. Shadowing se často přeskočí s tím, že už přece máte offline eval. Offline eval je nutný základ, ale nestačí. Reálné rozložení dotazů je vždycky hnusnější než pečlivě vybraný dataset. Uživatelé lepí stack traces, půlku smluvní klauzule, sériová čísla s rozbitými mezerami, příšerné OCR a fragmenty v několika jazycích.

Na to stačí malý shadow proxy. Dejte ho před retrieval službu, callerovi vraťte primary result path, candidate query odpálíte paralelně a zalogujete obě top-k sady, score, latenci a překryv. V Go je to tak 150 řádků, pokud z toho neuděláte zbytečný monument.

go
1type SearchRequest struct {
2 Query string `json:"query"`
3 K int `json:"k"`
4}
5
6type Hit struct {
7 SemanticKey string `json:"semantic_key"`
8 Score float64 `json:"score"`
9}
10
11type SearchResponse struct {
12 Hits []Hit `json:"hits"`
13 Model string `json:"model"`
14 TookMs int64 `json:"took_ms"`
15}
16
17func handler(w http.ResponseWriter, r *http.Request) {
18 var req SearchRequest
19 _ = json.NewDecoder(r.Body).Decode(&req)
20
21 ctx := r.Context()
22 primaryCh := make(chan SearchResponse, 1)
23 shadowCh := make(chan SearchResponse, 1)
24
25 go func() { primaryCh <- callRetriever(ctx, "embed-v2026-02", req) }()
26 go func() { shadowCh <- callRetriever(ctx, "embed-v2026-03", req) }()
27
28 primary := <-primaryCh
29 w.Header().Set("Content-Type", "application/json")
30 _ = json.NewEncoder(w).Encode(primary)
31
32 go func() {
33 shadow := <-shadowCh
34 logComparison(req, primary, shadow)
35 }()
36}

Důležitější než ten proxy je logování. Ukládejte top-10 semantic keys, delta reciprocal rank, Jaccard overlap, rozdíly v latenci a fingerprinty dotazů. Potřebujete odpovědět na hodně konkrétní otázky: drží candidate navigační dotazy, zlepšuje dlouhé dotazy v přirozeném jazyce, nerozpadá se na přesných identifikátorech v kódu, neotravuje OCR bordel nearest neighbors. Když máte slušný shadow záznam, odpovědi z něj dostanete.

Jedno tvrdé pravidlo: neporovnávejte raw similarity score mezi modely. Nemají mezi různými prostory žádný smysl. Porovnávejte rankované výstupy, judged relevance a úspěšnost navazující úlohy. Týmy zbytečně pálí dny tím, že se snaží normalizovat cosine score z nekompatibilních encoderů. Nedělejte to.

suďte to přes reranker

Jestli jen porovnáváte staré top-k s novým top-k, jen zopakujete bias starého systému a budete tomu říkat validace. Použijte cross-encoder nebo reranker jako rozhodčího, ideálně takový, který už vám na vašem corpusu koreloval s lidskou relevancí. Pro text retrieval umí moderní rerankery od Cohere, Jina, mixedbread, Voyage nebo self-hosted cross-encoder ze Sentence Transformers skórovat dvojice query-dokument výrazně líp než samotná embedding similarity. Reranker nemusí obsluhovat production traffic v kritické cestě, pokud vás tlačí latence. Stačí, když skóruje eval sety a shadow vzorky.

Vaše SLO musí být explicitní. Precision@10, Recall@50 proti judged setu, MRR pro navigační dotazy a k tomu failure budget pro regrese v konkrétních kategoriích. Když lookup faktur spadne o 8 procent a FAQ search si polepší o 2 procenta, rollout stejně zamítnete, pokud je lookup faktur revenue-critical. Obecné průměry umí bolest velmi dobře schovat.

Praktická tabulka pro CI může vypadat takhle:

yaml
1eval:
2 min_precision_at_10: 0.78
3 min_recall_at_50: 0.92
4 min_mrr: 0.81
5 max_latency_delta_ms_p95: 25
6 max_regression_rate: 0.03
7 critical_segments:
8 invoice_queries:
9 min_precision_at_10: 0.90
10 sku_lookup:
11 min_mrr: 0.95

Pak kolem toho postavte pass/fail script. Pošlete dotazy do obou indexů, posbírejte candidate dokumenty podle semantic key, rerankněte dvojice (query, chunk) a spočítejte metriky. V CI to může běžet nad fixním datasetem a každou noc nad čerstvými shadow vzorky. Jakmile candidate nesplní threshold, build failne. Bez diskuze.

python
1if metrics["precision_at_10"] < cfg["min_precision_at_10"]:
2 raise SystemExit(f"FAIL precision@10={metrics['precision_at_10']:.3f}")
3
4if metrics["invoice_queries"]["precision_at_10"] < 0.90:
5 raise SystemExit("FAIL invoice_queries precision regression")

Budete chtít i lidský review, hlavně pro divné edge buckety: krátké dotazy na identifikátory, právní text plný negací, vícejazyčné support tickety. Cross-encoder toho zachytí hodně, ale neodhalí všechny nuance konkrétního byznysu. Udržujte při životě 100 až 300 ručně ohodnocených příkladů, které bolí. V každém releasu se zaplatí.

migrace bez ztráty identity

Migrační job musí zachovat semantic keys, i když se změní chunk ID nebo layout storage. Jinak se každé srovnání a každá cache změní v nesmysl. Lidi často navážou embeddingy na row ID v databázi a pak později corpus znovu rozchunkují, nahrají backup nebo rozdělí tabulku na shardy. Najednou netuší, jestli candidate pokazil retrieval, nebo jen potichu změnili identitu pod celým systémem. Stabilní semantic keys tohle řeší.

Na základní backfill stačí SQL plus aplikační worker. Nejdřív vytvořte záznam cílové embedding verze, pak upsertujte vektory podle (chunk_id, embedding_version_id). Když se změní obsah, vytvářejte nový chunk record s novým semantic key jen tehdy, když se opravdu změnil normalizovaný obsah.

sql
1insert into embedding_versions (
2 version_key, encoder_name, encoder_revision, dimensions, distance_metric
3) values (
4 'embed-v2026-03', 'text-embedding-3-large-next', '2026-03-01', 1536, 'cosine'
5)
6on conflict (version_key) do nothing;
7
8with target as (
9 select id from embedding_versions where version_key = 'embed-v2026-03'
10)
11select dc.id, dc.semantic_key, dc.content
12from document_chunks dc
13where not exists (
14 select 1
15 from chunk_embeddings ce, target t
16 where ce.chunk_id = dc.id and ce.embedding_version_id = t.id
17)
18order by dc.id
19limit 1000;

Worker si načte batch, spočítá embeddingy a zapíše je přes COPY nebo batch insert. Sledujte rate, chyby a drift. Častý fail při bulk loadu je mismatch dimenzí po změně konfigurace modelu a pgvector je v tomhle příjemně nekompromisní: ERROR: expected 1536 dimensions, not 1024. To je dobře. Chcete tvrdé chyby.

Hosted vector DB potřebují stejnou disciplínu v klíčích. Upsert payload by měl vždy obsahovat semantic_key, source metadata a checksum chunku. Pokud vendor nabízí jen opaque ID, použijte semantic key jako ID. Nenechte si z jejich autogenerated UUID udělat svou identitní vrstvu.

Během migrace zmrazte všechny retrieval-side předpoklady, které závisejí na tvaru score. Jestli má aplikace logiku typu score > 0.82 znamená odpovědět rovnou, tak ji smažte nebo verzujte per encoder. Absolutní thresholdy při výměně modelu skoro vždycky časem shnijí.

cutover a rollback

Finální cutover musí být jeden config flip, ne řetězec vedlejších efektů. Aktivní verze encoderu se přepne v control table nebo přes feature flag, query embedding začne používat candidate model, retrieval ukáže na candidate index, reranking zůstane jednu release window kompatibilní s oběma cestami a starý index zůstane queryable, dokud nový neprojde dostatečným provozem z reálu.

V PostgreSQL může být tenhle control plane úplně jednoduchý:

sql
1update embedding_versions
2set active = case when version_key = 'embed-v2026-03' then true else false end;

Služba by měla aktivní verzi číst z krátké cache, 30 sekund je v pohodě, nebo odebírat config updates, pokud už na to máte infrastrukturu. Starou query path nechte naživu za flagem s nudným a zcela zřejmým názvem, třeba RETRIEVAL_ROLLBACK_VERSION=embed-v2026-02, protože uprostřed incidentu fakt není čas na básničky.

Rollback-safe reranking je důležitý. Jestli production ranking vypadá jako ANN top-100 -> cross-encoder rerank -> top-10, reranker musí bez změny kódu a bez předpokladů o score fungovat s kandidáty z obou verzí encoderu. Zní to samozřejmě, ale lidi stejně nacpou do post-filter fáze heuristiky specifické pro konkrétní encoder a pak se diví, že rollback vyrobí jiný typ selhání. Kde to jde, držte reranking stateless vůči verzi embeddingu.

Během canary sledujte pár konkrétních metrik: zero-result rate, clickthrough nebo task completion, pokud je máte, overlap proti starému top-k, segmentovou precision ze shadow judgmentů, p95 latency a objem low-confidence fallbacků. Canary musí být dost velká na to, aby obsahovala i ošklivý traffic. Jedno procento z miniaturní množiny tenantů vám neřekne vůbec nic.

Celý ten proces je těžkopádnější než prostě přepsat název modelu v configu, protože prostě je. Kvalita search je chování produktu, ne infra plumbing. Berte upgrady encoderu jako schema migrace s blast radiem, protože přesně to jsou. Jakmile to tak začnete dělat, rollouty modelů přestanou být nervózní věštění z křišťálové koule a začnou vypadat jako normální engineering.

Johnny Unar

Napsal/a

Johnny Unar

Chcete s námi spolupracovat?

Upgrady embedding modelů rozbíjejí search potichu a draze. Tohle je rollout playbook, který používáme, když potřebujeme nasadit nový encoder bez toho, aby retrieval šel do háje.