线上RAG应用pdf文档频繁更新,老板下了死命令要节省预算,不能重复做embedding,我这么做..... 原创

发布于 2024-12-3 08:51
浏览
0收藏

我们最近在一个项目中遇到了一个问题。项目的场景是这样的:用户将他们的PDF文档存储在磁盘的某个特定目录中,然后有一个定时任务来扫描此目录并从中的PDF文档构建知识库。

一开始,我们采用"增量更新"策略。在扫描目录中的文档时,我们会对每个文档进行哈希运算以生成其指纹,并检查该指纹是否已存在于数据库中。如果指纹不存在,就表示这是一个新文件,我们会对新文件的document做embedding,然后将其加入到知识库中。

然而,这种方法存在一个问题。如果同一文件进行了增量添加,例如我们已经将A.pdf文件加入到了知识库,但后来这个文件添加了新的内容。当我们重新计算其指纹并在数据库中查找时,由于指纹不存在,我们会将这个更新过的文件作为新文件处理,并重新做embedding加入到知识库。这样一来,对于未更新的部分,知识库会有两份相同的数据记录,第二份相同的记录可能会"占据"原本应该被召回的数据记录的位置,从而降低问答效果。

那么应该怎么解决这个问题呢?对于增量更新,做hash指纹这一点毋庸置疑,但是hash的对象不能是文件了,而应该聚焦于真实存到知识库的数据: document.

在这里,我们将查看使用LangChain index API的基本索引工作流。

index API允许您将来自任何源的文档加载到矢量存储中并保持同步。具体来说,它有助于:

  • 避免将重复的内容写入vector存储
  • 避免重写未更改的内容
  • 避免在未更改的内容上重新计算embedding

所有这些都可以节省你的时间和金钱,并改善你的矢量搜索结果。

如何工作

LangChain索引使用记录管理器(RecordManager)来跟踪写入矢量存储的文档。

当索引内容时,为每个文档计算哈希值,并将以下信息存储在记录管理器中:

  • 文档hash(页面内容和元数据的散列)
  • 写时间
  • 源id——每个文档应该在其元数据中包含信息,以便我们确定该文档的最终来源

删除模式

将文档索引到矢量存储时,可能会删除矢量存储中的一些现有文档。在某些情况下,您可能希望删除与正在索引的新文档来自相同来源的所有现有文档。在其他情况下,您可能希望批量删除所有现有文档。索引API删除模式可以让你选择你想要的行为:

Cleanup Mode

De-Duplicates Content

Parallelizable

Cleans Up Deleted Source Docs

Cleans Up Mutations of Source Docs and/or Derived Docs

Clean Up Timing

None

-

Incremental

Continuously

Full

At end of indexing

快速开始

首先,需要明确的是,无论使用何种清理模式,index函数都会自动去重。也就是说,调用index([doc1, doc1, doc2])的效果等同于调用index([doc1, doc2])。然而,在我们的实际应用场景中,情况并不完全如此。

可能在第一次运行时,我们对[doc1, doc2]进行了索引操作,而在下次定时任务执行时,我们又对[doc1, doc3]进行了索引。换言之,我们从源文档中删除了一部分内容,并添加了一些新的内容。这才是我们真正面临的场景:我们希望保持doc1不变,新增doc3,并能够自动删除doc2。这种需求可以通过Incremental增量模式得到满足。

话不多说,我们来看看三种模式的使用效果吧。

None

None模式的功能可以理解为去重和添加,而不包括删除。例如,如果你首次调用index([doc1, doc2]),然后再次调用index([doc1, doc3]),那么在向量库中的数据就会是[doc1, doc2, doc3]。需要注意的是,这种模式下,旧版本的doc2并不会被删除。

from langchain.embeddings import OpenAIEmbeddings
from langchain.schema import Document
from langchain.vectorstores.elasticsearch import ElasticsearchStore
from langchain.indexes import SQLRecordManager, index


collection_name = "test_index"

embedding = OpenAIEmbeddings()

vectorstore = ElasticsearchStore(
    es_url="http://localhost:9200",
    index_name="test_index",
    embedding=embedding)

namespace = f"elasticsearch/{collection_name}"
record_manager = SQLRecordManager(
    namespace, db_url="sqlite:///record_manager_cache.sql"
)
# record_manager.create_schema()

doc1 = Document(page_content="kitty", metadata={"source": "kitty.txt"})
doc2 = Document(page_content="doggy", metadata={"source": "doggy.txt"})
doc3 = Document(page_content="doggy1", metadata={"source": "doggy.txt"})


def _clear():
    """Hacky helper method to clear content. See the `full` mode section to to understand why it works."""
    index(
        [],
        record_manager,
        vectorstore,
        cleanup="full",
        source_id_key="source")

_clear()

res = index(
    [doc1, doc1, doc2],
    record_manager,
    vectorstore,
    cleanup=None,
    source_id_key="source",
)
print(res)

得到的结果:

{'num_added': 2, 'num_updated': 0, 'num_skipped': 0, 'num_deleted': 0}

我们发现做了去重并且帮我们增加了两条数据。

然后我们再执行index操作:

res = index(
    [doc1, doc3],
    record_manager,
    vectorstore,
    cleanup=None,
    source_id_key="source",
)
print(res)

执行结果发现添加了doc3, 跳过了doc1, doc2 还在数据库记录里:

{'num_added': 1, 'num_updated': 0, 'num_skipped': 1, 'num_deleted': 0}

线上RAG应用pdf文档频繁更新,老板下了死命令要节省预算,不能重复做embedding,我这么做.....-AI.x社区


full

full 含义是用户应该将所有需要进行索引的全部内容传递给index函数,任何没有传递到索引函数并且存在于vectorstore中的文档将被删除! 此行为对于处理源文档的删除非常有用。我们还是使用上面的代码,这次只是把模式换成 full. 首先,我们需要重置并清空数据,这可以通过调用​​_clear()​​函数实现。

res = index(
    [doc1, doc1, doc2],
    record_manager,
    vectorstore,
    cleanup="full",
    source_id_key="source",
)
print(res)

我们发现添加了2个文档:

{'num_added': 2, 'num_updated': 0, 'num_skipped': 0, 'num_deleted': 0}

接着我们执行:

res = index(
    [doc1, doc3],
    record_manager,
    vectorstore,
    cleanup="full",
    source_id_key="source",
)
print(res)

我们发现添加了一个文档doc3,跳过了一个文档doc1,删除了一个文档doc2:

线上RAG应用pdf文档频繁更新,老板下了死命令要节省预算,不能重复做embedding,我这么做.....-AI.x社区

{'num_added': 1, 'num_updated': 0, 'num_skipped': 1, 'num_deleted': 1}

incremental

"增量模式"是我们最常用的一种。顾名思义,这种模式主要进行增量操作,即添加最新记录并删除旧版记录。在这种模式下,如果我们传入一个空的文档数组,即index([]),将不会发生任何操作。然而,如果我们在"全量模式"下传入同样的空数组,系统则会清除所有数据。

首先,执行以下操作:

_clear()

res = index(
    [doc1, doc1, doc2],
    record_manager,
    vectorstore,
    cleanup="incremental",
    source_id_key="source",
)
print(res)

res = index(
    [doc1, doc3],
    record_manager,
    vectorstore,
    cleanup="incremental",
    source_id_key="source",
)
print(res)

得到的结果如下:

{'num_added': 2, 'num_updated': 0, 'num_skipped': 0, 'num_deleted': 0}
{'num_added': 1, 'num_updated': 0, 'num_skipped': 1, 'num_deleted': 1}

可以看出,第一次操作添加了两个文档。在第二次操作中,系统跳过了doc1,并删除了之前属于"doggy.txt"的doc2,因为现在我们只传入了doc3。因此,增量模式会将这个旧版本(doc2)删除。

然后执行以下操作:

res = index(
    [doc1],
    record_manager,
    vectorstore,
    cleanup="incremental",
    source_id_key="source",
)
print(res)

这次对于"doggy.txt"没有任何新的文档被传入,所以数据没有任何改动,结果如下:

{'num_added': 0, 'num_updated': 0, 'num_skipped': 1, 'num_deleted': 0}

但是,如果我们只传入doc2,则会发现系统增加了doc2,并删除了同一源文件("doggy.txt")的doc3。结果如下:

{'num_added': 1, 'num_updated': 0, 'num_skipped':

源码

def index(
    docs_source: Union[BaseLoader, Iterable[Document]],
    record_manager: RecordManager,
    vector_store: VectorStore,
    *,
    batch_size: int = 100,
    cleanup: Literal["incremental", "full", None] = None,
    source_id_key: Union[str, Callable[[Document], str], None] = None,
    cleanup_batch_size: int = 1_000,
) -> IndexingResult:
    ...

    if isinstance(docs_source, BaseLoader):
        try:
            doc_iterator = docs_source.lazy_load()
        except NotImplementedError:
            doc_iterator = iter(docs_source.load())
    else:
        doc_iterator = iter(docs_source)

    source_id_assigner = _get_source_id_assigner(source_id_key)

    # Mark when the update started.
    index_start_dt = record_manager.get_time()
    num_added = 0
    num_skipped = 0
    num_updated = 0
    num_deleted = 0
    
    
    for doc_batch in _batch(batch_size, doc_iterator):
        hashed_docs = list(
            _deduplicate_in_order(
                [_HashedDocument.from_document(doc) for doc in doc_batch]
            )
        )

        source_ids: Sequence[Optional[str]] = [
            source_id_assigner(doc) for doc in hashed_docs
        ]

        ....

        exists_batch = record_manager.exists([doc.uid for doc in hashed_docs])

        # Filter out documents that already exist in the record store.
        uids = []
        docs_to_index = []
        
        # 判断哪些是要更新,哪些是要添加的
        for hashed_doc, doc_exists in zip(hashed_docs, exists_batch):
            if doc_exists:
                # Must be updated to refresh timestamp.
                record_manager.update([hashed_doc.uid], time_at_least=index_start_dt)
                num_skipped += 1
                continue
            uids.append(hashed_doc.uid)
            docs_to_index.append(hashed_doc.to_document())

      
        # 知识入向量库
        if docs_to_index:
            vector_store.add_documents(docs_to_index, ids=uids)
            num_added += len(docs_to_index)

        # 更新数据库记录时间
        record_manager.update(
            [doc.uid for doc in hashed_docs],
            group_ids=source_ids,
            time_at_least=index_start_dt,
        )

        # 根据时间和source_ids 清理旧版本数据
        if cleanup == "incremental":
            ...

            uids_to_delete = record_manager.list_keys(
                group_ids=_source_ids, before=index_start_dt
            )
            if uids_to_delete:
                vector_store.delete(uids_to_delete)
                record_manager.delete_keys(uids_to_delete)
                num_deleted += len(uids_to_delete)

    if cleanup == "full":
        while uids_to_delete := record_manager.list_keys(
            before=index_start_dt, limit=cleanup_batch_size
        ):
            # First delete from record store.
            vector_store.delete(uids_to_delete)
            # Then delete from record manager.
            record_manager.delete_keys(uids_to_delete)
            num_deleted += len(uids_to_delete)

    return {
        "num_added": num_added,
        "num_updated": num_updated,
        "num_skipped": num_skipped,
        "num_deleted": num_deleted,
    }

通过上述代码,我们可以了解到一个常见的优化策略:对于涉及大量数据操作的数据库和向量库,我们通常使用批处理(batch)方式进行操作。上面代码的流程图如下:

线上RAG应用pdf文档频繁更新,老板下了死命令要节省预算,不能重复做embedding,我这么做.....-AI.x社区

本文转载自公众号AI 博物院 作者:longyunfeigu

原文链接:​​https://mp.weixin.qq.com/s/BFgtECvWMnUSDxH-JswbyQ​

©著作权归作者所有,如需转载,请注明出处,否则将追究法律责任
收藏
回复
举报
回复
相关推荐