RAG-Dokumente aktualisieren ohne zu überschreiben: sichere Versionierung für LLM
1. Überblick
In RAG-Systemen (Retrieval-Augmented Generation) stehen Quelldokumente nie still: Richtlinien ändern sich, Preise ändern sich, Vorschriften ändern sich. Wenn Sie alte Chunks einfach nur mit neuen Chunks im Vector Store “überschreiben”, gerät das System leicht in einen Zustand, in dem es auf die gleiche Frage sowohl richtig als auch falsch antwortet. Dieser Artikel erklärt, warum einfaches Überschreiben ein gefährliches Design ist, und präsentiert einen sichereren Aktualisierungsprozess: neue Chunks vorbereiten, verifizieren, dann die aktive Version wechseln — mit konkreten Java-Code-Beispielen.
2. Das Problem: Warum direktes Überschreiben zu Inkonsistenzen führt
Angenommen, Ihre Firma erhöht die Hotelrückerstattungsgrenze von 5.000.000đ auf 7.000.000đ. Ein Richtliniendokument wird in mehrere Chunks aufgeteilt, jeder Chunk wird zu einem Vektor eingebettet und in der Vektordatenbank gespeichert. Wenn die Richtlinie sich ändert, könnte folgendes passieren:
- Neuen Chunk einfügen, dann alten löschen → es gibt eine Zeitspanne, in der beide Chunks nebeneinander existieren und beide als “gültig für die aktuelle Abfrage” gelten. Eine semantische Suche könnte den alten Chunk (5.000.000đ) zurückgeben, weil die Vektorähnlichkeit mit der Abfrage immer noch hoch ist.
- Alten Chunk löschen, dann neuen einfügen → es gibt eine Zeitspanne, in der kein Chunk die Frage zur Grenze beantworten kann, und die LLM muss “erfinden” oder sagen, dass keine Information vorhanden ist.
Beide Wege sind problematisch, weil sie “Aktualisierung” als eine einzelne Aktion behandeln, während es tatsächlich zwei Operationen (Hinzufügen + Altes Entfernen) sind, die nicht atomar sind.
// FALSCHER Weg: implizites Überschreiben, keine Kontrolle über State Transition
public void updatePolicyNaive(VectorStore store, String oldChunkId, PolicyChunk newChunk) {
store.delete(oldChunkId); // (1) erzeugt eine temporäre Lücke
store.upsert(newChunk.id(), newChunk.embedding(), newChunk.metadata());
}
Die Zeile (1) erzeugt eine Lücke: während delete läuft aber upsert noch nicht fertig ist (oder dazwischen fehlschlägt), findet die Abfrage keinen Chunk zur Rückerstattungsgrenze.
Hinweis: Für ein produktives RAG-System ist diese Lücke keine Theorie — sie tritt jedesmal auf, wenn Sie einen Datenaktualisierungs-Job deployen, ohne einen Transaktions- oder Versionierungsmechanismus zu haben.
3. Wann muss neu eingebettet werden, wann nicht
Nicht jede Änderung erfordert eine Neuberechnung des Vektors. Der Vektor wird aus dem Textinhalt des Chunks generiert. Wenn Sie nur Metadaten ändern (z.B. Datum des Inkrafttretens, anwendende Abteilung), aber nicht den Text im Chunk, müssen Sie nicht neu einbetten — nur die Metadaten aktualisieren.
public record PolicyChunk(
String id,
String text, // Inhalt zum Generieren des Vektors
float[] embedding,
Map<String, String> metadata // NICHT Teil des Textes zum Einbetten
) {}
Wenn sich text ändert (z.B. “5.000.000đ” → “7.000.000đ”), reflektiert der alte Vektor den neuen Inhalt nicht korrekt → muss neu eingebettet werden. Aber andere Chunks im Dokument (z.B. der Teil über Anwendungsbedingungen, der sich nicht ändert) behalten ihren Vektor — kein Grund, sie anzutasten.
public interface VectorStore {
void upsertVector(String chunkId, float[] embedding);
void upsertMetadata(String chunkId, Map<String, String> metadata);
Optional<PolicyChunk> findById(String chunkId);
void delete(String chunkId);
}
Das Trennen von upsertVector und upsertMetadata in separate Methoden gibt Ihnen die Kontrolle: Nur Metadaten ändern ohne neu einzubetten, Text ändern mit Neueinbettung. Einige Vektordatenbanken (Qdrant, Weaviate, Pinecone) unterstützen die unabhängige Aktualisierung von Payload/Metadaten ohne Vektoränderung — überprüfen Sie die Dokumentation Ihres Produkts, bevor Sie Aktualisierungslogik schreiben.
4. Versionierungsdesign und active-version flag
Statt Chunks an Ort und Stelle zu ändern, ist es sicherer, jede Inhaltsänderung als eine neue Version zu behandeln, mit eigener versionId. Der alte Chunk wird nicht sofort gelöscht — er wird nur als nicht mehr “aktiv” für aktuelle Richtlinienabfragen gekennzeichnet.
public record PolicyVersion(
String versionId, // z.B. "hotel-reimbursement-v2"
String policyKey, // z.B. "hotel-reimbursement"
List<PolicyChunk> chunks,
Instant effectiveFrom
) {}
public class ActiveVersionRegistry {
private final Map<String, String> activeVersionByPolicyKey = new ConcurrentHashMap<>();
public void setActive(String policyKey, String versionId) {
activeVersionByPolicyKey.put(policyKey, versionId);
}
public Optional<String> getActiveVersion(String policyKey) {
return Optional.ofNullable(activeVersionByPolicyKey.get(policyKey));
}
}
ActiveVersionRegistry speichert nur eine Zuordnung: für jeden policyKey, welche Version ist “aktuell”. Bei der Suche filtern Sie nach metadata.versionId == activeVersion, anstatt sich darauf zu verlassen, ob der Chunk noch existiert. Dadurch wird der Versionswechsel zu einer einzigen Zeigeränderung, schnell und nahezu atomar — nicht eine Reihe von insert/delete über viele Chunks verteilt.
5. Sicherer Aktualisierungsprozess: vorbereiten → verifizieren → aktiv wechseln
Der Prozess umfasst drei klare Schritte und vermeidet beide Fehler aus Abschnitt 2.
@Service
public class PolicyUpdateService {
private final VectorStore vectorStore;
private final EmbeddingClient embeddingClient;
private final ActiveVersionRegistry registry;
public PolicyUpdateService(VectorStore vectorStore,
EmbeddingClient embeddingClient,
ActiveVersionRegistry registry) {
this.vectorStore = vectorStore;
this.embeddingClient = embeddingClient;
this.registry = registry;
}
public void applyNewVersion(String policyKey, PolicyVersion newVersion) {
// Schritt 1: vorbereiten — einbetten und neue Chunks speichern, OHNE aktive Version zu wechseln
for (PolicyChunk chunk : newVersion.chunks()) {
float[] vector = embeddingClient.embed(chunk.text());
Map<String, String> metadata = new HashMap<>(chunk.metadata());
metadata.put("versionId", newVersion.versionId());
metadata.put("policyKey", policyKey);
vectorStore.upsertVector(chunk.id(), vector);
vectorStore.upsertMetadata(chunk.id(), metadata);
}
// Schritt 2: verifizieren — neue Chunks sind wirklich lesbar, nicht leer
boolean ready = newVersion.chunks().stream()
.allMatch(c -> vectorStore.findById(c.id()).isPresent());
if (!ready) {
throw new IllegalStateException("Neue Chunks sind nicht bereit, wechseln Sie nicht die aktive Version");
}
// Schritt 3: aktive Version wechseln — schnelle Operation, nahezu atomar
registry.setActive(policyKey, newVersion.versionId());
}
}
Erklärung des Ablaufs:
- Schritt 1 läuft, bevor irgendwelche Abfragen betroffen sind — neue Chunks existieren parallel zu alten, aber der alte Chunk ist immer noch aktiv, also erhalten Benutzer in der ganzen Vorbereitungszeit konsistente Antworten (5.000.000đ).
- Schritt 2 stellt sicher, dass Sie nicht die aktive Version wechseln, wenn neue Daten noch nicht geschrieben oder teilweise fehlgeschlagen sind — vermeidet die Situation “Flagge gewechselt, aber Chunks fehlen”.
- Schritt 3 ändert nur einen Wert in der Registry. Dies ist der einzige Übergangspunkt, daher können Sie ihn leicht steuern und zurückrollen (setzen Sie einfach die alte
versionIdzurück).
Der alte Chunk (v1) wird nicht sofort gelöscht. Sie können ihn später aufräumen, in einem separaten Job, nicht auf dem kritischen Pfad der Richtlinienaktualisierung.
6. Die Fähigkeit bewahren, historische Fragen zu beantworten
Ein Nebeneffekt des Versionierungsdesigns: Sie können immer noch Fragen wie “Wie hoch war die Hotelrückerstattungsgrenze letztes Jahr?” beantworten. Da Chunk v1 nicht gelöscht wurde, müssen Sie nur einen anderen Filter in der Retrieval-Schicht anwenden.
public List<PolicyChunk> searchCurrentPolicy(String policyKey, float[] queryVector) {
String activeVersion = registry.getActiveVersion(policyKey)
.orElseThrow(() -> new IllegalStateException("Keine aktive Version für " + policyKey));
return vectorStore.searchSimilar(queryVector, Map.of(
"policyKey", policyKey,
"versionId", activeVersion
));
}
public List<PolicyChunk> searchHistoricalPolicy(String policyKey, String versionId, float[] queryVector) {
return vectorStore.searchSimilar(queryVector, Map.of(
"policyKey", policyKey,
"versionId", versionId
));
}
searchCurrentPolicy filtert immer nach activeVersion, die aus der Registry abgerufen wird — dies ist die einzige Stelle, die entscheidet, “welche Version ist aktuell”. searchHistoricalPolicy erlaubt es, eine bestimmte Version abzufragen, um historische oder Audit-Fragen zu bedienen.
7. Tests für das Versionswechselverhalten
Schreiben Sie Tests, um sicherzustellen, dass der Prozess dem System niemals einen “halben” Zustand offenlegt.
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
class PolicyUpdateServiceTest {
@Test
void shouldKeepOldVersionActiveUntilNewVersionVerified() {
InMemoryVectorStore store = new InMemoryVectorStore();
ActiveVersionRegistry registry = new ActiveVersionRegistry();
registry.setActive("hotel-reimbursement", "v1");
PolicyUpdateService service = new PolicyUpdateService(
store, text -> new float[]{0.1f, 0.2f}, registry);
PolicyVersion v2 = new PolicyVersion(
"v2", "hotel-reimbursement",
List.of(new PolicyChunk("chunk-v2-1", "Grenze 7.000.000đ", null, Map.of())),
Instant.now());
service.applyNewVersion("hotel-reimbursement", v2);
assertThat(registry.getActiveVersion("hotel-reimbursement"))
.contains("v2");
// Chunk v1 bleibt immer noch im Store für historische Abfragen
assertThat(store.findById("chunk-v1-1")).isPresent();
}
}
Dieser Test bestätigt zwei Dinge: Nach dem Ausführen von applyNewVersion muss die aktive Version v2 sein, und Chunks von v1 dürfen nicht verloren gehen — Sie können immer noch historische Daten abfragen.
8. Wechsel des Embedding-Modells: ein größeres Problem
Alles oben geht davon aus, dass sich das Embedding-Modell nicht ändert. Wenn Sie von Modell A zu Modell B wechseln (z.B. Provider wechseln, Vektordimensionen ändern), müssen alle vorhandenen Chunks mit dem neuen Modell neu eingebettet werden — nicht nur die gerade geänderten Chunks. Vektoren von Modell A und Modell B liegen nicht im gleichen Raum, und Cosine-Similarity-Vergleiche zwischen ihnen sind bedeutungslos.
public void migrateEmbeddingModel(EmbeddingClient newModel, List<PolicyChunk> allChunks) {
for (PolicyChunk chunk : allChunks) {
float[] newVector = newModel.embed(chunk.text());
vectorStore.upsertVector(chunk.id() + ":model-v2", newVector);
}
// Nachdem alle Chunks neue Vektoren haben, den Einstiegspunkt der Query-Pipeline wechseln,
// damit Query-Anfragen auch newModel verwenden beim Einbetten der Frage.
}
Der Schlüsselpunkt: die Benutzer-Query muss auch mit dem neuen Modell zum gleichen Zeitpunkt der Suche eingebettet werden. Wenn Sie nur Dokument-Vektoren wechseln, aber vergessen, das Modell zum Einbetten von Fragen zu wechseln, werden die Suchergebnisse völlig falsch, weil beide Seiten nicht mehr im gleichen Vektorraum sind.
9. Häufig auftretende Fehler und wann Sie nicht komplizieren müssen
- Häufiger Fehler: Update als ein einzelnes
upsert()zu behandeln, ohne “neue Daten schreiben” und “Aktiv wechseln” zu trennen — führt zu den Lücken oder Duplikaten aus Abschnitt 2. - Häufiger Fehler: Metadaten in den Text zu setzen, der eingebettet wird (z.B. Datum ins Einbettungstext einbetten), was zwingt, jede Metadaten-Änderung neu einzubetten, unnötige Ressourcen verschwenden.
- Häufiger Fehler: zu vergessen, dass alte Chunks für historische Abfragen behalten werden müssen — sofort nach Versionswechsel löschen, verliert die Fähigkeit, “wie war die Richtlinie damals?” zu beantworten.
Wann Sie NICHT den ganzen Versionierungsprozess brauchen: wenn Ihre Dokumente nahezu statisch sind (selten ändern, keine Anforderung für historische Abfragen, System läuft nur intern mit ein paar Dutzend Benutzern), ist ein einfacher Batch-Reindex-Job nach Zeitplan (z.B. jede Nacht) einfacher und ausreichend sicher — keine Registry, keine komplexe versionId.
10. Fazit
Das Aktualisieren von Dokumenten in einem RAG-System ist nicht das Problem “alte Vektoren mit neuen Vektoren überschreiben” — es ist das Problem, den Zustand zwischen zwei Datenversionen zu verwalten, ohne dass das System in Lücken oder Inkonsistenzen fällt. Trennen Sie Vektor und Metadaten, um unnötiges Neueinbetten zu vermeiden, verwenden Sie versionId zusammen mit active-version registry für schnelle und rollback-bare Übergänge, und bewahren Sie immer alte Daten lange genug für historische Abfragen. Wenn Sie das Embedding-Modell wechseln, behandeln Sie das als vollständige Migration, nicht als normales Update.