在RAG应用——无论是普通RAG还是GraphRAG——的实际落地中,领域知识的导入与索引是确保生成质量的基础。但一个绕不开的痛点在于:当这些知识本身发生变化时,如何以最高效、最经济的方式,同步更新那些已经建好的向量索引或知识图谱索引?这是一个非常现实的挑战。
需求
通常,企业内部的知识库有相对成熟的维护流程,但让这些变化“流”进RAG系统,就完全是另一回事了。知识的RAG化需要经过拆分(split)、嵌入(embedding)、再到向量化索引(vector index)等一系列步骤。
这意味着,一旦源文档有变动,整个链条就要跟着响应:首先得识别出哪些文档发生了变化,然后针对不同的知识块(chunk)采取不同动作——是忽略它、新增它、删除它、还是更新它。
在实际工程中,主流的增量更新策略分为两个级别:
第一种是文档(Document)级别的简单更新。操作很直接:识别出新增或修改过的文档,对整个文件做一次完整的解析和向量化,再与已有的索引合并。优点是实现简单,缺点是成本较高,即使只有一个小改动,也要全量重算。
第二种是块(Chunk)级别的精细更新。这是更高级的做法。它会深入到文档内部,精准判断:哪些块是新增的,哪些块的内容变了需要重建,哪些块可以原封不动跳过。如此一来,计算量大幅降低,重复的索引被消除,向量库中始终保持着“最新、有效且无冗余”的上下文。这不仅节约了嵌入API的成本,更直接提升了检索的准确性。
方案
实现上述增量逻辑,核心思路是引入“指纹”。无论是文档级还是块级,这套机制的基本原理是相通的。我们以更细致的Chunk级别为例,拆解一下这个过程:
- 每次处理开始时,先为每个知识块计算一个唯一的哈希指纹。这个指纹基于块的内容及其元数据生成,保证了内容的任何微小差异都能被捕捉。
- 需要一个持久化的跟踪机制,记录每次处理后的块信息——包括来源文档、块内容、哈希指纹、时间戳等。在LangChain里这个角色叫RecordManager,在LlamaIndex里则是DocumentStore。
- 增量更新时,通过对比当前计算出的哈希指纹与上一次记录的指纹,来决定每个块的处理动作:
- 指纹完全相同 → 跳过,不做任何处理。
- 指纹在本次出现,但上一次没有 → 执行新增。
- 指纹在上一次出现,但本次消失了 → 执行删除。
- 最后,根据这些决策,对数据块执行相应的嵌入和索引更新操作。需要注意的是,这一步对向量数据库的底层能力有一定要求,不是所有数据库都支持高效的增量索引更新。
实现
目前主流的两个LLM应用框架——LangChain与LlamaIndex——都提供了对这种增量更新的原生支持。虽然实现路径略有差异,但核心思想高度一致。下面做一个简单的演示。
【LangChain的索引API】
如果你用的是LangChain,并且希望向量索引能跟上文档的节奏,那就不该再用简单的from_documents()方法,而是要用它的索引API(Indexing API)。区别于传统方式的最大一点,就是它内置了增量更新能力:自动跳过未变更的块,只对新增或变化的块进行嵌入和写入。
要启用该能力,需要借助一个RecordManager组件,用来持久化记录每个知识块的源文档ID、哈希指纹和时间戳。下面是一个可以直接运行的参考代码:
from langchain.indexes import SQLRecordManager, index
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
from langchain_text_splitters import CharacterTextSplitter
from langchain_community.document_loaders import DirectoryLoader
# 嵌入模型、向量库
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma(
collection_name="example_collection",
embedding_function=embeddings,
persist_directory="./db_chroma"
)
# 记录管理器,用来跟踪向量库中已经存储的Document(hash、时间戳、source_id)
namespace = f"chroma/mydocs"
record_manager = SQLRecordManager(
namespace, db_url="sqlite:///record_manager_cache.sql"
)
record_manager.create_schema()
# 文档加载与分割
loader = DirectoryLoader("../data",glob='*.txt')
docs = loader.load()
docs = CharacterTextSplitter(separator='n',chunk_size=30,chunk_overlap=2).split_documents(docs)
# 向量话并索引
result = index(
docs,
record_manager,
vector_store,
cleanup='incremental',
source_id_key="source",
)
# 打印处理情况
print(result)
核心就在于 index() 方法。它除了接收待处理的块(docs)、向量库(vector_store)、记录管理器(record_manager)和源文档标识键(source_id_key)外,还有一个特别重要的参数 —— cleanup,它决定了LangChain对向量库中现有知识块的清理策略。三种模式都会基于哈希值跳过重复块并插入新块,但对旧块的清理规则不同:
- none:不对已有块做任何清理。
- incremental:如果某个源文档的块发生了变更(产生了新的哈希指纹),则清除该块的历史版本。
- full:比incremental更彻底。如果源文档中只删除了部分块(哪怕没有产生新的哈希指纹),full模式也会从向量库中将这部分旧块清除掉。
换句话说,incremental和full的关键区别在于:如果你只是从源文档中删除了几个块,而没有新增任何内容,incremental模式不会去动向量库;而full模式则会主动将其清理。
用上面代码来做一组对比测试。假设初始文档内容如下(按行分割后,会生成3个chunk):
首次索引后的结果是(无论选用哪种模式):
现在,把文档内容修改为:删掉最后一行,并修改第二行:
再次运行索引,结果如下(incremental或full效果一致):
可以看到,跳过了第一行对应的chunk (num_skipped=1),新增了第二行对应的chunk (num_added=1),删除了原有的第二行和第三行对应的chunk (num_deleted=2)。因为这里发生了修改,所以两者没有差异。
现在,如果我们直接删除第二行,文档变成只有第一行和第三行:
此时,两种模式的处理结果就出现了差别:
incremental模式:由于只是删除了第二个chunk,并未产生新的哈希指纹,因此它只会跳过重复的chunk(num_skipped=1),不做任何删除动作。
full模式:除了跳过重复块(num_skipped=1),还会明确地删除掉那个消失的第二个chunk(num_deleted=1)。
【LlamaIndex框架的数据摄入管道】
如果你更倾向于LlamaIndex,则需要借助它的数据摄入管道(IngestionPipeline)来实现类似效果。需要指定一个文档存储(docstore)以及具体的存储策略(docstore_strategy)。核心代码示意如下:
......
pipeline = IngestionPipeline(
transformations=[
TokenTextSplitter(chunk_size=20, chunk_overlap=0,separator="n"),
embedded_model
],
vector_store=vector_store,
docstore=RedisDocumentStore.from_host_and_port("localhost", 6379, namespace="document_store"),
docstore_strategy='upserts'
)
docs = SimpleDirectoryReader(input_files=["../data/datafile1.txt"],filename_as_id=True).load_data()
nodes = pipeline.run(documents=docs,show_progress=False)
......
详细细节可以参考LlamaIndex官方文档,这里不再赘述。
GraphRAG的增量更新
GraphRAG是近期的热点,它借助知识图谱和图数据库,将文本中的实体与关系显式组织起来,再结合向量检索、社区识别等算法,实现更复杂的知识处理与答案生成。借用成熟框架(如Microsoft GraphRAG)是个好主意,但一个尴尬的现实是:官方版本目前尚未实现增量更新。由于涉及图结构、社区发现等高级数据结构,其增量更新的难度远高于普通RAG。
在这方面,一个值得关注的开源项目是 nano-GraphRAG。它是Microsoft GraphRAG的精简版,保留了核心功能,且提供了初步的增量更新能力。其思想也是通过对比文档与知识块的哈希值,识别新增的内容,然后在已有的图结构上做增量插入——添加新的实体和关系。此外,它同样提供了社区识别功能,不过目前每次发生图更新时,它会对所有社区信息进行全量重算,尚未做到社区级别的增量更新。
对该项目感兴趣的朋友,可以在GitHub上搜索了解更多细节,后续我们也会进行更深入的测试与解读。
问题与展望
以上我们围绕RAG应用中知识文档的增量更新进行了探讨。对于企业级应用,特别是那些文档频繁变动的场景,这项能力对于降低维护成本、保持检索一致性至关重要。当然,现有的方案也并非尽善尽美,仍有一些问题值得进一步思考:
- 基于Chunk哈希的指纹策略,在使用固定chunk_size进行分割时,文档中间哪怕只有一个字符的改动,也可能触发大量相邻chunk的哈希变化,从而导致不必要的全量更新。
- 存在一种情况:文本内容发生了细微变化,但语义并未改变。这会导致无效的更新。但如果引入LLM来判断语义是否变化,又会带来新的性能开销和成本。
- 对于更复杂的知识结构,比如多模态文档,或者是不同类型的索引(例如上述的Graph Index),如何高效、准确地识别变化并执行增量更新,仍是一个开放性问题。
- 在真实的企业环境中,文档更新的策略应该更有弹性:对于实时性要求高的数据,可以采用频繁的动态更新;而对于变化频率低或时效性要求不高的数据,用简单的批量更新就足够了。
可以预见,随着RAG技术在各行各业深入落地,增量更新方案也会越来越成熟。这确实是一个值得持续关注的领域。
