RAG文書の編集は上書きではない:LLMの正しい更新方法

この記事には短い動画があります:YouTubeTikTok

1. 概要

RAG(Retrieval-Augmented Generation)システムでは、ソース文書は決して止まりません:ポリシーが変わり、価格が変わり、規制が変わります。単に古いchunkを新しいchunkでvector storeに「上書き」すると、システムは同じ質問に対して正しい答えと間違った答えを同時に返す状態に陥りやすくなります。本記事では、単純な上書きがなぜ危険な設計なのか、そしてより安全な更新プロセス(新しいchunkの準備、検証、その後のactive versionの切り替え)を説明します。具体的なJavaコード例も含めます。

2. 問題:直接上書きがなぜ矛盾を引き起こすのか

あなたの会社がホテル払い戻しの上限を500万ドンから700万ドンに引き上げたとしましょう。ポリシー文書は複数のchunkに分割され、各chunkはベクトルに埋め込まれ、ベクトルデータベースに保存されます。ポリシーが変わるときに、もしあなたが:

  • 新しいchunkを先に挿入してから古いchunkを削除する → 両方のchunkが同時に存在する期間があり、両方が「現在の質問に対して有効」とみなされます。セマンティック検索クエリは古いchunk(500万ドン)を返す可能性があります。その理由は、chunkのベクトルと質問との類似度がまだ高いためです。
  • 古いchunkを先に削除してから新しいchunkを挿入する → どのchunkも上限に関する質問に答えられない期間があり、LLMは「作り上げ」るか、または情報がないと答えざるを得ません。

どちらの方法にも問題があります。なぜなら、「更新」を単一のアクションと見なしていますが、実際には(追加+削除)の2つの操作はアトミック(atomic)ではないからです。

// 間違ったやり方:暗黙的な上書き、状態遷移の制御がない
public void updatePolicyNaive(VectorStore store, String oldChunkId, PolicyChunk newChunk) {
    store.delete(oldChunkId);          // (1) 一時的な空白期間がある
    store.upsert(newChunk.id(), newChunk.embedding(), newChunk.metadata());
}

行(1)は空白期間を作ります:deleteが実行されたがupsertがまだ完了していない(または途中でエラーが発生)場合、クエリは払い戻し上限に関するchunkを見つけられません。

注意: 本番環境で実行されているRAGシステムでは、この空白期間は理論的ではありません — transactionまたはversioningのメカニズムなしでデータ更新ジョブをデプロイするたびに発生します。

3. いつre-embedが必要か、いつ不要か

すべての変更がvectorの再計算を必要とするわけではありません。ベクトルはchunkのテキスト内容から生成されます。メタデータのみを変更する場合(例:有効日、適用部門)で、chunkの文章を変更しない場合、re-embedは必要ありません — メタデータの更新のみで十分です。

public record PolicyChunk(
        String id,
        String text,              // ベクトル生成に使用されるコンテンツ
        float[] embedding,
        Map<String, String> metadata // embedに使用されるテキストに含まれていない
) {}

もしtextが変わったら(例:「500万ドン」→「700万ドン」)、古いベクトルは正しい内容をもう反映していません → そのchunkを必ず re-embedしてください。ただし、ドキュメント内の他のchunk(例えば適用条件の部分で、金額は変わらない)は、ベクトルをそのまま保持しています — 触れる必要はありません。

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

upsertVectorとupsertMetadataを別々のメソッドに分割することで、積極的に選択できます:メタデータを変更するときはre-embedしない、テキストを変更するときはre-embedします。一部のベクトルデータベース(Qdrant、Weaviate、Pinecone)は、vectorとは独立して payload/metadata を更新することをサポートしています — update ロジックを書く前に、使用している製品のドキュメントを確認してください。

4. バージョンとactive-versionフラグの設計

chunkをその場で編集する代わりに、より安全な方法は、コンテンツの変更のたびに新しいバージョンとして扱い、独自のversionIdを持たせることです。古いchunkはすぐに削除されません — 現在のポリシークエリに対して「active」ではなくなったとマークされるだけです。

public record PolicyVersion(
        String versionId,      // 例:「hotel-reimbursement-v2」
        String policyKey,      // 例:「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はマッピングのみを保存します:各policyKeyについて、どのversionが「現在」と見なされているのか。検索するとき、metadata.versionId == activeVersionでフィルターします。chunkが存在するか否かに基づくのではなく。このように、versionの切り替えはポインタの変更という単一の操作になり、高速でほぼアトミックです — 多くのchunkに散らばったinsert/deleteのシーケンスではなく。

5. 安全な更新手順:準備→検証→active切り替え

プロセスは3つの明確なステップで構成され、セクション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) {
        // ステップ1:準備 — 新しいchunkをembedして書き込む、まだactive versionを変更していない
        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);
        }

        // ステップ2:検証 — 新しいchunkが実際に読める、空ではない
        boolean ready = newVersion.chunks().stream()
                .allMatch(c -> vectorStore.findById(c.id()).isPresent());
        if (!ready) {
            throw new IllegalStateException("新しいchunkが準備できていません。active versionを切り替えません");
        }

        // ステップ3:active versionを切り替え — 高速操作、ほぼアトミック
        registry.setActive(policyKey, newVersion.versionId());
    }
}

フロー説明:

  • ステップ1はクエリが影響を受ける前に実行されます — 新しいchunkは古いchunkと並行して存在しますが、古いchunkがまだactiveなので、ユーザーは準備プロセス全体を通じて一貫した回答(500万ドン)を受け取ります。
  • ステップ2は、新しいデータがまだ書き込まれていないか、部分的にエラーが発生している場合、active versionを切り替えないようにします — 「フラグを変更したがchunkがない」という状況を回避します。
  • ステップ3はregistryの1つの値のみを変更します。これが唯一の切り替えポイントなので、制御と rollback が簡単です(古いversionIdを再設定するだけ)。

古いchunk(v1)はすぐに削除されません。後で別のジョブで処理できます。ポリシー更新のクリティカルパスにはありません。

6. 過去の質問に答える能力を保つ

versioningの設計の副次的な利点:「去年のホテル払い戻しの上限はいくらでしたか?」のような質問にまだ答えられます。chunkv1がまだ削除されていないため、retrieval層で別のフィルターが必要なだけです。

public List<PolicyChunk> searchCurrentPolicy(String policyKey, float[] queryVector) {
    String activeVersion = registry.getActiveVersion(policyKey)
            .orElseThrow(() -> new IllegalStateException("アクティブなバージョンがありません: " + 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は常にregistryから取得したactiveVersionでフィルターします — これが「どのバージョンが現在実装中か」を決定する唯一の場所です。searchHistoricalPolicyは特定のバージョンを明示的にクエリすることを許可し、歴史的またはaudit質問に役立ちます。

7. active versionの切り替え動作のテスト

プロセスが「半端な」状態を外部に公開しないようにするテストを書きます。

<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", "上限700万ドン", null, Map.of())),
                Instant.now());

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

        assertThat(registry.getActiveVersion("hotel-reimbursement"))
                .contains("v2");
        // chunk v1はまだstoreに存在し、歴史的なクエリに役立ちます
        assertThat(store.findById("chunk-v1-1")).isPresent();
    }
}

このテストは2つのことを確認します:applyNewVersionの実行後、active versionはv2である必要があり、v1のchunkが失われません — 依然として履歴をクエリできます。

8. embeddingモデルの変更:より大きな問題

上記のすべてでは、embeddingモデルは変わらないと想定しています。model AからBに切り替える場合(例えば、providerを変更する、vector次元数を変更する)、存在するすべてのchunkを新しいモデルで再度embedする必要があります — 最近編集されたchunkだけではなく。modelAとBのベクトルは同じ空間にあるわけではなく、それらの間のcosine similarityの比較は無意味です。

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);
    }
    // すべてのchunkが新しいベクトルを持った後、query pipelineのエントリーポイントを変更します
    // 質問をembedするときも新しいモデルを使用するようにします。
}

重要な注意点:ユーザーのクエリも、検索時に同じ新しいモデルでembedする必要があります。documentのベクトルのみを変更して、クエリembedモデルの変更を忘れると、検索結果は完全に間違ったものになります。両側が同じベクトル空間に存在しなくなったためです。

9. よくあるエラーと不要な複雑化

  • よくあるエラー:updateを単一のupsert()と見なし、「新しいデータの書き込み」と「activeの切り替え」を分離しない — セクション2で述べた空白または重複につながります。
  • よくあるエラー:embedに使用されるテキスト部分にmetadataを含める(例えば、有効日を文に埋め込む)。これにより、すべてのmetadata変更がre-embedを強制し、不要なリソースを消費します。
  • よくあるエラー:古いchunkを履歴クエリのために保持する必要があることを忘れる — active切り替え後すぐに削除し、「過去のポリシーはどうでしたか?」に答える能力を失う。

このバージョニングプロセス全体が不要な場合:ドキュメントがほぼ静的な場合(変更が少ない、履歴クエリの要件がない、システムは数十人のユーザーで内部のみ実行)、スケジュールに従って全体をre-indexするバッチジョブ(例えば毎晩)の方がシンプルで十分に安全です — registryやversionIdの複雑さは不要です。

10. 結論

RAGシステムでの文書更新は、「古いベクトルを新しいベクトルで上書きする」という問題ではなく、システムが空白または矛盾に陥ることなく、2つのデータバージョン間の状態遷移を管理することです。不要なre-embedを回避するためにvectorとmetadataを分離し、versionIdとactive-version registryを使用して高速で rollback 可能な切り替えを行い、履歴クエリに対応するために十分な期間の古いデータを保持します。embeddingモデル全体を変更する場合、それを包括的なmigrationと見なします。通常のupdateではなく。

llmragvector-databasespring-bootjava