从模型输出到知识库检索:LangChain 数据管道实战
大模型能够生成自然、连贯的文本,但真正把它接入业务系统时,仅仅"能回答问题"远远不够。程序需要稳定的数据结构,知识库需要统一的文档对象,检索系统还要把长文档切成大小合适、语义完整的片段。
这三件事分别对应 LangChain 中的三类基础组件:输出解析器、文档加载器和文本分割器。它们共同组成了一条重要的数据管道:
text
原始文件 -> Document -> 文档块 -> 检索上下文 -> 模型回答 -> 结构化结果
本文从工程视角拆解这条管道,重点说明每个组件解决什么问题、如何选择,以及怎样把它们组合成可维护的 RAG 应用。
为什么模型输出必须经过解析
大模型的原始输出通常是一段自然语言。人可以轻松读懂,但程序很难可靠处理。例如,业务接口可能期待如下数据:
json
{
"answer": "退款通常会在 3 个工作日内到账。",
"sources": ["售后服务说明"],
"confidence": 0.92
}
如果模型有时返回 JSON,有时添加解释文字,有时改变字段名,下游代码就会变得非常脆弱。输出解析器的价值,就是把模型的非结构化回答转换为字符串、字典、列表或 Pydantic 对象,让模型调用从"文本对话"变成可验证的数据接口。
只需要文本时使用 StrOutputParser
聊天模型通常返回 AIMessage,文本位于 content 字段中。StrOutputParser 可以直接取出文本内容,并且实现了 Runnable 接口,可以自然地接入 LCEL 管道。
python
from langchain_openai import ChatOpenAI
from langchain_core.output_parsers import StrOutputParser
model = ChatOpenAI(model="gpt-4o-mini", temperature=0)
chain = model | StrOutputParser()
result = chain.invoke("用一句话解释 RAG。")
print(result)
流式调用时,解析器也可以继续向下游传递文本片段:
python
for chunk in chain.stream("写一段不超过 80 字的产品简介。"):
print(chunk, end="", flush=True)
如果最终只需要展示回答,StrOutputParser 通常已经足够。
需要强类型对象时使用 PydanticOutputParser
当模型结果要进入数据库、工作流或后端接口时,更适合先定义一个明确的数据模型,再让解析器负责格式说明和结果校验。
python
from typing import Optional
from pydantic import BaseModel, Field
from langchain_openai import ChatOpenAI
from langchain_core.output_parsers import PydanticOutputParser
from langchain_core.prompts import PromptTemplate
class Joke(BaseModel):
setup: str = Field(description="笑话的开头")
punchline: str = Field(description="笑话的包袱")
rating: Optional[int] = Field(
default=None,
ge=1,
le=10,
description="1 到 10 分的评分",
)
model = ChatOpenAI(model="gpt-4o-mini", temperature=0)
parser = PydanticOutputParser(pydantic_object=Joke)
prompt = PromptTemplate(
template=(
"请根据用户主题创作一个简短笑话。\n"
"{format_instructions}\n"
"主题:{topic}"
),
input_variables=["topic"],
partial_variables={
"format_instructions": parser.get_format_instructions()
},
)
chain = prompt | model | parser
result = chain.invoke({"topic": "程序员写单元测试"})
print(result.setup)
print(result.punchline)
print(result.rating)
这里最关键的方法是 get_format_instructions()。它根据 Pydantic 模型生成格式约束,并把约束注入提示词。模型负责按格式生成,解析器负责把结果转换成对象并校验字段。
需要通用字典时使用 JsonOutputParser
如果下游系统只要求 JSON 或 Python 字典,不需要强类型对象,可以使用 JsonOutputParser:
python
from langchain_core.output_parsers import JsonOutputParser
from langchain_core.prompts import PromptTemplate
parser = JsonOutputParser()
prompt = PromptTemplate(
template=(
"提取文本中的姓名和城市。\n"
"{format_instructions}\n"
"文本:{text}"
),
input_variables=["text"],
partial_variables={
"format_instructions": parser.get_format_instructions()
},
)
chain = prompt | model | parser
result = chain.invoke({"text": "李华毕业后前往杭州工作。"})
print(result)
除了字符串、Pydantic 和 JSON,LangChain 还提供 XML、YAML、CSV、枚举、日期等解析器。选择原则很简单:下游需要什么类型,就让解析器输出什么类型。
输出解析器与 with_structured_output 如何选择
二者都可以获得结构化结果,但控制位置不同:
PydanticOutputParser是独立组件,格式说明、提示词和解析过程都显式可见,适合需要精细控制提示词或统一解析逻辑的场景。with_structured_output()是聊天模型的方法,通常会优先利用模型提供方的工具调用或结构化输出能力,代码更短,也更贴近模型原生能力。
python
structured_model = model.with_structured_output(Joke)
result = structured_model.invoke("讲一个关于数据库索引的笑话")
如果模型提供方原生支持稳定的结构化输出,可以优先考虑 with_structured_output();如果需要把解析器作为独立 Runnable 复用,或需要明确控制格式提示,则使用输出解析器更合适。
无论采用哪一种方式,都不应假设模型永远会返回合法数据。生产环境还需要捕获解析异常、记录原始响应,并根据业务风险决定重试、降级还是拒绝结果。
用 Document 统一不同数据源
RAG 系统可能要处理 PDF、Markdown、网页、数据库记录甚至代码文件。如果每种来源都保留自己的数据结构,后续的切分、向量化和检索会非常复杂。
LangChain 使用 Document 作为统一抽象。一个 Document 主要包含三类信息:
page_content:正文文本。metadata:来源、页码、标题层级、文件名等元数据。id:可选的唯一标识。
python
from langchain_core.documents import Document
documents = [
Document(
page_content="退款申请提交后,通常会在 3 个工作日内完成审核。",
metadata={
"source": "售后服务说明.md",
"category": "退款规则",
},
),
Document(
page_content="审核通过后,款项会原路退回。",
metadata={
"source": "售后服务说明.md",
"category": "退款规则",
},
),
]
正文用于向量化和模型上下文,元数据则用于追踪来源、过滤检索结果和生成引用。很多 RAG 系统能检索到正确内容,却无法告诉用户答案来自哪里,根本原因往往不是模型能力不足,而是数据加载时丢失了元数据。
加载 PDF 文档
PyPDFLoader 可以把 PDF 转换为 Document 列表。默认情况下,每一页通常对应一个对象,页码和文件来源会保存在元数据中。
python
from langchain_community.document_loaders import PyPDFLoader
loader = PyPDFLoader("./docs/product-manual.pdf")
docs = loader.load()
print(f"文档页数:{len(docs)}")
print(docs[0].page_content[:200])
print(docs[0].metadata)
按页加载有两个明显优势:一是可以保留页码,方便答案引用;二是某一页解析失败时更容易定位问题。
但 PDF 并不等于纯文本。遇到扫描件、多栏排版、复杂表格或图表时,普通文本抽取可能会出现顺序错乱或内容缺失。此时应根据文件特征增加 OCR、版面分析或多模态模型,而不是只调整切分参数。
加载 Markdown 文档
Markdown 天然带有标题、列表、表格等结构,适合构建高质量知识库。可以使用 UnstructuredMarkdownLoader 读取:
bash
pip install "unstructured[md]" nltk
single 模式把整个文件作为一个 Document 返回:
python
from langchain_community.document_loaders import UnstructuredMarkdownLoader
loader = UnstructuredMarkdownLoader(
"./docs/faq.md",
mode="single",
)
docs = loader.load()
print(len(docs))
print(docs[0].metadata)
elements 模式会根据标题、叙述文本、列表项、表格、图片等元素拆分文档:
python
loader = UnstructuredMarkdownLoader(
"./docs/faq.md",
mode="elements",
)
elements = loader.load()
for element in elements[:3]:
print(element.page_content)
print(element.metadata.get("category"))
元素模式通常会产生更丰富的元数据,例如:
category:元素类型,如Title、ListItem、Table、NarrativeText。element_id:当前元素的唯一标识。parent_id:父元素标识,可用于恢复标题与正文之间的层级关系。
如果知识库非常依赖文档结构,例如需要按标题过滤、保留问答层级或单独处理表格,元素模式更有价值;如果后续会统一使用自己的分割策略,单文档模式通常更简单。
RAG 的离线处理与在线检索
文档加载器和文本分割器主要工作在离线阶段,输出解析器则更多出现在在线回答阶段。一条完整的 RAG 链路可以分成两个过程。
离线数据处理:
text
加载文档 -> 清洗内容 -> 切分文档 -> 生成向量 -> 写入向量数据库
在线检索:
text
接收问题 -> 检索相关文档块 -> 拼装提示词 -> 调用模型 -> 解析结果
离线处理决定"知识是否进入系统",在线检索决定"正确知识能否在正确时刻被取出"。如果加载阶段漏掉了正文,或者切分阶段破坏了语义,后面的模型再强也无法凭空恢复信息。
文本切分为什么会影响检索质量
长文档不适合直接送入向量模型和聊天模型。一方面,大块文本难以与具体问题精确匹配;另一方面,模型上下文窗口有限,过大的片段会挤占输入空间并增加成本。
但切得越小也不一定越好。片段过小会丢失上下文,例如只保留"审核通过后"而没有保留前文中的"退款申请",检索结果即使命中也可能无法独立表达完整含义。
文本切分本质上是在三个目标之间取平衡:
- 检索粒度:块越小,定位通常越精确。
- 语义完整性:块越大,信息通常越完整。
- 上下文与成本:块越大,占用的 Token 越多。
基于字符长度切分
CharacterTextSplitter 会围绕指定分隔符组织文本块,并用字符数衡量长度。
python
from langchain_text_splitters import CharacterTextSplitter
text_splitter = CharacterTextSplitter(
separator="\n\n",
chunk_size=500,
chunk_overlap=80,
length_function=len,
is_separator_regex=False,
)
chunks = text_splitter.split_documents(docs)
几个核心参数需要一起理解:
separator:优先在哪些位置断开,例如段落之间的双换行。chunk_size:目标块大小。chunk_overlap:相邻块重复保留的内容,用于维持上下文连续性。length_function:如何计算长度,len代表字符数。
chunk_size 是目标值,不一定是绝对上限。如果某个完整段落本身已经超过目标大小,而当前分隔策略无法继续拆分,分割器可能保留整个段落并给出超长提示。这通常是在语义完整性和长度限制之间选择前者。
处理这类提示时,应先查看超长块的分布:如果大部分块都超长,说明目标值可能太小;如果只有少量异常块,则应检查文档中是否存在超长段落、URL、表格或缺失换行。
基于 Token 长度切分
模型按 Token 而不是按字符计费和限制上下文。中英文、数字和符号的 Token 比例并不相同,因此对接特定模型时,Token 长度通常比字符长度更准确。
python
from langchain_text_splitters import CharacterTextSplitter
text_splitter = CharacterTextSplitter.from_tiktoken_encoder(
encoding_name="cl100k_base",
chunk_size=300,
chunk_overlap=50,
)
chunks = text_splitter.split_documents(docs)
这里 chunk_size 和 chunk_overlap 使用 Token 数衡量。编码方式需要与目标模型尽量匹配,否则估算会产生偏差。
需要注意,改变长度计算方式并不会自动解决语义边界问题。即使按 Token 计数,如果分隔器仍然不愿破坏一个完整的大段落,也可能生成超过目标值的块。
需要硬性上限时使用递归切分
如果任何文本块都不能超过指定大小,可以使用 RecursiveCharacterTextSplitter。它会按分隔符优先级逐层尝试:先按段落,再按行、标点、空格,最后才退化为更细的字符边界。
python
from langchain_text_splitters import RecursiveCharacterTextSplitter
text_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
encoding_name="cl100k_base",
chunk_size=300,
chunk_overlap=50,
separators=[
"\n\n",
"\n",
"。",
"!",
"?",
";",
",",
".",
",",
" ",
"",
],
)
chunks = text_splitter.split_documents(docs)
中文文本应显式加入中文句号、逗号、问号和分号等分隔符。如果只使用空格和换行,中文词组更容易在不自然的位置被切开。
递归切分可以更严格地控制大小,但代价是极端情况下会牺牲语义完整性。因此,硬性限制不应该只是为了让日志更干净,而应来自明确的模型上下文、向量模型限制或接口约束。
特殊文档优先使用结构感知切分
纯字符切分不了解文档语法。对于结构明确的数据,按结构切分通常更合理:
- Markdown:按
#、##、###等标题层级切分。 - HTML:按标题或其他标签切分。
- JSON:按对象或数组元素切分。
- 代码:按类、函数或逻辑块切分。
例如,Python 代码可以使用专门的分割器:
python
from langchain_text_splitters import PythonCodeTextSplitter
python_code = """
class PriceService:
def calculate(self, amount, discount):
return amount * (1 - discount)
def format_price(value):
return f"¥{value:.2f}"
"""
splitter = PythonCodeTextSplitter(
chunk_size=120,
chunk_overlap=20,
)
chunks = splitter.create_documents([python_code])
结构感知切分能够尽量让一个函数、一个标题下的内容或一个 JSON 对象保持在同一块中,通常比固定字符截断更适合检索。
把加载、切分和输出解析串起来
下面用一个简化示例展示三类组件如何协作。向量检索部分可以替换为 Redis、Chroma、Pinecone 或其他向量存储,关键是保持输入输出边界清晰。
python
from pathlib import Path
from typing import List
from pydantic import BaseModel, Field
from langchain_community.document_loaders import (
PyPDFLoader,
UnstructuredMarkdownLoader,
)
from langchain_core.documents import Document
from langchain_core.output_parsers import PydanticOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from langchain_text_splitters import RecursiveCharacterTextSplitter
def load_documents(file_path: str) -> List[Document]:
path = Path(file_path)
suffix = path.suffix.lower()
if suffix == ".pdf":
return PyPDFLoader(str(path)).load()
if suffix in {".md", ".markdown"}:
return UnstructuredMarkdownLoader(
str(path), mode="single"
).load()
raise ValueError(f"不支持的文件类型:{suffix}")
def split_documents(docs: List[Document]) -> List[Document]:
splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
encoding_name="cl100k_base",
chunk_size=400,
chunk_overlap=60,
separators=[
"\n\n", "\n", "。", "!", "?", ";", ",", " ", ""
],
)
return splitter.split_documents(docs)
class RagAnswer(BaseModel):
answer: str = Field(description="只依据上下文生成的答案")
sources: List[str] = Field(description="答案使用的来源")
found: bool = Field(description="上下文中是否存在足够信息")
def format_context(docs: List[Document]) -> str:
parts = []
for index, doc in enumerate(docs, start=1):
source = doc.metadata.get("source", "unknown")
page = doc.metadata.get("page")
location = f"{source}#page={page + 1}" if page is not None else source
parts.append(
f"[文档 {index}]\n来源:{location}\n内容:{doc.page_content}"
)
return "\n\n".join(parts)
parser = PydanticOutputParser(pydantic_object=RagAnswer)
prompt = ChatPromptTemplate.from_template(
"""
你是一个知识库问答助手,只能根据给定上下文回答。
如果上下文不足,请明确说明,并将 found 设置为 false。
{format_instructions}
问题:{question}
上下文:
{context}
"""
).partial(format_instructions=parser.get_format_instructions())
model = ChatOpenAI(model="gpt-4o-mini", temperature=0)
answer_chain = prompt | model | parser
# 离线阶段:加载、切分,并将 chunks 写入向量数据库。
docs = load_documents("./docs/product-manual.pdf")
chunks = split_documents(docs)
# 在线阶段:这里用检索器返回的结果替换示例变量。
retrieved_docs = chunks[:3]
result = answer_chain.invoke(
{
"question": "退款审核通常需要多长时间?",
"context": format_context(retrieved_docs),
}
)
print(result.model_dump())
这个示例中,每个组件都只承担一种职责:加载器适配数据源,分割器控制检索粒度,解析器保证结果结构。它们之间通过 Document、字符串和 Pydantic 对象建立清晰边界,后续替换模型、向量数据库或文件格式时,不需要重写整条链路。
生产环境中的调优重点
真正上线前,建议重点检查以下问题:
- 解析成功率:统计结构化结果的解析失败率,并保留失败时的原始模型响应。
- 元数据完整性:至少保留来源、页码或标题路径,保证答案可以追溯。
- 空内容与乱码:加载后先检查空页面、异常编码和重复页眉页脚。
- 块大小分布:不要只看平均值,还要观察最大值、分位数和超长块数量。
- 重叠比例:重叠过小可能丢上下文,过大则会增加向量存储、检索重复和 Token 成本。
- 中文分隔符:根据语料加入中文标点,避免在词组中间切断。
- 结构化文档:Markdown、HTML、JSON 和代码优先采用结构感知策略。
- 检索评估:准备一组真实问题,验证正确片段能否进入 Top K,而不是只观察最终回答是否流畅。
输出解析、文档加载和文本切分看起来都是基础组件,却直接决定了 LLM 应用的数据质量。可靠的系统并不是把模型接上接口就结束,而是让输入有来源、过程有边界、输出有结构。把这条数据管道打磨好,RAG 应用才会真正具备可检索、可验证和可维护的工程能力。