之前的文章中, 我们介绍了RAG中常见的文档分块策略。 这些分块策略对于文档中的任何信息都只有一个与之对应的向量(文档重叠的部分除外)。 在实际应用场景中, 我们可以通过为每一个文档块关联多个向量来进一步提升检索的效果。

创建多向量有多种方式, 包括:

  • 为每一个文档块创建子分块, 基于子分块和原始文档块进行匹配, 返回原始文档块。
  • 为每一个文档块创建摘要(Summary), 基于摘要和原始文档块进行匹配, 返回原始文档块。
  • 为每一个文档块创建假设查询(Hypothetical Questions), 基于假设查询和原始文档块进行匹配, 返回原始文档块。

本文通过代码示例来说明如何在 LangChain中使用多向量检索。

准备工作

为了在使用多向量查询时, 能够返回原始文档的原文, 需要将向量索引与原始文档块分开存储, 向量索引存储称为 vector sctore, 而原始文档块存储则称为 doc store。

以下代码基于 Chroma 创建了向量存储, 同时通过 InMemoryByteStore 创建了内存中的原始文档块存储。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
from langchain.storage import InMemoryByteStore
from langchain_chroma import Chroma
from langchain_community.document_loaders import TextLoader
from langchain_openai import OpenAIEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter

loaders = [
TextLoader("paul_graham_essay.txt"),
TextLoader("state_of_the_union.txt"),
]
docs = []
for loader in loaders:
docs.extend(loader.load())
text_splitter = RecursiveCharacterTextSplitter(chunk_size=10000)
docs = text_splitter.split_documents(docs)

# The vectorstore to use to index the child chunks
vectorstore = Chroma(
collection_name="full_documents", embedding_function=OpenAIEmbeddings()
)

import uuid
from langchain.retrievers.multi_vector import MultiVectorRetriever

store = InMemoryByteStore()
id_key = "doc_id"

# The retriever (empty to start)
retriever = MultiVectorRetriever(
vectorstore=vectorstore,
byte_store=store,
id_key=id_key,
)

doc_ids = [str(uuid.uuid4()) for _ in docs]

对于一个文档(这里是指初始分块之后的文档), 有多种创建多向量的方式。

子分块向量

在初始文档块的基础上, 将每个文档块划分为多个子分块, 同时将子分块与原始分块进行关联, 然后加入向量和文档索引。

1
2
3
4
5
6
7
8
9
10
11
12
# The splitter to use to create smaller chunks
child_text_splitter = RecursiveCharacterTextSplitter(chunk_size=400)

sub_docs = []
for i, doc in enumerate(docs):
_id = doc_ids[i]
_sub_docs = child_text_splitter.split_documents([doc])
for _doc in _sub_docs:
_doc.metadata[id_key] = _id
sub_docs.extend(_sub_docs)
retriever.vectorstore.add_documents(sub_docs)
retriever.docstore.mset(list(zip(doc_ids, docs)))

如果通过向量索引来检索, 返回的是子文档块:

1
retriever.vectorstore.similarity_search("justice breyer")[0]

输出:

1
Document(page_content='Tonight, I’d like to honor someone who has dedicated his life to serve this country: Justice Stephen Breyer—an Army veteran, Constitutional scholar, and retiring Justice of the United States Supreme Court. Justice Breyer, thank you for your service. \n\nOne of the most serious constitutional responsibilities a President has is nominating someone to serve on the United States Supreme Court.', metadata={'doc_id': '064eca46-a4c4-4789-8e3b-583f9597e54f', 'source': 'state_of_the_union.txt'})

如果通过多向量 retriever 来检索, 同样是通过向量索引来匹配, 但返回的是原始文档块:

1
len(retriever.invoke("justice breyer")[0].page_content)

输出:

1
9875

在LangChain中, 向量检索时默认使用的排序指标是余弦相似度, 也可以使用其他指标,如结合了相似性与多样性的MMR (Maximal Marginal Relevance):

1
2
3
4
from langchain.retrievers.multi_vector import SearchType

retriever.search_type = SearchType.mmr
len(retriever.invoke("justice breyer")[0].page_content)

输出:

1
9875

文档摘要

摘要(Summary) 是文档块核心语义信息的浓缩。通过LLM对文档块进行总结, 有助于编码模型更准确地表征语义。

以下代码展示了如何通过LLM对文档块进行总结, 得到文档块的摘要:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import getpass
import os
from langchain_openai import ChatOpenAI
import uuid
from langchain_core.documents import Document
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate

if not os.environ.get("OPENAI_API_KEY"):
os.environ["OPENAI_API_KEY"] = getpass.getpass("Enter API key for OpenAI: ")

llm = ChatOpenAI(model="gpt-4o-mini")

chain = (
{"doc": lambda x: x.page_content}
| ChatPromptTemplate.from_template("Summarize the following document:\n\n{doc}")
| llm
| StrOutputParser()
)
summaries = chain.batch(docs, {"max_concurrency": 5})

对摘要进行向量编码和索引:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

vectorstore = Chroma(collection_name="summaries", embedding_function=OpenAIEmbeddings())

store = InMemoryByteStore()
id_key = "doc_id"
# The retriever (empty to start)
retriever = MultiVectorRetriever(
vectorstore=vectorstore,
byte_store=store,
id_key=id_key,
)
doc_ids = [str(uuid.uuid4()) for _ in docs]

summary_docs = [
Document(page_content=s, metadata={id_key: doc_ids[i]})
for i, s in enumerate(summaries)
]

retriever.vectorstore.add_documents(summary_docs)
retriever.docstore.mset(list(zip(doc_ids, docs)))

假设查询

与HyDE(Hypothetical Document, 见论文 Precise Zero-Shot Dense Retrieval without Relevance Labels)通过构造假设文档的思路相对, 这里通过文档来构造假设查询。 检索时, 在查询向量与假设查询向量之间进行匹配, 返回原文档块。

通过LLM构造假设查询:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from typing import List
from pydantic import BaseModel, Field

class HypotheticalQuestions(BaseModel):
"""Generate hypothetical questions."""
questions: List[str] = Field(..., description="List of questions")


chain = (
{"doc": lambda x: x.page_content}
# Only asking for 3 hypothetical questions, but this could be adjusted
| ChatPromptTemplate.from_template(
"Generate a list of exactly 3 hypothetical questions that the below document could be used to answer:\n\n{doc}"
)
| ChatOpenAI(max_retries=0, model="gpt-4o").with_structured_output(
HypotheticalQuestions
)
| (lambda x: x.questions)
)
hypothetical_questions = chain.batch(docs, {"max_concurrency": 5})

对假设查询进行向量编码和索引:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# The vectorstore to use to index the child chunks
vectorstore = Chroma(
collection_name="hypo-questions", embedding_function=OpenAIEmbeddings()
)
# The storage layer for the parent documents
store = InMemoryByteStore()
id_key = "doc_id"
# The retriever (empty to start)
retriever = MultiVectorRetriever(
vectorstore=vectorstore,
byte_store=store,
id_key=id_key,
)
doc_ids = [str(uuid.uuid4()) for _ in docs]

question_docs = []
for i, question_list in enumerate(hypothetical_questions):
question_docs.extend(
[Document(page_content=s, metadata={id_key: doc_ids[i]}) for s in question_list]
)

retriever.vectorstore.add_documents(question_docs)
retriever.docstore.mset(list(zip(doc_ids, docs)))

多向量在多模态RAG中的应用

上面介绍了多向量在文本模态上创建多向量的三种方法。 多向量只是一种将检索表征与合成表征解耦的索引范式, 对于多模态内容来说, 这也同样适用。

在文章 Multi-Vector Retriever for RAG on tables, text, and images中, LangChain 团队介绍了将多向量应用于多模态内容的几种方式:

  • 使用多模态向量模型(如CLIP)来同时对图像和文本进行向量化, 通过多模态向量来检索, 但返回原始图像。 。

  • 利用多模态LLM(如GPT4-V、LLaVA或FUYU-8b)来为图像生成文本摘要并Embedding。基于查询和摘要的相似度来检索,同时返回摘要给LLM。

  • 利用多模态LLM(如GPT4-V、LLaVA或FUYU-8b)来为图像生成文本摘要并Embedding, 基于查询和摘要的相似度来检索, 但返回原始图片。

具体使用哪种方式取决于具体的应用场景, 以及对成本、性能、检索质量的综合考量。

参考