LangChain从模型输出到RAG数据管道实战

从模型输出到知识库检索: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:元素类型,如 TitleListItemTableNarrativeText
  • 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_sizechunk_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 应用才会真正具备可检索、可验证和可维护的工程能力。

相关推荐
10x1017 小时前
企业 AI 真正的分水岭:不是人人都在用,而是工作方式被重新设计
人工智能
收放扳机17 小时前
PCB产线的“柔性”是怎么实现的——从自动化上下料设备兼容性看产线弹性
人工智能·科技·自动化·制造·pcb工艺
一个天蝎座 白勺 程序猿17 小时前
从电网改造踩坑说起:深度拆解时序大模型TimechoAI的自主可控与安全合规底气
大数据·运维·服务器·大模型·timechoai
天上路人18 小时前
全功能AI语音处理模组的算法集成与接口设计分析
人工智能·ai语音·麦克风·回音消除
触底反弹18 小时前
🔥 保姆级教程|SSE + BFF + 跨域三件套,从零实现 ChatGPT 流式输出(附完整代码)
人工智能·node.js·vite
嘿丨嘿18 小时前
VLA 入门(六):VLA 如何进行强化学习后训练?
人工智能·python·深度学习·机器人
触底反弹19 小时前
🔥 从零搭建 RAG 知识库:爬虫→分词→向量化→检索,一步都不能错
javascript·人工智能·面试
zhou lily19 小时前
超自动化落地:RPA+AI如何打通业务流程的“最后一公里”?
人工智能·自动化·rpa
tyqtyq2219 小时前
HarmonyOS AI 应用开发实战:简历项目经历改写系统
人工智能·学习·华为·生活·harmonyos