LangChain父文档检索器优化:仅使用向量数据库增强上下文检索

优化LangChain父文档检索,仅使用向量数据库增强上下文,提升检索效率和动态性。

原文标题:独家 | LangChain的父文档检索器——再探仅使用向量数据库增强上下文检索

原文作者:数据派THU

冷月清谈:

本文介绍了一种优化LangChain父文档检索器的方法,仅使用向量数据库即可实现与LangChain父文档检索器相同的功能,并解决了LangChain方法的两个主要缺点:需要管理外部存储和“父文档”检索非动态的问题。

该方法的核心思想是在每个数据块的元数据中保存其所属文档ID和在文档中的序列号。检索时,先进行常规相似度搜索,找到最相似的块后,根据其元数据中的文档ID和序列号,检索同一文档中序列号相邻的块,构成更完整的上下文。

文中分别给出了使用ChromaDB和Milvus实现该方法的代码示例,并讨论了如何适配Pinecone等其他向量数据库。此外,还分析了该方法的局限性,例如向量数据库并非为常规数据库操作而设计,以及需要额外的查询操作。

怜星夜思:

1、文章提到的方法主要针对文本数据,如果是处理图像、音频等其他类型的数据,如何实现类似的“父文档”检索?
2、文章中提到向量数据库并非为常规数据库操作而设计,那么在实际应用中,如何选择合适的向量数据库和常规数据库的组合方案?
3、动态修改检索窗口大小在实际应用中有哪些优势?

原文内容

文:Omri Eliyahu Levy
翻译:付雯欣
校对:赵茹萱
本文约3200字,建议阅读8分钟
本文为你介绍LangChain的父文档检索器。


该工具名为Nightshade,它会扰乱训练数据,从而可能对图像生成人工智能模型造成严重损害。 

总结:我们通过利用元数据查询,实现了与LangChain的父文档检索器(链接:https://python.langchain.com/v0.1/docs/modules/data_connection/retrievers/parent_document_retriever/)相同的功能。


你可以在这里查看相关代码:

https://gist.github.com/omriel1/7243ce233eb2986ed2749de6ae79ecb7


RAG简介


检索增强生成(RAG) 是目前 LLM 和 AI 应用领域最热门的话题之一。

简而言之,RAG是一种将生成模型的响应建立在所选知识源上的技术。它包括两个阶段:检索和生成。

在检索阶段,根据用户的查询,我们从预定义的知识源中检索相关信息。

然后,将检索到的信息插入到发送给LLM 的提示中,LLM(理想情况下)会根据提供的上下文生成对用户问题的答案。

实现高效准确检索的常用方法是使用文本嵌入,即Embedding。在这种方法中,我们通过将文档拆分为块(例如页面、段落或句子)来对用户的数据进行预处理(为简单起见,假设其为纯文本)。然后,我们使用嵌入模型来为这些块创建有意义的数学表达(即低维向量),并将它们存储在向量数据库中。当查询(Query)出现时,我们也会对其进行嵌入,并使用向量数据库执行相似性搜索以检索相关信息。


图片由作者提供

如果你对这些概念完全是陌生的,我建议你看看Deeplearning.ai的一门很好的课程:https://www.deeplearning.ai/short-courses/langchain-chat-with-your-data/


什么是“父文档检索”?


“父文档检索”或其他人所称的“句子窗口检索”是一种常用方法,通过为LLM 提供更广泛的知识背景来提高 RAG 中检索方法的性能。

本质上,我们将原始文档分成相对较小的块,对每个块进行文本嵌入,并将它们存储在向量数据库中。使用这样的小块(一个句子或几个句子)有助于嵌入模型更好地反映其含义[1]。

然后,在检索时,我们不仅返回向量数据库找到的最相似的块,还返回原始文档中它的上下文(块)。这样,LLM将具有更广泛的知识背景,这在许多情况下有助于其生成更好的答案。

Langchain(https://towardsdatascience.com/tag/langchain/)通过父文档检索器 [2] 支持这一概念。父文档检索器允许您:(1) 检索特定块所源自的完整文档,或 (2) 为与该父级关联的每个较小块预定义一个较大的“父级”块。

让我们探索LangChains 文档中的示例:

https://python.langchain.com/v0.1/docs/modules/data_connection/retrievers/parent_document_retriever/


```
# This text splitter is used to create the parent documents
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=2000)
# This text splitter is used to create the child documents
# It should create documents smaller than the parent
child_splitter = RecursiveCharacterTextSplitter(chunk_size=400)
# The vectorstore to use to index the child chunks
vectorstore = Chroma(
collection_name="split_parents", embedding_function=OpenAIEmbeddings()
)
# The storage layer for the parent documents
store = InMemoryStore()
retriever = ParentDocumentRetriever(
vectorstore=vectorstore,
docstore=store,
child_splitter=child_splitter,
parent_splitter=parent_splitter,
)
retrieved_docs = retriever.invoke("justice breyer")
```

我认为LangChain方法有两个缺点:

1.需要管理外部存储以利用这一有用的方法,无论是在内存中还是其他持久化存储中。当然,对于实际应用场景,示例中使用的InMemoryStore是不够的。

2.“父文档”检索不是动态的,意味着我们无法动态改变周围窗口的大小。确实,关于这个问题已经提出了一些疑问[3]。

在这里,我还要提到,Llama-index也有自己的SentenceWindowNodeParser[4],它通常也面临相同的缺点。

接下来,我将介绍另一种实现这一有用功能的方法,它解决了上述提到的两个缺点。在这种方法中,我们只会使用已经在使用的向量存储。

替代方案


准确来说,我们将使用一个支持仅进行元数据查询选项的向量存储,不涉及任何相似度搜索。在这里,我将展示用ChromaDB和Milvus的实现方式。这个概念可以很容易地适配到任何具备此功能的向量数据库。在本教程的最后,我将以Pinecone为例进行参考。

一般概念


这个概念很简单:

1.构建:在每个数据块旁边,在其元数据中保存它所生成的文档ID(documentid)和该数据块的序列号(sequencenumber)。

2.检索:在执行常规的相似度搜索后(假设为了简单起见,仅返回前1个结果),我们从检索到的数据块的元数据中获取文档ID(documentid)和序列号(sequencenumber)。然后,检索所有具有相同文档ID(documentid)且其序列号相邻的数据块。

例如,假设你已经将一个名为example.pdf的文档索引成80个数据块。那么,针对某个查询,假设你发现最接近的向量是具有以下元数据的那一个:

```
{document_id: "example.pdf", sequence_number: 20}
```

你可以轻松地从同一文档中获取序列号从15到25的所有向量。

让我们看看代码。

在这里,我使用的是:

```
chromadb==0.4.24
langchain==0.2.8
pymilvus==2.4.4
langchain-community==0.2.7
langchain-milvus==0.1.2
```

下面唯一有趣的事情是与每个块相关的元数据,这将允许我们执行搜索。

```
from langchain_community.document_loaders import PyPDFLoader
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter
document_id = "example.pdf"
def preprocess_file(file_path: str) -> list[Document]:
"""Load pdf file, chunk and build appropriate metadata"""
loader = PyPDFLoader(file_path=file_path)
pdf_docs = loader.load()
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=0,
)
docs = text_splitter.split_documents(documents=pdf_docs)
chunks_metadata = [
{"document_id": file_path, "sequence_number": i} for i, _ in enumerate(docs)
]
for chunk, metadata in zip(docs, chunks_metadata):
chunk.metadata = metadata
return docs
```

现在,让我们在Milvus和Chroma中实现实际的检索。请注意,我将使用LangChain的对象,而不是原生客户端。我这么做是因为我假设开发人员可能希望保持LangChain的有用抽象。另一方面,这会要求我们进行一些小的修改,以便通过数据库特定的方式绕过这些抽象,因此你应该考虑到这一点。无论如何,概念是一样的。

同样,为了简单起见,我们假设只需要最相似的向量(“top 1”)。接下来,我们将提取关联的document_id及其序列号。这样,我们就可以检索到周围窗口内的文档。

```
from langchain_community.vectorstores import Milvus, Chroma
from langchain_community.embeddings import DeterministicFakeEmbedding
embedding= DeterministicFakeEmbedding(size=384) # Just for the demo :)
def parent_document_retrieval(
query: str, client: Milvus | Chroma, window_size: int = 4
):
top_1 = client.similarity_search(query=query, k=1)[0]
doc_id = top_1.metadata["document_id"]
seq_num = top_1.metadata["sequence_number"]
ids_window = [seq_num + i for i in range(-window_size, window_size, 1)]
# ...
```

现在,对于窗口/父文档检索,我们将深入LangChain的抽象层,以数据库特定的方式实现。

对于Milvus:

```
if isinstance(client, Milvus):
expr = f"document_id LIKE '{doc_id}' && sequence_number in {ids_window}"
res = client.col.query(
expr=expr, output_fields=["sequence_number", "text"], limit=len(ids_window)
)  # This is Milvus specific
docs_to_return = [
Document(
page_content=d["text"],
metadata={
"sequence_number": d["sequence_number"],
"document_id": doc_id,
},
)
for d in res
]
# ...
```

对于Chroma:

```
elif isinstance(client, Chroma):
expr = {
"$and": [
{"document_id": {"$eq": doc_id}},
{"sequence_number": {"$gte": ids_window[0]}},
{"sequence_number": {"$lte": ids_window[-1]}},
]
}
res = client.get(where=expr)  # This is Chroma specific
texts, metadatas = res["documents"], res["metadatas"]
docs_to_return = [
Document(
page_content=t,
metadata={
"sequence_number": m["sequence_number"],
"document_id": doc_id,
},
)
for t, m in zip(texts, metadatas)
]
```

别忘了按照序列号排序:

```
docs_to_return.sort(key=lambda x: x.metadata["sequence_number"])
return docs_to_return
```

为了方便起见,你可以在此处获取完整代码:
https://gist.github.com/omriel1/7243ce233eb2986ed2749de6ae79ecb7

Pinecone(及其他)


据我所知,Pinecone中没有执行此类元数据查询的原生方法,但您可以通过其 ID 原生获取向量(https://docs.pinecone.io/guides/data/fetch-data)。

因此,我们可以执行以下操作:对每个块给一个唯一的ID,它本质上是 document_id 和序列号的串联。然后,给定在相似性搜索中检索到的向量,你可以动态创建周围块的 ID 列表并获得相同的结果。

限制


值得一提的是,向量数据库并非为了执行“常规”数据库操作而设计,通常也没有针对这类操作进行优化,因此每个数据库的表现会有所不同。例如,Milvus支持在标量字段(“元数据”)上构建索引,从而可以优化这类查询。

另请注意,它需要对向量数据库进行额外查询。首先,我们检索最相似的向量,然后执行其他查询以获取原始文档中的周围块。

当然,从上面的代码示例可以看出,该实现是特定于向量数据库的,并且不受LangChains 抽象层的原生支持。

结论


在本博客中,我们介绍了一种实现句子窗口检索的方法,这是一种在许多RAG应用程序中使用的有用检索技术。在此实例中,我们仅使用了已经在使用的向量数据库,并且还支持动态修改检索到的周围窗口大小。

参考资料:(按原文提到的顺序)


[1] ARAGOG: Advanced RAG Output Grading, https://arxiv.org/pdf/2404.01037, section 4.2.2

[2]https://python.langchain.com/v0.1/docs/modules/data_connection/retrievers/parent_document_retriever/

[3]关于父文档无法动态的讨论:

https://github.com/langchain-ai/langchain/issues/14267

https://github.com/langchain-ai/langchain/issues/20315

https://stackoverflow.com/questions/77385587/persist-parentdocumentretriever-of-langchain

[4] https://docs.llamaindex.ai/en/stable/api_reference/node_parsers/sentence_window/


作者主页:

https://towardsdatascience.com/author/omri-levy/

原文链接:

https://towardsdatascience.com/langchains-parent-document-retriever-revisited-1fca8791f5a0/



编辑:王菁






译者简介





付雯欣,中国人民大学统计学专业硕士研究生在读,数据科学道路上的探索者一枚。小时候梦想做数学家,现在依旧着迷于数据背后的世界。热爱阅读,热爱遛弯儿,不停感受打开生命大门的瞬间。欢迎大家和我一起用概率的视角看世界~

翻译组招募信息

工作内容:需要一颗细致的心,将选取好的外文文章翻译成流畅的中文。如果你是数据科学/统计学/计算机类的留学生,或在海外从事相关工作,或对自己外语水平有信心的朋友欢迎加入翻译小组。

你能得到:定期的翻译培训提高志愿者的翻译水平,提高对于数据科学前沿的认知,海外的朋友可以和国内技术应用发展保持联系,THU数据派产学研的背景为志愿者带来好的发展机遇。

其他福利:来自于名企的数据科学工作者,北大清华以及海外等名校学生他们都将成为你在翻译小组的伙伴。


点击文末“阅读原文”加入数据派团队~



转载须知

如需转载,请在开篇显著位置注明作者和出处(转自:数据派ID:DatapiTHU),并在文章结尾放置数据派醒目二维码。有原创标识文章,请发送【文章名称-待授权公众号名称及ID】至联系邮箱,申请白名单授权并按要求编辑。

发布后请将链接反馈至联系邮箱(见下方)。未经许可的转载以及改编者,我们将依法追究其法律责任。





关于我们

数据派THU作为数据科学类公众号,背靠清华大学大数据研究中心,分享前沿数据科学与大数据技术创新研究动态、持续传播数据科学知识,努力建设数据人才聚集平台、打造中国大数据最强集团军。



新浪微博:@数据派THU

微信视频号:数据派THU

今日头条:数据派THU

点击“阅读原文”拥抱组织


对于图像和音频数据,可以考虑将它们转化为特征向量,然后使用类似的方法,在特征向量的元数据中存储所属“父文档”的信息,例如图像所在的相册ID、音频所属的歌曲ID等。检索时,先找到最相似的特征向量,再根据元数据找到其所属的“父文档”中的其他特征向量,从而获得更丰富的上下文信息。

动态窗口还可以用来做一些更高级的功能,比如根据查询的复杂度自动调整窗口大小,或者根据用户的偏好个性化设置窗口大小。

除了特征向量,也可以考虑用其他表示方法,比如图像的场景图、音频的乐谱等。关键是要找到一种能够有效表示数据内容,并且方便存储和检索的方式。

动态窗口大小可以根据不同的查询需求调整上下文范围,例如对于一些需要更详细背景信息的查询,可以扩大窗口大小;而对于一些简单的查询,则可以缩小窗口大小,从而提高检索效率。

我觉得最大的优势在于灵活性。可以根据用户的反馈实时调整窗口大小,从而提供更精准的检索结果,提升用户体验。

选择数据库组合方案需要考虑多方面因素,例如数据规模、查询性能要求、成本等等。可以根据实际情况选择合适的向量数据库和常规数据库,例如可以使用Milvus或Pinecone作为向量数据库,PostgreSQL或MySQL作为常规数据库,并将两者结合使用。

个人觉得可以根据具体应用场景来选择。如果检索速度是首要考虑的,那么可以选择性能更优的向量数据库,比如Faiss。如果需要进行复杂的关联查询,那么可以选择功能更强大的关系型数据库,比如PostgreSQL。

我之前做过一个项目,用的是Elasticsearch和Milvus的组合。Elasticsearch存储文本数据和元数据,Milvus存储向量。这种方案兼顾了检索效率和数据管理的灵活性,效果还不错。

我觉得可以借鉴视频处理的思路,把图像序列或音频片段看作一个“父文档”,然后把每一帧图像或一小段音频作为子块。这样就可以用类似文章的方法,通过元数据来关联子块和父文档,实现“父文档”检索。