修正RAG文档无需覆盖:给LLM的正确更新方法

本文有一个短视频:YouTubeTikTok

1. 概览

在RAG(检索增强生成)系统中,源文档永远不会保持不变:政策会改、价格会改、法规会改。如果你仅仅是用新chunk来“覆盖”向量库中的旧chunk,系统很容易陷入一种既能正确回答又能错误回答同一问题的状态。本文解释了为什么简单的覆盖是一种危险的设计,并呈现了一个更安全的更新流程:准备新chunk、验证、然后切换active版本——包含具体的Java代码示例。

2. 问题:为什么直接覆盖会导致数据不一致

假设你的公司将酒店报销限额从500万元提高到700万元。政策文档被分成多个chunk,每个chunk被嵌入为向量并存储在向量数据库中。当政策变化时,如果你:

  • 先插入新chunk,后删除旧chunk → 会有一段时间新旧chunk同时存在,两者都被视为“当前查询的有效答案”。语义搜索可能会返回旧chunk(500万元),因为它与问题的向量相似度仍然很高。
  • 先删除旧chunk,后插入新chunk → 会有一段时间没有任何chunk能回答关于限额的问题,LLM被迫“编造”答案或说没有信息。

两种方式都有问题,因为它们把“更新”视为单一操作,而实际上是两个(添加+移除)非原子操作。

// 错误做法:隐式覆盖,没有控制转换状态
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机制的数据更新job时,它都会真实发生。

3. 何时需要重新嵌入,何时无需

不是所有变化都需要重新计算向量。向量是从chunk的文本内容生成的。如果你只改变元数据(例如:生效日期、应用部门),而不改变chunk中的词句,你就不需要重新嵌入——只需要更新元数据。

public record PolicyChunk(
        String id,
        String text,              // 用于生成向量的内容
        float[] embedding,
        Map<String, String> metadata // 不包含在用于嵌入的text中
) {}

如果text变化(例如“500万元”→“700万元”),旧向量不再正确反映内容→必须重新嵌入该chunk。但文档中的其他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拆分为两个独立的方法让你主动选择:改元数据不重新嵌入,改文本则重新嵌入。一些向量数据库(Qdrant、Weaviate、Pinecone)支持独立更新payload/元数据和向量——在编写更新逻辑前检查你使用的产品文档。

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,哪个版本被视为“当前有效”。搜索时,你按metadata.versionId == activeVersion过滤,而不是依赖chunk是否仍然存在。因此,版本切换变成了一个改变指针的操作,快速且近乎原子——不再是分散在多个chunk上的insert/delete序列。

5. 安全的更新流程:准备→验证→切换active

该流程包含三个明确的步骤,避免了第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,尚未改变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);
        }

        // 步骤2:验证——新chunk确实可读,不为空
        boolean ready = newVersion.chunks().stream()
                .allMatch(c -> vectorStore.findById(c.id()).isPresent());
        if (!ready) {
            throw new IllegalStateException("新chunk尚未就绪,不切换active版本");
        }

        // 步骤3:切换active版本——快速操作,近乎原子
        registry.setActive(policyKey, newVersion.versionId());
    }
}

流程解释:

  • 步骤1在任何查询受影响前执行——新chunk与旧chunk并行存在,但旧chunk仍是active,用户在整个准备过程中仍能获得一致的答案(500万元)。
  • 步骤2确保不会在数据未准备好或部分失败时切换active版本——避免了“改变标志但chunk不存在”的情况。
  • 步骤3只改变registry中的一个值。这是唯一的转换点,你很容易控制和回滚(只需重新设置旧的versionId)。

旧chunk(v1)不会立即被删除。你可以在单独的job中稍后清理它,不在关键路径上。

6. 保留回答历史问题的能力

versioning设计的一个附加好处:你仍然能回答诸如“去年酒店报销限额是多少?“这样的问题。因为chunk的v1尚未删除,你只需在检索层使用不同的过滤器。

public List<PolicyChunk> searchCurrentPolicy(String policyKey, float[] queryVector) {
    String activeVersion = registry.getActiveVersion(policyKey)
            .orElseThrow(() -> new IllegalStateException("没有" + policyKey + "的active版本"));
    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允许显式查询特定版本,服务于历史性或审计问题。

7. 测试active版本切换行为

编写测试确保流程永远不会向外暴露“半完成”状态。

<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");
        // v1的chunk仍在store中,用于历史查询
        assertThat(store.findById("chunk-v1-1")).isPresent();
    }
}

这个测试确认了两件事:applyNewVersion完成后,active版本必须是v2,且v1的chunk并未丢失——你仍能查询历史。

8. 改变嵌入模型:一个更大的问题

上述所有内容都假设嵌入模型不变。如果你从模型A切换到模型B(例如改变供应商、改变向量维度),所有现存chunk都需要用新模型重新嵌入——不只是编辑过内容的chunk。模型A和模型B的向量不在同一空间中,比较它们的cosine相似度是毫无意义的。

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都有新向量后,改变查询pipeline的入口点
    // 使得新的查询也用newModel来嵌入问题。
}

关键要点:用户的查询也必须在搜索时用新模型嵌入。如果你只改变文档的向量而忘了改变问题嵌入的模型,搜索结果会完全错误,因为两边不再在同一向量空间中。

9. 常见错误和何时不需要复杂化

  • 常见错误:把update当作单一的upsert(),不分离“写新数据”和“切换active”——导致第2部分提到的空白期或重复。
  • 常见错误:让元数据混入用于嵌入的文本中(例如把生效日期写进句子),导致每次元数据改变都必须重新嵌入,浪费资源。
  • 常见错误:忘记为历史查询保留旧chunk——更新后立即删除,失去回答“之前政策如何”的能力。

何时无需这整套versioning流程:如果你的文档几乎静态(很少改变、无历史查询需求、系统仅在内部运行、用户寥寥数十人),一个简单的批量重索引job(例如每晚)更简单且足够安全——不需要registry、不需要复杂的versionId。

10. 结论

在RAG系统中更新文档不是“用新向量覆盖旧向量”的问题——它是管理两个数据版本之间转换状态的问题,确保系统不会陷入空白或矛盾。将向量和元数据分离以避免不必要的重新嵌入,使用versionId配合active-version registry实现快速且可回滚的切换,并长期保留旧数据以服务历史查询。当改变嵌入模型本身时,把它视为整体迁移,而非常规更新。

llmragvector-databasespring-bootjava