Indexuppdatering och versionering
Kunna uppdatera ett index inkrementellt utan att tappa reproducerbarhet.
Förkunskaper
- EDataversioneringkrävs
- EVektordatabaser och indexeringkrävs
Intuition
Ett index som byggdes en gång och aldrig uppdateras blir snabbt inaktuellt. Men att bygga om allt varje gång ett dokument ändras är ohållbart vid miljontals dokument.
Fyra operationer, med olika svårighetsgrad:
| Operation | Svårighet | Varför |
|---|---|---|
| Lägg till | lätt | de flesta index stöder det direkt |
| Uppdatera | medel | ta bort + lägg till, och håll id:t stabilt |
| Ta bort | svårt | HNSW-grafen kan inte enkelt läka efter borttagning |
| Byta embeddingmodell | kräver full ombyggnad | gamla och nya vektorer är inte jämförbara |
Den sista raden är den viktigaste att planera för. Två vektorer från olika modeller ligger i olika rum — avstånden mellan dem betyder ingenting. Ett index kan aldrig innehålla en blandning.
Formellt
Mjuk borttagning är standardlösningen: markera dokumentet som borttaget i metadatan och filtrera bort det vid sökning. Vektorn ligger kvar i grafen och tar plats, men sökningen ger rätt svar.
När andelen borttagna växer över några tiotal procent försämras både minne och recall, och då byggs indexet om — som ett vakuum i en databas.
Stabila id:n är en förutsättning. Använd ett deterministiskt id från dokumentets identitet, inte ett löpnummer:
id = sha256(f"{kalla}:{dokument_id}:{chunk_index}")
Då går samma stycke alltid att hitta och ersätta, oavsett i vilken ordning indexeringen kördes.
Chunk-hash avgör vad som behöver göras om. Spara innehållets hash per stycke; vid omindexering jämförs den nya hashen med den gamla:
| Utfall | Åtgärd |
|---|---|
| Hashen oförändrad | hoppa över — ingen ny embedding behövs |
| Hashen ändrad | bädda in på nytt och ersätt |
| Stycket saknas nu | markera som borttaget |
| Nytt stycke | lägg till |
För ett korpus där 2 % ändras per vecka innebär det att 98 % av embeddingkostnaden försvinner.
Blågrön ombyggnad vid modellbyte:
- Bygg ett nytt index (grönt) med den nya modellen, medan det gamla (blått) fortsätter att svara.
- Kör utvärderingen mot båda på samma frågor.
- Är grönt bättre — byt alias. Är det sämre — kasta det.
- Behåll blått en tid, så att en återgång är ett aliasbyte bort.
Versionera indexet som datan. Varje index ska bära:
| Fält | Varför |
|---|---|
| Embeddingmodell och version | avgör om vektorerna är jämförbara |
| Chunkningsparametrar | påverkar vad som hittas |
| Datasetets hash | vilket underlag |
| Byggtidpunkt och kodversion | spårbarhet |
Utan de fälten går det inte att svara på varför en fråga gav ett annat svar i förra veckan — och det är en fråga som alltid kommer.
Kod
import hashlib, json, time
from dataclasses import dataclass, asdict
from pathlib import Path
def chunk_id(kalla: str, dok: str, i: int) -> str:
return hashlib.sha256(f"{kalla}:{dok}:{i}".encode()).hexdigest()[:32]
def innehall_hash(text: str) -> str:
return hashlib.sha256(text.encode()).hexdigest()[:16]
@dataclass
class Indexversion:
embeddingmodell: str
embedding_dim: int
chunk_storlek: int
chunk_overlapp: int
dataset_hash: str
byggd: str
git_commit: str
def inkrementell_uppdatering(klient, samling, nya_chunks, befintliga: dict[str, str]):
"""befintliga: chunk_id -> innehålls-hash. Returnerar vad som gjordes."""
lagg_till, ersatt, oforandrade = [], [], 0
sedda = set()
for c in nya_chunks:
cid = chunk_id(c["kalla"], c["dok"], c["i"])
h = innehall_hash(c["text"])
sedda.add(cid)
if befintliga.get(cid) == h:
oforandrade += 1 # hoppa över — spar embeddingkostnad
elif cid in befintliga:
ersatt.append((cid, c, h))
else:
lagg_till.append((cid, c, h))
borttagna = [cid for cid in befintliga if cid not in sedda]
for cid, c, h in lagg_till + ersatt:
klient.upsert(samling, id=cid, vector=bädda_in(c["text"]),
payload={"text": c["text"], "hash": h, "borttagen": False})
for cid in borttagna:
klient.set_payload(samling, id=cid, payload={"borttagen": True}) # mjuk borttagning
return {"nya": len(lagg_till), "ersatta": len(ersatt),
"oforandrade": oforandrade, "borttagna": len(borttagna),
"besparad_embeddingandel": round(
oforandrade / max(len(nya_chunks), 1), 3)}
# Sökningen måste filtrera bort mjukt borttagna
def sok(klient, samling, q, k=10):
return klient.search(samling, q, limit=k,
query_filter={"must": [{"key": "borttagen", "match": {"value": False}}]})
# Blågrön: bygg nytt vid sidan av, byt alias först efter utvärdering
def blagron_utrullning(klient, gammalt, nytt, utvardera, troskel=0.0):
fore, efter = utvardera(gammalt), utvardera(nytt)
print(f"recall@10: {fore:.3f} → {efter:.3f}")
if efter >= fore + troskel:
klient.update_alias("produktion", nytt)
return {"bytte": True, "fore": fore, "efter": efter}
return {"bytte": False, "anledning": "ingen förbättring", "fore": fore, "efter": efter}
besparad_embeddingandel är talet som motiverar hela konstruktionen: ligger den på 0,98 betyder det att en veckovis omindexering kostar två procent av vad en full ombyggnad hade gjort.
Behärskning innebär
- Uppdaterar ett index inkrementellt
- Hanterar borttagning och ombyggnad
- Behåller reproducerbarhet vid modellbyte
Logga in för att göra övningarna och bygga upp din behärskning.
Källor
- Qdrant — dokumentation (Apache-2.0) — Apache-2.0
- FAISS — dokumentation (MIT) — MIT
- Hugging Face — dokumentation (Apache-2.0) — Apache-2.0