深入实践:用 LangGraph 实现 Small-to-Big 分块检索策略
引言
在构建企业级 RAG(检索增强生成)系统时,分块策略(Chunking Strategy)是决定检索质量与生成效果的关键一环。传统单粒度分块往往需要在"精确检索"与"上下文完整"之间做出取舍:小块检索精准但上下文孤立,大块信息丰富但噪声多、召回难。为了突破这一困境,Small-to-Big(父文档检索) 策略应运而生,它通过两级分块与递归检索,实现了"鱼与熊掌兼得"的效果。
本文将深入剖析 Small-to-Big 的原理与优势,并基于 LangGraph------一个专为构建状态化、多智能体应用设计的框架------展示如何从零到一落地这一策略。
什么是 Small-to-Big 检索?
Small-to-Big 是一种分层检索策略,它将文档切分为两种粒度的块:
- 子块(Child Chunks):尺寸较小(如 100~200 token),用于向量检索,确保高精度。
- 父块(Parent Chunks):尺寸较大(如 500~1000 token 或整个段落),用于生成上下文,提供完整背景。
工作流程:
- 用户查询向量化,在子块向量库中检索 Top-k 个最相似的子块。
- 根据子块记录的父块 ID,从文档存储中取出对应的完整父块。
- 将这些父块作为上下文输入给大语言模型生成答案。
这种"先检索小块,再获取大块"的机制,既保证了召回精度,又保留了上下文的完整性。
为什么需要 Small-to-Big?
传统单粒度分块的痛点:
- 小块:检索准确率高,但上下文缺失,LLM 难以理解逻辑关联,容易产生幻觉。
- 大块:上下文丰富,但噪声多,检索时可能因向量平均化而漏掉关键细节,尤其对于事实性问答效果不佳。
Small-to-Big 的优势:
- 检索精度提升:子块与查询语义更对齐,能精准命中事实片段。
- 上下文连贯:父块包含完整背景,支持多跳推理与复杂问题。
- 灵活性:可动态调整父子块大小,适配不同文档类型(如法律合同、技术手册)。
适用场景
- 事实性问答:如"公司章程第 3.2 条对董事任期有何规定?"
- 多跳问题:如"哪个部门的预算超过了去年同期的 10%?"(需要跨段落关联)
- 长文档精细检索:如科研论文、法律文书、技术规格书。
- 需引证原文的场景:父块能提供完整段落,便于用户核实。
项目落地:使用 LangGraph 实现 Small-to-Big
为什么选择 LangGraph?
LangGraph 是 LangChain 团队推出的框架,专为构建状态化、多参与者 的应用程序设计。它允许开发者将流程建模为图(Graph),节点是处理步骤,边是状态流转。相比 LangChain 的链式(Chain)结构,LangGraph 更灵活,支持循环、分支和复杂状态管理,非常适合实现 Small-to-Big 这类包含多步检索、条件判断的流程。
系统设计
我们设计一个包含以下节点的图:
- 分块与索引节点(离线):将原始文档分割为父子块,并存储到向量库与文档库。
- 查询节点:接收用户输入。
- 子块检索节点:查询向量化,检索 Top-k 子块。
- 父块获取节点:根据子块的父块 ID,从文档库获取去重后的父块。
- 重排序节点(可选):对父块进行相关性重排序,提升质量。
- 生成节点:将父块与查询组装成 Prompt,调用 LLM 生成答案。
在线流程如下:
用户查询
子块检索
获取父块
重排序
LLM 生成
最终答案
核心代码实现
以下代码基于 LangGraph 0.2+ 版本,展示关键节点实现。
1. 定义状态
python
from typing import List, Dict, Any
from langgraph.graph import StateGraph, END
from typing_extensions import TypedDict
class RetrievalState(TypedDict):
question: str
child_chunks: List[Dict] # 检索到的子块
parent_chunks: List[str] # 获取到的父块内容
ranked_parents: List[str] # 重排序后的父块
answer: str
2. 初始化存储与模型
假设我们已经使用某个向量库(如 Chroma)存储子块向量,并使用一个简单的内存字典存储父块(生产环境可用 Redis 或 PostgreSQL)。
python
from langchain.vectorstores import Chroma
from langchain.embeddings import OpenAIEmbeddings
from langchain.storage import InMemoryStore
vectorstore = Chroma(collection_name="child_chunks", embedding_function=OpenAIEmbeddings())
docstore = InMemoryStore() # key: parent_id, value: parent_text
3. 节点函数实现
子块检索节点:
python
def retrieve_child_chunks(state: RetrievalState) -> RetrievalState:
question = state["question"]
# 向量检索,返回 top-5 子块
docs = vectorstore.similarity_search_with_score(question, k=5)
child_chunks = [{"id": doc.metadata["child_id"],
"parent_id": doc.metadata["parent_id"],
"text": doc.page_content,
"score": score} for doc, score in docs]
return {"child_chunks": child_chunks}
父块获取节点:
python
def fetch_parent_chunks(state: RetrievalState) -> RetrievalState:
child_chunks = state["child_chunks"]
parent_ids = set(c["parent_id"] for c in child_chunks)
parent_texts = [docstore.get(pid) for pid in parent_ids if docstore.get(pid)]
# 去重,保留顺序
return {"parent_chunks": parent_texts}
重排序节点(可选,使用 Cross-Encoder):
python
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
def rerank_parents(state: RetrievalState) -> RetrievalState:
question = state["question"]
parent_texts = state["parent_chunks"]
pairs = [(question, text) for text in parent_texts]
scores = reranker.predict(pairs)
ranked = [text for _, text in sorted(zip(scores, parent_texts), reverse=True)]
return {"ranked_parents": ranked[:3]} # 取 top-3
生成节点:
python
from langchain.chat_models import ChatOpenAI
from langchain.schema import HumanMessage
llm = ChatOpenAI(model="gpt-4")
def generate_answer(state: RetrievalState) -> RetrievalState:
context = "\n\n".join(state.get("ranked_parents", state.get("parent_chunks")))
prompt = f"""基于以下上下文,回答用户问题。如果上下文不相关,请说明无法回答。
上下文:
{context}
问题:{state['question']}
答案:"""
response = llm.invoke([HumanMessage(content=prompt)])
return {"answer": response.content}
4. 构建 LangGraph
python
from langgraph.graph import StateGraph, END
# 初始化图
graph = StateGraph(RetrievalState)
# 添加节点
graph.add_node("retrieve_child", retrieve_child_chunks)
graph.add_node("fetch_parent", fetch_parent_chunks)
graph.add_node("rerank", rerank_parents)
graph.add_node("generate", generate_answer)
# 设置边
graph.set_entry_point("retrieve_child")
graph.add_edge("retrieve_child", "fetch_parent")
graph.add_edge("fetch_parent", "rerank")
graph.add_edge("rerank", "generate")
graph.add_edge("generate", END)
# 编译图
app = graph.compile()
5. 执行查询
python
result = app.invoke({"question": "2024年公司营收增长的主要原因是什么?"})
print(result["answer"])
存储实现细节
在实际项目中,向量存储 (子块)与文档存储(父块)的选择至关重要:
- 向量存储 :推荐使用生产级数据库如 Milvus、Qdrant 或 Elasticsearch(支持向量)。需存储子块向量及元数据(
parent_id、child_id)。 - 文档存储 :可用 Redis(高性能键值)、PostgreSQL(持久化)或 MongoDB。通过
parent_id快速检索父块原文。
分块与索引的离线流程可以使用 LangChain 的 ParentDocumentRetriever 简化,但为了与 LangGraph 集成,我们往往需要手动实现索引逻辑,以便控制元数据字段。
增强模块:混合搜索与重排序
为了进一步提升检索质量,可以在 LangGraph 中增加节点:
- 混合搜索节点:同时执行向量检索和关键词检索(如 BM25),用 RRF 算法融合结果。
- 重排序节点:如上文所示,用 Cross-Encoder 对父块重排序,能显著提升最终生成质量。
这些节点可以灵活插入图中,实现定制化流程。
优化与挑战
- 分块大小调优:父子块的大小需通过实验确定。通常子块 200~400 token,父块 800~2000 token。可借助验证集(如 SQuAD-like 数据)评估召回率。
- 存储开销:子块数量多,向量库需支持高并发写入与检索。需估算数据规模并选择合适的数据库。
- 延迟 :检索 + 重排序会增加响应时间。可考虑将重排序作为可选步骤,或使用更轻量的模型(如
Qwen3-Reranker-0.6B)。 - 去重与冗余:多个子块可能映射到同一父块,务必在获取父块时去重,避免重复输入。
- 元数据传递:LangGraph 的状态管理需确保每个节点只访问必要的字段,避免状态膨胀。
总结
Small-to-Big 分块策略通过两级粒度与递归检索,有效平衡了 RAG 系统的检索精度与上下文完整性。借助 LangGraph,我们可以将这一流程建模为清晰的状态图,灵活地加入重排序、混合搜索等增强模块,构建一个可扩展、易维护的企业级检索系统。
延伸资源:
- LangGraph 官方文档:https://langchain-ai.github.io/langgraph/
- LangChain ParentDocumentRetriever:https://python.langchain.com/docs/modules/data_connection/retrievers/parent_document_retriever
- LlamaIndex HierarchicalNodeParser:https://docs.llamaindex.ai/en/stable/module_guides/loading/node_parsers/modules.html#hierarchicalnodeparser