前言
前面讲了什么是 AdvancedRAG,以及简单讲了其较传统 RAG 的进步性。今天开始从代码层实战开始看看 AdvancedRAG 究竟依靠哪些技术实现了其先进性。目前主要看 AdvancedRAG 预检索的索引优化。
- 摘要索引
- 父子索引
summary_MultiVectorRetriever
- MultiVectorRetriever: 是LangChain在0.x时代提供的一种高级检索器。它的核心思想是 “检索与生成的解耦” 和 “一文档多向量” 。
- 应用场景:对于包含表格、图表、混合格式的半结构化文档,直接嵌入效果差。MultiVectorRetriever通过提取摘要或重述,能将非文本信息转化为可检索的语义,是处理财报、论文等文档的利器。
- 核心流程:
- 让LLM为每个块生成summary,并作为embedding存到summary database中
- 在检索时,通过summary database找到最相关的summary,再回溯到原始文档中去
- 将原始文本块作为上下文发送给LLM以获取答案
准备工作
#获得访问大模型和嵌入模型客户端
client,embeddings_model = get_ali_clients()
# 初始化文档加载器
loader = TextLoader("../data/deepseek百度百科.txt", encoding="utf-8")
# 加载文档
docs = loader.load()
# 初始化递归文本分割器(设置块大小和重叠)
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1024, chunk_overlap=100)
docs = text_splitter.split_documents(docs)
# 初始化Chroma实例(用于存储摘要向量)
vectorstore = Chroma(
collection_name="summaries",
embedding_function=embeddings_model
)
# 初始化内存字节存储(用于存储原始文档)
store = InMemoryByteStore()
创建摘要链
# 创建摘要生成链
chain = (
{"doc": lambda x: x.page_content}
| ChatPromptTemplate.from_template("总结下面的文档:\n\n{doc}")
| client
| StrOutputParser()
)
# 批量生成文档摘要(最大并发数5)
summaries = chain.batch(docs, {"max_concurrency": 5})
摘要-文档映射
doc_id:摘要和文档通过捆绑映射。
# 初始化多向量检索器(结合向量存储和文档存储)
id_key = "doc_id"
retriever = MultiVectorRetriever(
vectorstore=vectorstore,
byte_store=store,
id_key=id_key,
)
# 为每个文档生成唯一ID,该ID用于关联原始文档和摘要
doc_ids = [str(uuid.uuid4()) for _ in docs]
# 将文档摘要转换为LangChain中Document
summary_docs = [
Document(page_content=s, metadata={id_key: doc_ids[i]})
for i, s in enumerate(summaries)
]
# 将摘要添加到向量数据库
print("准备将摘要添加到向量数据库...")
retriever.vectorstore.add_documents(summary_docs)
# 将原始文档存储到字节存储(使用ID关联)
print("准备将原始文档存储到字节存储...")
# mset:批量设置键值对
# list(zip(doc_ids, docs)):将ID和文档配对
retriever.docstore.mset(list(zip(doc_ids, docs)))
测试代码
prompt = ChatPromptTemplate.from_template("根据下面的文档回答问题:\n\n{doc}\n\n问题: {question}")
# 生成问题回答链
#retriever.invoke将上面对摘要进行检索,但是通过关联ID获得原始文档,最终返回原始文档的过程全部都包含完成了
chain = RunnableMap({
"doc": lambda x: retriever.invoke(x["question"]),
"question": lambda x: x["question"]
}) | prompt | client | StrOutputParser()
# 生成问题回答
query = "deepseek的企业事件"
answer = chain.invoke({"question": query})
print("-------------回答--------------")
print(answer)
# 1.向量数据库中检索摘要向量 2.匹配对应的原始文档并返回
retrieved_docs = retriever.invoke(query)
print("-------------检索到的文档--------------")
print(retrieved_docs)
parent_child_ParentDocumentRetriever
ParentDocumentRetriever 是LangChain中用于解决长文档检索困境的一种经典检索器。
- 核心逻辑: “分层存储,回溯召回” 。
- 核心流程:
- 索引过程:
- 1.1 使用一个父分割器将原始文档切割成较大的块(例如2000字符),用于保留完整语义。
- 1.2 使用一个子分割器将每个父文档切割成更小的块(例如400字符),用于向量语义匹配。
- 1.3 将子文档的向量存入向量数据库,同时将父文档的原始内容存入一个键值存储(Docstore,如InMemoryStore或MongoDB)。两者通过一个唯一的
doc_id建立关联。
- 检索过程:
- 2.1 当用户查询到来时,搜索与查询最相似的子文档。
- 2.2 根据匹配到的子文档的
doc_id,去Docstore中查找并返回对应父文档,最终将这个更大的上下文块提供给LLM生成答案。
准备
#获得访问大模型和嵌入模型客户端
client,embeddings_model = get_ali_clients()
# 加载数据
loader = TextLoader("../data/deepseek百度百科.txt",encoding="utf-8")
docs = loader.load()
# 创建向量数据库对象
vectorstore = Chroma(
collection_name="split_parents", embedding_function = embeddings_model
)
# 创建内存存储对象
store = InMemoryStore()
父子索引
# 子块是父块内容的子集
#创建主文档分割器
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=1024)
#创建子文档分割器
child_splitter = RecursiveCharacterTextSplitter(chunk_size=256)
#创建父子文档检索器,帮我们通过检索子块,返回父文档块
# topK = 2,相似度最高的子文档块(A,B) A,B属于同一个父, 父文档块被查询两次,不会去重
retriever = ParentDocumentRetriever(
vectorstore=vectorstore,
docstore=store, # 文档存储对象
child_splitter=child_splitter, # 子文档分割器,子文档存储到向量数据库
parent_splitter=parent_splitter,# 主文档分割器,主文档存储到内存中
search_kwargs={"k": 1} # topK = 1,相似度最高的子文档块
)
#添加文档集
retriever.add_documents(docs)
测试代码
#创建prompt模板
template = """请根据下面给出的上下文来回答问题:
{context}
问题: {question}
"""
#由模板生成prompt
prompt = ChatPromptTemplate.from_template(template)
#创建chain
chain = RunnableMap({
"context": lambda x: retriever.invoke(x["question"]),
"question": lambda x: x["question"]
}) | prompt | client | StrOutputParser()
print("------------模型回复------------------------")
response = chain.invoke({"question": "deepseek最大的的挑战是什么"})
print(response)
思考
我们在写summary_MultiVectorRetriever和ParentDocumentRetriever代码时发现,这两个函数都是来自模块langchain_classic,而不是langchain。我们知道:
- langchain:Langchain 1.0+ 主包。
- langchain_classic:Langchain 1.0架构升级之后的 “兼容包”。它包含了 0.x 版本中那些经典的、非智能体核心的功能,比如
Chains、Retrievers、Indexing API等。
那为什么这两个关键函数被放到了兼容包,好像被边缘化了?
- LangChain新架构专注于构建 Agent。ParentDocumentRetriever属于 **RAG(检索增强生成)**领域的特定优化技术,它们并不是构建智能体的“必需品”。
- ParentDocumentRetriever 是一个“黑盒”类,它强制你使用特定的存储结构。而手动实现让你可以自由选择存储后端(如 Redis、MongoDB),并自定义关联逻辑。
那问题又来了,我们知道Langchain 1.0架构升级之后,官方也鼓励我们使用新范式和新架构。比如新建的Langchain项目必然是最好依赖langchain包。那么,在Langchain1.0架构和开发范式下,如果我们想使用索引前优化的摘要索引和父子索引该怎么调用?
下面就给出一个简单示例,只给出父子索引映射关系的核心代码。其他代码见源码:
# 处理文档并建立父子关联
def process_documents_for_parent_child_retrieval(docs: List[Document]) -> None:
"""核心处理函数:创建父子文档并存储"""
parent_docs = parent_splitter.split_documents(docs)
all_child_docs = []
parent_id_to_doc = {}
for parent_doc in parent_docs:
# 为每个父文档生成唯一ID
parent_id = str(uuid4())
parent_doc_with_id = Document(
page_content=parent_doc.page_content,
metadata={**parent_doc.metadata, "doc_id": parent_id, "type": "parent"}
)
parent_id_to_doc[parent_id] = parent_doc_with_id
# 为父文档生成子文档
children = child_splitter.split_documents([parent_doc])
for child in children:
child.metadata.update({
"parent_doc_id": parent_id,
"type": "child"
})
all_child_docs.append(child)
# 存储
if all_child_docs:
vector_store.add_documents(all_child_docs)
# 存储父文档
for pid, pdoc in parent_id_to_doc.items():
doc_store.mset([(pid, pdoc)])
今天就到这,剩下的假设性答案索引、元数据索引,我们下期继续。