RAG 문서 수정은 덮어쓰기가 아니다: LLM을 위한 올바른 업데이트 방법

이 글에는 짧은 영상이 있습니다:YouTubeTikTok

1. 개요

RAG(Retrieval-Augmented Generation) 시스템에서 소스 문서는 절대 고정되어 있지 않습니다. 정책이 바뀌고, 가격이 바뀌고, 규정이 바뀝니다. 벡터 스토어에서 기존 청크를 새 청크로 간단히 “덮어쓰면” 시스템은 같은 질문에 대해 맞는 답도 주고 틀린 답도 주는 상태에 빠지기 쉽습니다. 이 글은 직접 덮어쓰기가 왜 위험한 설계인지 설명하고, 더 안전한 업데이트 프로세스를 제시합니다. 새 청크 준비, 검증, 그리고 활성 버전 전환 — 구체적인 Java 코드 예시를 포함합니다.

2. 문제점: 왜 직접 덮어쓰기가 데이터 불일치를 초래하는가

회사에서 호텔 환급 한도를 5,000,000원에서 7,000,000원으로 인상했다고 가정해봅시다. 정책 문서는 여러 청크로 나뉘고, 각 청크는 벡터로 임베딩되어 벡터 데이터베이스에 저장됩니다. 정책이 바뀔 때 다음과 같이 하면:

  • 새 청크를 먼저 삽입한 후 기존 청크를 삭제 → 두 청크가 일시적으로 함께 존재하며 둘 다 “현재 질문에 유효한 것”으로 간주됩니다. 의미론적 검색 쿼리는 기존 청크(5,000,000원)를 반환할 수 있습니다. 그 청크의 벡터 유사도가 여전히 높기 때문입니다.
  • 기존 청크를 먼저 삭제한 후 새 청크를 삽입 → 어떤 청크도 답할 수 없는 상황이 발생합니다. 호텔 환급 한도에 대한 질문에 LLM은 “지어내거나” 정보가 없다고 답해야 합니다.

두 방법 모두 문제가 있습니다. 왜냐하면 “업데이트”를 단일 작업으로 취급하지만, 실제로는 두 가지 작업(추가 + 제거)이 원자적(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는 아직이거나 중간에 실패한 상황에서, 쿼리는 호텔 환급 한도에 대한 청크를 찾지 못합니다.

주의: Production RAG 시스템의 경우 이 공백은 이론이 아닙니다 — 트랜잭션이나 버전 관리 메커니즘 없이 데이터 업데이트 작업을 배포할 때마다 실제로 발생합니다.

3. 언제 재임베딩이 필요하고 언제 아닌가

모든 변경이 벡터를 다시 계산할 필요는 없습니다. 벡터는 청크의 텍스트 내용에서 생성됩니다. 메타데이터만 변경하면서(예: 시행 날짜, 적용 부서) 청크 내 문장은 바뀌지 않으면, 재임베딩할 필요가 없습니다 — 메타데이터만 업데이트하면 됩니다.

public record PolicyChunk(
        String id,
        String text,              // 벡터 생성에 사용되는 내용
        float[] embedding,
        Map<String, String> metadata // 임베딩되지 않는 부분
) {}

text가 변경되면(예: “5,000,000원” → “7,000,000원”), 기존 벡터는 더 이상 내용을 정확히 반영하지 않으므로 → 반드시 재임베딩해야 합니다. 하지만 문서의 다른 청크들(예: 적용 조건 부분, 금액은 변경 없음)은 벡터를 유지할 수 있습니다 — 건드릴 필요가 없습니다.

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를 별도 메서드로 분리하면 명시적으로 선택할 수 있습니다. 메타데이터 변경은 재임베딩 불필요, 텍스트 변경은 재임베딩 필요. 많은 벡터 데이터베이스(Qdrant, Weaviate, Pinecone)는 벡터와 독립적으로 페이로드/메타데이터 업데이트를 지원합니다 — 사용하는 제품의 문서를 확인한 후 로직을 작성하세요.

4. 버전 설계 및 활성-버전 플래그

청크를 제자리에서 수정하는 대신, 각 콘텐츠 변경을 새로운 버전으로 취급하고 고유한 versionId를 부여합니다. 기존 청크는 즉시 삭제되지 않고 — 현재 정책에 대한 쿼리에서 더 이상 “활성”으로 표시되지 않을 뿐입니다.

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에 대해 어떤 버전이 “현재 유효한” 버전인지. 검색할 때는 metadata.versionId == activeVersion으로 필터링합니다. 청크가 존재하는지 여부에 의존하지 않습니다. 덕분에 버전 전환은 포인터 변경 하나가 되어 빠르고 거의 원자적입니다 — 여러 청크에 걸친 insert/delete 시리즈가 아닙니다.

5. 안전한 업데이트 프로세스: 준비 → 검증 → 활성화

프로세스는 세 단계로 나뉘어 섹션 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: 준비 — 새 청크를 임베딩하고 저장, 아직 활성 버전을 바꾸지 않음
        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: 검증 — 새 청크가 실제로 읽을 수 있는지, 비어있지 않은지 확인
        boolean ready = newVersion.chunks().stream()
                .allMatch(c -> vectorStore.findById(c.id()).isPresent());
        if (!ready) {
            throw new IllegalStateException("새 청크가 아직 준비되지 않았습니다. 활성 버전을 전환하지 않습니다.");
        }

        // 단계 3: 활성 버전 전환 — 빠르고 거의 원자적인 작업
        registry.setActive(policyKey, newVersion.versionId());
    }
}

흐름 설명:

  • 단계 1은 어떤 쿼리에도 영향을 주기 전에 실행됩니다 — 새 청크는 기존 청크와 함께 존재하지만, 기존 청크는 여전히 활성이므로 사용자는 준비 과정 내내 일관된 답변(5,000,000원)을 받습니다.
  • 단계 2는 데이터가 새로 기록되지 않았거나 부분적으로 실패했을 때 활성 버전을 전환하는 것을 방지합니다 — “포인터를 바꾸지만 청크는 없다”는 상황을 피합니다.
  • 단계 3은 레지스트리의 한 값만 변경합니다. 이것이 유일한 전환점이므로 제어와 롤백이 쉽습니다(기존 versionId로 다시 설정하면 됨).

기존 청크(v1)는 즉시 삭제되지 않습니다. 별도의 작업에서 나중에 정리할 수 있으며, 정책 업데이트의 중요 경로에 있지 않습니다.

6. 과거 질문에 답할 수 있는 능력 유지

버전 관리 설계의 추가 이점: “작년에 호텔 환급 한도가 얼마였나요?“라는 질문에도 답할 수 있습니다. 청크 v1이 아직 존재하므로, 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는 항상 레지스트리에서 가져온 activeVersion으로 필터링합니다 — 현재 어떤 버전이 활성인지를 결정하는 유일한 장소입니다. searchHistoricalPolicy는 명시적으로 특정 버전을 쿼리할 수 있도록 하여 과거 질문이나 감시 목적을 지원합니다.

7. 활성 버전 전환 동작에 대한 테스트

프로세스가 “반반의” 상태를 외부에 노출하지 않도록 테스트를 작성합니다.

<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", "환급 한도 7,000,000원", null, Map.of())),
                Instant.now());

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

        assertThat(registry.getActiveVersion("hotel-reimbursement"))
                .contains("v2");
        // v1 청크는 여전히 스토어에 있어 과거 쿼리에 사용됨
        assertThat(store.findById("chunk-v1-1")).isPresent();
    }
}

이 테스트는 두 가지를 확인합니다: applyNewVersion이 완료된 후 활성 버전은 v2여야 하고, v1의 청크는 잃어버리지 않습니다 — 과거 쿼리를 할 수 있습니다.

8. 임베딩 모델 변경: 더 큰 문제

위의 모든 내용은 임베딩 모델이 변하지 않는다고 가정합니다. 모델 A에서 모델 B로 전환하면(예: 제공자 변경, 벡터 차원 변경), 존재하는 모든 청크를 새 모델로 다시 임베딩해야 합니다 — 방금 수정한 청크뿐만 아닙니다. 모델 A와 모델 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);
    }
    // 모든 청크가 새 벡터를 가진 후, 쿼리 파이프라인의 진입점을 변경
    // 사용자 쿼리도 새 모델로 임베딩되도록 함
}

핵심: 사용자의 질문도 검색 시점에 새 모델로 임베딩되어야 합니다. 문서의 벡터만 변경하고 질문 임베딩 모델을 바꾸지 않으면, 두 쪽이 다른 공간에 있으므로 검색 결과가 완전히 잘못됩니다.

9. 흔한 실수와 복잡함을 피해야 할 때

  • 흔한 실수: 업데이트를 단일 upsert() 작업으로 취급, “새 데이터 저장”과 “활성화”를 분리하지 않음 — 섹션 2에서 언급한 공백이나 중복 발생.
  • 흔한 실수: 메타데이터를 임베딩 텍스트 부분에 포함시키기(예: 시행 날짜를 문장 안에 넣기) — 메타데이터 변경마다 재임베딩 강제, 불필요한 리소스 낭비.
  • 흔한 실수: 기존 청크를 활성 버전 전환 후 즉시 삭제 — 과거 쿼리 능력 손실.

언제 전체 버전 관리가 필요 없는가: 문서가 거의 정적이면(변경 거의 없음, 과거 쿼리 요구사항 없음, 수십 명의 사용자만 있는 내부 시스템), 매일 밤 전체 인덱스를 다시 구성하는 배치 작업이 더 간단하고 충분히 안전합니다 — 레지스트리, versionId 복잡함 불필요.

10. 결론

RAG 시스템의 문서 업데이트는 “기존 벡터를 새 벡터로 덮어쓰기”가 아닙니다 — 시스템이 공백이나 모순에 빠지지 않도록 두 데이터 버전 사이의 상태 전환을 관리하는 문제입니다. 벡터와 메타데이터를 분리하여 불필요한 재임베딩을 피하고, versionId와 active-version registry를 사용하여 빠르고 롤백 가능한 전환을 만들고, 과거 쿼리를 지원하기 위해 기존 데이터를 충분히 오래 유지합니다. 임베딩 모델을 변경할 때는 그것을 일반적인 업데이트가 아닌 전체 마이그레이션으로 취급합니다.

llmragvector-databasespring-bootjava