链载Ai

标题: 线上RAG应用pdf文档频繁更新,老板下了死命令要节省预算,不能重复做embedding,我这么做..... [打印本页]

作者: 链载Ai    时间: 昨天 11:04
标题: 线上RAG应用pdf文档频繁更新,老板下了死命令要节省预算,不能重复做embedding,我这么做.....

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

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

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

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

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

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

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

如何工作

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

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

删除模式

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

Cleanup ModeDe-Duplicates ContentParallelizableCleans Up Deleted Source DocsCleans Up Mutations of Source Docs and/or Derived DocsClean Up Timing
None-
IncrementalContinuously
FullAt 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}

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:

{'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':

源码

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

ifisinstance(docs_source,BaseLoader):
try:
doc_iterator=docs_source.lazy_load()
exceptNotImplementedError:
doc_iterator=iter(docs_source.load())
else:
doc_iterator=iter(docs_source)

source_id_assigner=_get_source_id_assigner(source_id_key)

#Markwhentheupdatestarted.
index_start_dt=record_manager.get_time()
num_added=0
num_skipped=0
num_updated=0
num_deleted=0


fordoc_batchin_batch(batch_size,doc_iterator):
hashed_docs=list(
_deduplicate_in_order(
[_HashedDocument.from_document(doc)fordocindoc_batch]
)
)

source_ids:Sequence[Optional[str]]=[
source_id_assigner(doc)fordocinhashed_docs
]

....

exists_batch=record_manager.exists([doc.uidfordocinhashed_docs])

#Filteroutdocumentsthatalreadyexistintherecordstore.
uids=[]
docs_to_index=[]

#判断哪些是要更新,哪些是要添加的
forhashed_doc,doc_existsinzip(hashed_docs,exists_batch):
ifdoc_exists:
#Mustbeupdatedtorefreshtimestamp.
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())


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

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

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

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

ifcleanup=="full":
whileuids_to_delete:=record_manager.list_keys(
before=index_start_dt,limit=cleanup_batch_size
):
#Firstdeletefromrecordstore.
vector_store.delete(uids_to_delete)
#Thendeletefromrecordmanager.
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)方式进行操作。上面代码的流程图如下:







欢迎光临 链载Ai (https://www.lianzai.com/) Powered by Discuz! X3.5