从零学RAG0x08:AdvancedRAG摘要索引 & 父子索引优化

11 阅读6分钟

前言

前面讲了什么是 AdvancedRAG,以及简单讲了其较传统 RAG 的进步性。今天开始从代码层实战开始看看 AdvancedRAG 究竟依靠哪些技术实现了其先进性。目前主要看 AdvancedRAG 预检索的索引优化。

  • 摘要索引
  • 父子索引

summary_MultiVectorRetriever

image.png

  • MultiVectorRetriever: 是LangChain在0.x时代提供的一种高级检索器。它的核心思想是 “检索与生成的解耦” ​ 和 “一文档多向量”
  • 应用场景:对于包含表格、图表、混合格式的半结构化文档,直接嵌入效果差。MultiVectorRetriever通过提取摘要或重述,能将非文本信息转化为可检索的语义,是处理财报、论文等文档的利器。
  • 核心流程:
    1. 让LLM为每个块生成summary,并作为embedding存到summary database中
    2. 在检索时,通过summary database找到最相关的summary,再回溯到原始文档中去
    3. 将原始文本块作为上下文发送给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

image.png

ParentDocumentRetriever​ 是LangChain中用于解决长文档检索困境的一种经典检索器。

  • 核心逻辑: “分层存储,回溯召回”
  • 核心流程:
  1. 索引过程:
  • 1.1 使用一个父分割器将原始文档切割成较大的块(例如2000字符),用于保留完整语义。
  • 1.2 使用一个子分割器将每个父文档切割成更小的块(例如400字符),用于向量语义匹配。
  • 1.3 将子文档的向量存入向量数据库,同时将父文档的原始内容存入一个键值存储(Docstore,如InMemoryStore或MongoDB)。两者通过一个唯一的doc_id建立关联。
  1. 检索过程:
  • 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_MultiVectorRetrieverParentDocumentRetriever代码时发现,这两个函数都是来自模块langchain_classic,而不是langchain。我们知道:

  • langchain:Langchain 1.0+ 主包。
  • langchain_classic:Langchain 1.0架构升级之后的 “兼容包”。它包含了 0.x 版本中那些经典的、非智能体核心的功能,比如 ChainsRetrieversIndexing 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)])
        

今天就到这,剩下的假设性答案索引元数据索引,我们下期继续。

源码

github