Corriger les documents RAG sans les écraser : versioning sûr

Cet article a une courte vidéo :YouTubeTikTok

1. Vue d’ensemble

Dans un système RAG (Retrieval-Augmented Generation), les documents sources ne restent jamais statiques : les politiques changent, les prix changent, les réglementations changent. Si vous « écrasez » simplement les anciens chunks avec de nouveaux chunks dans le vector store, le système peut facilement se retrouver dans un état où il donne des réponses correctes et incorrectes à la même question. Cet article explique pourquoi un simple écrasement est une conception dangereuse, et présente un processus de mise à jour plus sûr : préparer les nouveaux chunks, les vérifier, puis basculer la version active — avec des exemples de code Java concrets.

2. Le problème : pourquoi l’écrasement direct crée des incohérences

Supposons que votre entreprise augmente la limite de remboursement pour les hôtels de 5 000 000đ à 7 000 000đ. Le document de politique est divisé en plusieurs chunks, chaque chunk est incorporé en vecteur et stocké dans la base de données vectorielle. Si la politique change et que vous :

  • Insérez le nouveau chunk en premier, supprimez l’ancien ensuite → il y a une période où les deux chunks coexistent et sont tous deux considérés comme « valides pour la requête actuelle ». Une requête de recherche sémantique pourrait retourner l’ancien chunk (5 000 000đ) car la similarité vectorielle avec la question reste élevée.
  • Supprimez l’ancien chunk en premier, insérez le nouveau ensuite → il y a une période aucun chunk ne peut répondre à la question sur la limite, le LLM est forcé d’ « inventer » ou de répondre qu’il n’y a pas d’information.

Les deux approches ont des problèmes car elles traitent la « mise à jour » comme une seule action, alors qu’en réalité ce sont deux opérations (ajouter + supprimer l’ancien) qui ne sont pas atomiques.

// Mauvaise approche : écrasement implicite, pas de contrôle d'état de transition
public void updatePolicyNaive(VectorStore store, String oldChunkId, PolicyChunk newChunk) {
    store.delete(oldChunkId);          // (1) créer un vide temporaire
    store.upsert(newChunk.id(), newChunk.embedding(), newChunk.metadata());
}

La ligne (1) crée un vide : pendant que delete s’exécute mais que upsert n’est pas encore terminé (ou échoue en chemin), la requête ne trouvera aucun chunk sur la limite de remboursement.

Remarque : Pour un système RAG en production, ce vide n’est pas théorique — il se produit réellement chaque fois que vous déployez un job de mise à jour de données sans mécanisme de transaction ou de versioning.

3. Quand faut-il re-embed, quand ne pas le faire

Tous les changements n’exigent pas de recalculer le vecteur. Le vecteur est généré à partir du contenu textuel du chunk. Si vous ne changez que les métadonnées (par exemple : date d’effet, division applicable) sans modifier le texte du chunk, vous n’avez pas besoin de re-embed — il suffit de mettre à jour les métadonnées.

public record PolicyChunk(
        String id,
        String text,              // contenu utilisé pour générer le vecteur
        float[] embedding,
        Map<String, String> metadata // N'EST PAS dans le texte utilisé pour embed
) {}

Si le text change (par exemple « 5 000 000đ » → « 7 000 000đ »), l’ancien vecteur ne reflète plus correctement le contenu → vous devez re-embed ce chunk. Mais les autres chunks du document (par exemple la section décrivant les conditions d’application, qui ne change pas le montant) conservent leurs vecteurs — pas besoin de les toucher.

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);
}

Séparer upsertVector et upsertMetadata en deux méthodes distinctes vous permet de choisir activement : modifier les métadonnées sans re-embed, modifier le texte et re-embed. Certaines bases de données vectorielles (Qdrant, Weaviate, Pinecone) supportent la mise à jour de payload/métadonnées indépendamment du vecteur — consultez la documentation du produit que vous utilisez avant d’écrire votre logique de mise à jour.

4. Conception du versioning et du flag de version active

Plutôt que de modifier les chunks sur place, une approche plus sûre est de traiter chaque changement de contenu comme une nouvelle version, avec son propre versionId. L’ancien chunk n’est pas supprimé immédiatement — il est simplement marqué comme non plus « actif » pour les requêtes de politique actuelles.

public record PolicyVersion(
        String versionId,      // exemple : « hotel-reimbursement-v2 »
        String policyKey,      // exemple : « 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));
    }
}

Le ActiveVersionRegistry ne stocke qu’un mappage : pour chaque policyKey, quelle version est considérée comme « active ». Lors de la recherche, vous filtrez selon metadata.versionId == activeVersion, plutôt que de vous fier à l’existence ou non du chunk. De cette manière, basculer de version devient une seule opération de changement de pointeur, rapide et presque atomique — pas une série d’insertions/suppressions éparpillées sur plusieurs chunks.

5. Processus de mise à jour sécurisé : préparer → vérifier → basculer

Le processus comprend trois étapes claires, évitant les deux erreurs mentionnées à la section 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) {
        // Étape 1 : préparer — embedder et écrire les nouveaux chunks, SANS ENCORE changer la version active
        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);
        }

        // Étape 2 : vérifier — les nouveaux chunks sont vraiment lisibles, pas vides
        boolean ready = newVersion.chunks().stream()
                .allMatch(c -> vectorStore.findById(c.id()).isPresent());
        if (!ready) {
            throw new IllegalStateException("Les nouveaux chunks ne sont pas prêts, pas de basculement de version active");
        }

        // Étape 3 : basculer la version active — opération rapide, presque atomique
        registry.setActive(policyKey, newVersion.versionId());
    }
}

Explication du flux :

  • L’étape 1 s’exécute avant que toute requête ne soit affectée — les nouveaux chunks coexistent avec les anciens, mais les anciens restent actifs, de sorte que les utilisateurs reçoivent toujours des réponses cohérentes (5 000 000đ) tout au long du processus de préparation.
  • L’étape 2 garantit que vous ne basculez pas la version active quand les nouvelles données n’ont pas été écrites complètement ou ont échoué partiellement — évitant la situation « change flag mais pas de chunk ».
  • L’étape 3 ne change qu’une valeur dans le registre. C’est le seul point de basculement, il est donc facile à contrôler et facile à annuler (il suffit de redéfinir l’ancien versionId).

L’ancien chunk (v1) n’est pas supprimé immédiatement. Vous pouvez le nettoyer plus tard, dans un job séparé, hors du chemin critique de la mise à jour de politique.

6. Préserver la capacité à répondre aux questions historiques

Un avantage secondaire de la conception du versioning : vous pouvez toujours répondre à des questions comme « Quelle était la limite de remboursement pour les hôtels l’année dernière ? ». Puisque le chunk v1 n’a pas été supprimé, vous avez juste besoin d’un filtre différent à la couche de récupération.

public List<PolicyChunk> searchCurrentPolicy(String policyKey, float[] queryVector) {
    String activeVersion = registry.getActiveVersion(policyKey)
            .orElseThrow(() -> new IllegalStateException("Pas de version active pour " + 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 filtre toujours selon la activeVersion récupérée du registre — c’est le seul endroit qui décide « quelle version est actuellement active ». searchHistoricalPolicy permet de requêter explicitement une version spécifique, servant les questions historiques ou d’audit.

7. Tests pour le comportement de basculement de version active

Écrivez des tests pour vous assurer que le processus ne révèle jamais un état « à moitié fini ».

<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", "Limite de 7 000 000đ", null, Map.of())),
                Instant.now());

        service.applyNewVersion("hotel-reimbursement", v2);

        assertThat(registry.getActiveVersion("hotel-reimbursement"))
                .contains("v2");
        // chunk v1 est toujours dans le store, servant les requêtes historiques
        assertThat(store.findById("chunk-v1-1")).isPresent();
    }
}

Ce test affirme deux choses : après que applyNewVersion s’exécute, la version active doit être v2, et le chunk de v1 n’est pas perdu — vous pouvez toujours interroger l’historique.

8. Changer le modèle d’embedding : un problème plus important

Tout ce qui précède suppose que le modèle d’embedding ne change pas. Si vous passez du modèle A au modèle B (par exemple changement de fournisseur, changement de dimensionnalité du vecteur), tous les chunks existants doivent être réembeddés avec le nouveau modèle — pas seulement les chunks dont le contenu vient de changer. Les vecteurs du modèle A et du modèle B ne se trouvent pas dans le même espace, comparer leur similarité cosinus est sans sens.

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);
    }
    // Après que tous les chunks aient les nouveaux vecteurs, changez le point d'entrée du pipeline de requête
    // pour que les requêtes utilisent également newModel lors de l'embedding des questions.
}

Point clé à noter : la requête de l’utilisateur doit également être incorporée avec le même nouveau modèle au moment de la recherche. Si vous ne changez que le vecteur du document et oubliez de changer le modèle d’embedding des questions, les résultats de recherche seront complètement incorrects car les deux côtés ne sont plus dans le même espace vectoriel.

9. Erreurs courantes et quand ne pas overcomplicate

  • Erreur courante : traiter la mise à jour comme un seul upsert(), sans séparer « écrire les nouvelles données » et « basculer active » — conduisant au vide ou aux doublons mentionnés à la section 2.
  • Erreur courante : placer les métadonnées dans la partie texte utilisée pour l’embedding (par exemple intégrer la date d’effet dans le texte), forçant à re-embedder chaque changement de métadonnées, gaspillant des ressources inutiles.
  • Erreur courante : oublier que les anciens chunks doivent être conservés pour les requêtes historiques — les supprimer immédiatement après le basculement, perdant la capacité à répondre « quelle était la politique auparavant ».

Quand vous N’AVEZ PAS BESOIN de tout ce processus de versioning : si vos documents sont quasi statiques (changements rares, pas besoin de requêtes historiques, système utilisé seulement en interne par quelques dizaines d’utilisateurs), un job batch réindexant tout selon un calendrier (par exemple chaque nuit) est plus simple et suffisamment sûr — pas besoin de registre, pas besoin de versionId complexe.

10. Conclusion

Mettre à jour les documents dans un système RAG n’est pas un problème de « remplacer les anciens vecteurs par les nouveaux » — c’est un problème de gestion de l’état de transition entre deux versions de données sans que le système ne se retrouve dans une zone grise ou une incohérence. Séparez les vecteurs et les métadonnées pour éviter les re-embeddings inutiles, utilisez versionId avec un registre de version active pour basculer rapidement et rollback, et toujours conserver les anciennes données assez longtemps pour servir les requêtes historiques. Quand vous changez entièrement le modèle d’embedding, traitez-le comme une migration complète, pas une mise à jour ordinaire.

llmragvector-databasespring-bootjava