乐于分享
好东西不私藏

知识库的动态更新:文档变了,向量怎么同步?新增、修改、删除三种场景一次讲透

知识库的动态更新:文档变了,向量怎么同步?新增、修改、删除三种场景一次讲透

大家好,我是James。

上一篇我们聊了 Agentic RAG,让 Agent 自己决定要不要检索、检索几次——本质上是把检索策略从「固定流程」变成了「动态决策」。但有一个更基础的问题我们一直没讲:知识库本身是会变的,文档更新了,向量怎么跟着动?

很多同学搭完第一个 RAG 系统之后,会遇到这么一个情况:

产品文档昨天改了定价,但 AI 今天还在跟用户说旧价格。

排查一圈发现,文档确实更新了,向量库却还是老版本——因为根本没有同步机制,每次改文档全靠手动重导。

这还是小问题。更麻烦的是:

  • 文档改了,旧 chunk 还留在库里,和新内容同时被召回,LLM 拿到两个矛盾的片段,答案开始出现幻觉
  • 一篇敏感合同被删了,但向量库里的 chunk 还在,三个月后用户还能检索到它
  • 换了一个 embedding 模型,忘了重新入库,查询和索引用的是两个完全不同的向量空间,召回率腰斩

这些坑的根源是一样的:没有系统性地处理知识库的动态更新

这篇文章就把这件事从头讲透。三种文档变更操作(新增、修改、删除),对应的向量同步策略,LangChain 的 Index API 怎么用,常见坑一一拆解。


01 为什么向量同步这么难:三角不一致

先把问题说清楚。

一个典型的 RAG 知识库,背后至少有三个存储层:

原始文档(S3/本地磁盘)
       ↓ 解析 → 切块 → Embedding
向量数据库(Milvus/Pinecone/Qdrant)
       +
元数据库(PostgreSQL/SQLite)  ←  记录 doc_id、版本、哈希

文档变了,这三层都要同步。任何一层没跟上,系统就开始出问题。

最常见的两个失控点:

失控点 1:只写新向量,不删旧向量。

假设你的「产品介绍.pdf」被切成了 10 个 chunk。文档更新了,你重新切块,生成了 8 个新 chunk,写进向量库。但旧的 10 个 chunk 还在里面。

查询时,旧的 chunk 和新的 chunk 都可能被召回。LLM 拿到矛盾信息,轻则答案不准,重则开始编造。

失控点 2:删了文档,向量成了孤儿。

文档从原始存储里删了,但没人通知向量库。这些「孤儿向量」继续活在库里,被用户查到,指向一个已经不存在的文档。如果涉及敏感数据,这就是一个合规事故。

所以向量同步的核心不是「把新向量写进去」,而是保证三层数据始终一致


02 哈希去重:增量同步的核心武器

每次文档变化都要把所有 chunk 重新 Embedding,成本太高。聪明的做法是:先用哈希判断内容有没有变,没变就跳过

这是 LangChain Index API 的核心思路。

原理很简单:

文档 chunk → 计算 SHA-256 哈希 → 与记录管理器中已有哈希对比
                                    ↓
                       哈希一致 → 跳过(不重新 Embedding)
                       哈希不同 → 重新 Embedding → 写入向量库

用代码实现:

import { index } from "langchain/indexes";
import { SQLRecordManager } from "@langchain/community/indexes/sqlite";
import { OpenAIEmbeddings } from "@langchain/openai";
import { Milvus } from "@langchain/community/vectorstores/milvus";
import { Document } from "@langchain/core/documents";
import * as crypto from "crypto";

// 初始化向量库
const embeddings = new OpenAIEmbeddings({ model"text-embedding-3-large" });
const vectorStore = await Milvus.fromExistingCollection(embeddings, {
  collectionName"knowledge_base",
});

// 初始化记录管理器(用 SQLite 存哈希记录)
const recordManager = new SQLRecordManager("milvus/knowledge_base", {
  dbUrl"sqlite:///record_manager.db",
});
await recordManager.createSchema();

// 增量同步文档
async function syncDocuments(docsDocument[]) {
  const result = await index({
    docsSource: docs,
    recordManager,
    vectorStore,
    options: {
      cleanup"incremental",   // 增量模式:自动清理旧版本
      sourceIdKey"source",    // 用 source 字段标识文档来源
    },
  });
  
  console.log(`新增: ${result.numAdded}, 跳过: ${result.numSkipped}, 删除: ${result.numDeleted}`);
}

// 计算内容哈希(用于手动比对)
function contentHash(textstring): string {
  return crypto.createHash("sha256").update(text).digest("hex");
}

三种清理模式对比:

模式 自动清理已删除文档 实时清理旧版本 适用场景
none 只需去重,手动管理清理
incremental ✅(写入时) 文档只会修改,不会删除
full ✅(批次结束后) 需要处理文档删除
scoped_full ✅(批次结束后) 分批索引,按批次清理

关键差异: incremental 模式能在写入时实时清理旧版本,新旧内容并存的时间窗口最短;full 模式在全批次写入完成后才清理,适合每次传入完整文档列表的场景。


03 新增文档:幂等写入,不怕重复投递

新增是三种操作里最简单的,但有一个坑:重复投递

消息队列重复消费、worker 崩溃重启后重试——这些场景都会导致同一篇文档被触发多次「新增」。如果没有幂等保护,向量库里会堆满重复的 chunk,占存储、拖检索速度。

正确的新增流程:

interface ChunkMetadata {
  doc_idstring;
  chunk_idstring;
  content_hashstring;
  version_idnumber;
  sourcestring;         // 原始文件路径/URL(Index API 必需)
  source_typestring;    // "pdf" | "confluence" | "notion"
  embedding_modelstring;
  created_atstring;
  is_deletedboolean;
}

async function addDocument(filePathstring) {
  // 1. 解析 + 切块
  const loader = new PDFLoader(filePath);
  const docs = await loader.load();
  
  const splitter = new RecursiveCharacterTextSplitter({
    chunkSize512,
    chunkOverlap50,
  });
  const chunks = await splitter.splitDocuments(docs);
  
  // 2. 附加元数据(每个 chunk 携带来源信息)
  const docId = generateDocId(filePath);
  const chunksWithMeta = chunks.map((chunk, i) => ({
    ...chunk,
    metadata: {
      ...chunk.metadata,
      doc_id: docId,
      chunk_id`${docId}-chunk-${i}`,
      content_hashcontentHash(chunk.pageContent),
      version_id1,
      source: filePath,       // Index API 用这个做去重 key
      source_type"pdf",
      embedding_model"text-embedding-3-large",
      created_atnew Date().toISOString(),
      is_deletedfalse,
    } as ChunkMetadata,
  }));
  
  // 3. 用 Index API 写入(自带去重)
  await syncDocuments(chunksWithMeta);
}

哈希去重保证了幂等性:同一篇文档无论触发多少次,只要内容没变,第二次起全部跳过,不会产生重复记录。


04 修改文档:旧 chunk 清理是核心,不是可选项

修改比新增复杂,原因前面说了:必须清理旧版本向量

incremental 模式的删除逻辑是基于 source 字段的:同一个 source,内容哈希变了,就把旧 chunk 删掉,写入新 chunk。

async function updateDocument(filePathstringnewContentstring) {
  // 重新切块,source 保持原路径不变
  const newChunks = await splitContent(newContent, {
    source: filePath,   // ← 关键:source 不变,Index API 才能识别是同一文档的更新
  });
  
  // incremental 模式:自动删旧写新
  const result = await index({
    docsSource: newChunks,
    recordManager,
    vectorStore,
    options: {
      cleanup"incremental",
      sourceIdKey"source",
    },
  });
  
  // 预期:num_deleted > 0(旧 chunk 被清理),num_added > 0(新 chunk 写入)
  console.log(result);
  // { numAdded: 8, numUpdated: 0, numSkipped: 0, numDeleted: 10 }
}

一个高频踩坑点:文档重新切块后 chunk 数量变了。

假设原来 10 个 chunk,更新后内容精简了,变成 6 个。如果用的是基于 chunk_id 的 upsert,旧的 7-10 号 chunk 会永远留在库里。

incremental 模式用 source 做关联,只要 source 一样,不管 chunk 数量怎么变,旧版本全部清理干净。


05 删除文档:软删除 + 延迟物理清理的标准姿势

文档删除是最容易埋雷的操作。两种思路:

方案 A:直接物理删除

// 直接从向量库删
async function hardDeleteDocument(sourcestring) {
  await index({
    docsSource: [],      // 传空列表
    recordManager,
    vectorStore,
    options: {
      cleanup"full",   // full 模式:传入列表之外的文档全部删除
      sourceIdKey"source",
    },
  });
  // 问题:需要传入所有"应该保留"的文档列表,适合文档集合小的场景
}

// 更精准的做法:按 source 删除
async function deleteBySource(sourcestring) {
  // 先从记录管理器查出这个 source 对应的所有 vector ID
  // 再批量删除
  const ids = await recordManager.listKeys({ after0beforeDate.now(), groupIds: [source] });
  await vectorStore.delete({ ids });
  await recordManager.deleteKeys(ids);
}

方案 B:软删除(推荐)

// 软删除:不立即物理清理,先标记 is_deleted
async function softDeleteDocument(docIdstring) {
  // 1. 更新元数据库:is_deleted = true
  await metaDB.update(
    { is_deletedtruedeleted_atnew Date().toISOString() },
    { where: { doc_id: docId } }
  );
  
  // 2. 查询时自动过滤(向量库元数据过滤)
  // retriever 配置中加 filter: { is_deleted: false }
  
  // 3. 定时任务:30天后执行物理清理
  await schedulePhysicalCleanup(docId, 30 * 24 * 60 * 60 * 1000);
}

// 软删除检索器:只返回 is_deleted=false 的结果
const retriever = vectorStore.asRetriever({
  filter: { is_deletedfalse },
  k5,
});

为什么推荐软删除?

删除操作不可逆。误删了一篇重要文档,软删除可以 30 秒内恢复;物理删除就要重新解析、切块、Embedding,至少几分钟。对于敏感文档(合规删除要求),软删除 + 延迟物理清理还能提供 30 天的审计窗口。


06 Embedding 模型升级:最容易被忽视的定时炸弹

这个问题不如「文档更新」直观,但在真实项目里炸过不止一次。

本质是向量空间不兼容。

OpenAI 的 text-embedding-3-smalltext-embedding-3-large 向量空间不同,维度也不同(1536 vs 3072)。索引时用 small,查询时误用 large,就等于在两个完全不相干的数学空间里做距离计算——结果是随机的。

// 元数据里记录 embedding 模型版本(关键)
interface EmbeddingMetadata {
  embedding_modelstring;         // "text-embedding-3-large"
  embedding_model_versionstring// "2025-01-15"
  embedding_dimensionnumber;     // 3072
}

// 查询前校验模型版本
async function safeSearch(querystringexpectedModelstring) {
  // 检查向量库的模型版本记录
  const indexMeta = await getIndexMetadata();
  
  if (indexMeta.embedding_model !== expectedModel) {
    throw new Error(
      `模型不匹配!索引用的是 ${indexMeta.embedding_model},` +
      `查询用的是 ${expectedModel}。请先重建索引。`
    );
  }
  
  return await vectorStore.similaritySearch(query, 5);
}

模型升级的正确姿势:蓝绿切换,不原地升级。

1. 新建 collection(knowledge_base_v2),用新模型重新入库全量数据
2. 双索引并行运行一周,对比召回率
3. 确认 v2 稳定后,通过别名切换(alias swap)把流量切过去
4. 保留 v1 两周,用于回滚
5. 确认无问题后,删除 v1

原地升级(直接把旧 chunk 替换)的风险是:替换过程中,库里同时存在新旧两种向量空间的数据,检索结果完全不可预期。


07 生产级同步架构:事件驱动 + 补偿机制

前面讲的都是单次操作。生产环境里,文档变更是持续发生的,需要一套自动化的同步管道。

推荐架构:

文档源(Confluence/Notion/S3)
         ↓ 变更事件(Webhook / CDC / 轮询)
     消息队列(Kafka/Redis Queue)
         ↓ 消费(at-least-once delivery)
     同步 Worker
       ├── 解析 + 切块
       ├── 哈希去重(跳过未变化的 chunk)
       ├── Embedding(只对变化的 chunk)
       └── 写向量库 + 更新元数据库
         ↓
     一致性检查(定时 Reconciliation)
       └── 扫描元数据库和向量库的差异,自动补偿

关键设计点:

// Worker 的幂等处理
async function processDocumentEvent(eventDocumentChangeEvent) {
  const { type, source, docId } = event;
  
  // 记录处理状态(避免重复处理)
  const status = await getProcessingStatus(event.eventId);
  if (status === "completed") {
    console.log(`事件 ${event.eventId} 已处理,跳过`);
    return;
  }
  
  try {
    await markProcessing(event.eventId);
    
    switch (type) {
      case "created":
      case "updated":
        await syncDocumentToVectorStore(source, { cleanup"incremental" });
        break;
      case "deleted":
        await softDeleteDocument(docId);
        break;
    }
    
    await markCompleted(event.eventId);
  } catch (err) {
    await markFailed(event.eventId, err.message);
    throw err; // 触发消息队列的重试机制
  }
}

// 定时 Reconciliation:发现并修复不一致
async function reconcile() {
  // 1. 查元数据库:所有 is_deleted=false 的文档
  const activeDocs = await metaDB.findAll({ is_deletedfalse });
  
  // 2. 查向量库:所有存在的 doc_id
  const vectorDocIds = await vectorStore.listDocIds();
  
  // 3. 找出差异:元数据库有但向量库没有的
  const missing = activeDocs.filter(d => !vectorDocIds.includes(d.doc_id));
  
  // 4. 补偿:重新同步缺失的文档
  for (const doc of missing) {
    console.log(`补偿同步: ${doc.source}`);
    await syncDocumentToVectorStore(doc.source, { cleanup"incremental" });
  }
}


08 常见坑:这几个错误 90% 的人都踩过

坑 1:source 字段没有统一规范,导致同一文档被识别为不同来源。

/data/docs/product.pdf./docs/product.pdf 对于 Index API 来说是两个不同的 source。文档更新了,旧 chunk 没被清理,反而增加了一份新的。

→ 规范:所有文档统一用绝对路径或全局唯一 ID 作为 source。

坑 2:切块策略变了,但忘了触发全量重建。

chunkSize=512 改成 chunkSize=256,同一篇文档切出来的 chunk 数量翻倍。incremental 模式无法感知切块策略的变化(只看内容哈希),结果新旧两套 chunk 并存在库里。

→ 解法:把切块策略(chunkSize、overlap、策略名)也写进元数据,切块策略变更时触发 full 模式重建。

坑 3:权限变更没有触发重新索引。

一篇文档从「所有人可见」改为「仅高管可见」,但向量库里 chunk 的 acl 字段还是旧的。普通员工查询时仍能召回。

→ 解法:权限变更事件和内容变更事件一样,都要触发文档重新索引,确保 acl 元数据同步。

坑 4:incremental 模式不处理文档删除。

incremental 只能清理「已更新文档的旧版本」,无法感知「文档从源系统被彻底删除」。如果文档被删了,还用 incremental 模式,旧 chunk 永远不会消失。

→ 解法:文档删除事件用 full 模式(传入剩余文档的完整列表),或手动按 source 删除记录。

坑 5:换了 embedding 模型,忘了重建索引。

上线前测试用 text-embedding-ada-002,上线后业务方要求换 text-embedding-3-large。直接换了调用模型,但历史 chunk 还是 ada-002 的向量,查询时召回率骤降。

→ 解法:模型版本写进元数据,换模型时检查不一致,强制触发全量重建。


总结

知识库动态更新,核心是保证向量库、元数据库、原始文档三层一致,任何一层脱轨都会导致召回结果失真。

  • 哈希去重是增量同步的基础:用 SHA-256 判断 chunk 内容是否变化,未变的跳过 Embedding,大幅节省成本
  • 修改文档必须清理旧向量incremental 模式按 source 自动清理旧版本,单纯写入新 chunk 是最危险的做法
  • 删除推荐软删除 + 延迟物理清理:保留 30 天审计窗口,误删可恢复,合规删除有保障
  • 切块策略变更 = 全量重建incremental 模式无法感知切块策略变化,策略改了必须触发重建
  • Embedding 模型升级用蓝绿切换:不原地改,新建索引验证稳定后切别名,保留旧索引用于回滚
  • 生产环境加补偿机制:定时 Reconciliation 扫描三层数据差异,自动修复不一致

下一篇我们进入 RAG 效果评估,聊聊怎么知道你的检索到底好不好——用数据说话,而不是「感觉还行」。


关注我,James 的成长日记,持续分享干货,帮你在 AI 时代少走弯路。