我们最近在一个项目中遇到了一个问题。项目的场景是这样的:用户将他们的PDF文档存储在磁盘的某个特定目录中,然后有一个定时任务来扫描此目录并从中的PDF文档构建知识库。
一开始,我们采用"增量更新"策略。在扫描目录中的文档时,我们会对每个文档进行哈希运算以生成其指纹,并检查该指纹是否已存在于数据库中。如果指纹不存在,就表示这是一个新文件,我们会对新文件的document做embedding,然后将其加入到知识库中。
然而,这种方法存在一个问题。如果同一文件进行了增量添加,例如我们已经将A.pdf文件加入到了知识库,但后来这个文件添加了新的内容。当我们重新计算其指纹并在数据库中查找时,由于指纹不存在,我们会将这个更新过的文件作为新文件处理,并重新做embedding加入到知识库。这样一来,对于未更新的部分,知识库会有两份相同的数据记录,第二份相同的记录可能会"占据"原本应该被召回的数据记录的位置,从而降低问答效果。
那么应该怎么解决这个问题呢?对于增量更新,做hash指纹这一点毋庸置疑,但是hash的对象不能是文件了,而应该聚焦于真实存到知识库的数据: document.
在这里,我们将查看使用LangChain index API的基本索引工作流。
index API允许您将来自任何源的文档加载到矢量存储中并保持同步。具体来说,它有助于:
所有这些都可以节省你的时间和金钱,并改善你的矢量搜索结果。
如何工作 LangChain索引使用记录管理器(RecordManager)来跟踪写入矢量存储的文档。
当索引内容时,为每个文档计算哈希值,并将以下信息存储在记录管理器中:
源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}
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, cleanup iteral["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)方式进行操作。上面代码的流程图如下: