第3讲:LangChain 生态系统¶
上一讲:RAG 核心概念深入 下一讲:Milvus 索引机制与基本操作
本讲导入¶
LangChain 的组件很多:Runnable、Prompt、Message、Parser、History、Loader、Splitter、VectorStore 都是常见概念。学习这些组件时,最重要的不是记住每个类名,而是看清它们在企业级 RAG 项目中分别出现在什么位置、解决什么工程问题。
本讲只围绕三个问题:
- LangChain 在本项目里到底是什么角色?
- 一次在线问答中,哪些步骤用到了 LangChain?
- 一次离线入库中,哪些步骤用到了 LangChain?
先记住一句话:
本项目没有把 LangChain 当成“一键 RAG 框架”,而是把它当成一组可靠的工程适配器:模型适配器、消息对象、结构化输出、历史记录、文档对象、加载器、切分器和向量库封装。
本讲目标¶
学完本讲后,需要能说清楚:
- 为什么项目用
ChatOpenAI接入 DashScope,而不是直接绑定某个厂商 SDK。 SystemMessage、HumanMessage、AIMessage在多轮对话和 Prompt 中分别承担什么角色。with_structured_output()为什么比让模型返回自由文本更适合意图识别和查询变体生成。SQLChatMessageHistory、Document、TextSplitter、Milvus VectorStore分别位于项目哪条链路。- 为什么本项目没有直接使用
RetrievalQA、ConversationalRetrievalChain或一条 LCEL 管道完成全部 RAG。
本讲地图¶
本图只展示本项目真正使用的 LangChain 能力:它们是工程适配器,不替代业务编排。
图 1:第 03 讲功能闭环地图¶
flowchart TD
C03_CHAT["模型适配<br/>get_chat_model()"]
C03_MSG["对话消息<br/>format_messages()"]
C03_STRUCT["结构化输出<br/>with_structured_output(QueryVariants)"]
C03_DOC["文档对象<br/>load_file()"]
C03_SPLIT["文本切分<br/>split_documents()"]
C03_VECTOR{{"向量库封装<br/>MilvusHybridStore"}}
C03_CHAT --> C03_MSG
C03_MSG --> C03_STRUCT
C03_STRUCT --> C03_DOC
C03_DOC --> C03_SPLIT
C03_SPLIT --> C03_VECTOR
style C03_CHAT fill:#F8FAFC,stroke:#64748B,stroke-width:2px
style C03_MSG fill:#DBEAFE,stroke:#2563EB,stroke-width:2px
style C03_STRUCT fill:#F5F3FF,stroke:#7C3AED,stroke-width:2px
style C03_DOC fill:#FEF3C7,stroke:#D97706,stroke-width:2px
style C03_SPLIT fill:#DBEAFE,stroke:#2563EB,stroke-width:2px
style C03_VECTOR fill:#DCFCE7,stroke:#16A34A,stroke-width:2px
节点与代码对齐¶
| 节点 | 对齐文件 | 函数/对象 | 本章职责 |
|---|---|---|---|
| 模型适配 | qa_core/llm/client.py |
get_chat_model() |
用 ChatOpenAI 兼容接口接入 DashScope 等模型服务。 |
| 对话消息 | qa_core/memory/history.py |
format_messages() |
用消息对象组织多轮历史和追问上下文。 |
| 结构化输出 | qa_core/pipeline/query_variants.py |
with_structured_output(QueryVariants) |
让模型输出稳定的查询变体结构。 |
| 文档对象 | qa_core/indexing/document_loaders.py |
load_file() |
把多格式资料加载成 LangChain Document。 |
| 文本切分 | qa_core/indexing/chunking.py |
split_documents() |
把长文档切成可检索 chunk。 |
| 向量库封装 | qa_core/retrieval/store.py |
MilvusHybridStore |
封装 langchain-milvus 与 PyMilvus 的检索边界。 |
第一部分:LangChain 在项目中的角色¶
1.1 LangChain 不是完整业务流程¶
LangChain 不是模型,也不是知识库,更不是项目的业务大脑。它更像一组标准接口:
| 项目问题 | LangChain 提供的抽象 |
|---|---|
| 不同 LLM 厂商 API 不一致 | ChatOpenAI 等 ChatModel 统一接口 |
| 多轮对话消息结构容易混乱 | SystemMessage / HumanMessage / AIMessage |
| LLM 输出格式不稳定 | with_structured_output() + Pydantic |
| 历史消息要持久化 | SQLChatMessageHistory |
| 文件格式不同 | Document Loader |
| 大文档需要切块 | Text Splitter |
| 向量库写入和检索 API 复杂 | VectorStore |
也就是说,LangChain 解决的是接口标准化和工程胶水问题。本项目真正的业务流程仍然由 qa_core 自己编排。
1.2 本项目的使用边界¶
flowchart TB
subgraph Use["本项目重点使用"]
A["ChatOpenAI<br/>统一 LLM 调用"]
B["Message Types<br/>多轮对话消息"]
C["Structured Output<br/>意图/变体结构化"]
D["SQLChatMessageHistory<br/>MySQL 历史"]
E["Document / Loader / Splitter<br/>离线入库"]
F["Milvus VectorStore<br/>向量库封装"]
end
subgraph Light["本项目只讲清楚,不作为主链路"]
G["Runnable<br/>invoke / stream / batch"]
H["LCEL<br/>prompt | model | parser"]
end
subgraph Avoid["本项目不直接采用"]
I["RetrievalQA"]
J["ConversationalRetrievalChain"]
K["Agent 自动决策"]
end
Use --> Light --> Avoid
style Use fill:#ECFDF5,stroke:#059669,stroke-width:2px
style Light fill:#EFF6FF,stroke:#2563EB,stroke-width:2px
style Avoid fill:#FEF2F2,stroke:#DC2626,stroke-width:2px
这里要特别区分:不用高层 Chain,不代表不用 LangChain。本项目用的是 LangChain 的底层稳定组件,把分支、阈值、追问改写、FAQ 直出、文档检索、Prompt 档位这些业务决策留在项目代码里。
第二部分:一张图看清两条主线¶
本项目中 LangChain 的使用分成两条线:
- 在线问答链路:用户提问 -> 意图识别 -> 检索准备 -> Prompt -> LLM 流式生成。
- 离线入库链路:业务文件 -> Document -> 切分 -> Milvus VectorStore 写入。
flowchart LR
subgraph Online["在线问答链路"]
Q["用户问题"]
M1["Message<br/>历史上下文"]
I["Structured Output<br/>意图/查询变体"]
P["Prompt Profile<br/>System + Human"]
L["ChatOpenAI.stream()<br/>流式生成"]
end
subgraph Offline["离线入库链路"]
F["PDF / Word / MD / Excel"]
D["Document Loader<br/>统一成 Document"]
S["Text Splitter<br/>父子块切分"]
V["Milvus VectorStore<br/>add_documents"]
end
subgraph Store["Milvus"]
FAQ[("FAQ 集合")]
DOC[("文档集合")]
end
Q --> M1 --> I --> P --> L
F --> D --> S --> V --> DOC
I --> FAQ
P --> DOC
style Online fill:#EFF6FF,stroke:#2563EB,stroke-width:2px
style Offline fill:#ECFDF5,stroke:#059669,stroke-width:2px
style Store fill:#FFFBEB,stroke:#D97706,stroke-width:2px
这张图就是本讲主线。后面所有组件都放回这两条线里讲。
第三部分:在线问答链路中的 LangChain¶
3.1 ChatOpenAI:统一模型调用入口¶
项目文件:qa_core/llm/client.py
本项目实际使用 DashScope 的 OpenAI-compatible 接口,但代码里不直接写 DashScope SDK,而是统一用 LangChain 的 ChatOpenAI:
这里的设计点:
base_url可以指向 DashScope、DeepSeek 或其他 OpenAI-compatible 服务。streaming=False用于意图识别、查询改写、结构化输出。streaming=True用于最终答案生成,方便 WebSocket 逐 token 推送。@lru_cache(maxsize=2)只缓存两个客户端实例,不缓存模型答案。
3.2 Message:让多轮对话结构稳定¶
LangChain 的消息对象对应 OpenAI API 的角色格式:
| LangChain 对象 | API role | 项目用途 |
|---|---|---|
SystemMessage |
system |
设定助手身份、回答边界、风险约束 |
HumanMessage |
user |
用户问题、改写请求、检索上下文问题 |
AIMessage |
assistant |
历史回答,进入多轮上下文 |
项目文件:qa_core/memory/history.py
需要理解:LLM 本身没有会话记忆。所谓“多轮对话”,本质是每次调用模型时,把必要的历史消息重新发给模型。
sequenceDiagram
participant U as 用户
participant API as QAService
participant H as SQLChatMessageHistory
participant LLM as ChatOpenAI
U->>API: 入职流程有哪些步骤?
API->>LLM: [System, Human]
LLM-->>API: AIMessage
API->>H: 保存 Human + AI
U->>API: 那审批需要多久?
API->>H: 读取最近历史
API->>LLM: [System, Human, AI, Human]
LLM-->>API: 能理解“审批”指入职审批
3.3 Structured Output:把 LLM 输出变成业务对象¶
项目文件:qa_core/intent/classifier.py
意图识别不能让模型自由发挥。自由文本会出现这些情况:
这些格式都不稳定。项目里使用 Pydantic 结构约束:
返回值不是字符串,而是一个 Pydantic 对象:
这一步是 LangChain 在项目中非常关键的价值:让 LLM 的输出进入可校验、可分支、可记录的工程世界。
同样的方式也用于查询变体生成。
3.4 Prompt Profile:不是一个 Prompt 走天下¶
项目文件:
qa_core/prompts/profiles.pyqa_core/prompts/selector.py
项目没有把所有问题都塞进同一个 Prompt,而是按意图和风险类别选择不同模板:
可以这样理解:
- Prompt 不是一段固定文案,而是回答策略配置。
system_template控制助手身份、边界、风险口径。user_template注入历史、检索上下文、用户问题。reason进入调试信息,帮助解释为什么选择这个模板。
3.5 最终答案:ChatOpenAI.stream() 推给前端¶
项目文件:qa_core/pipeline/steps.py
项目主流程会把每个 chunk 转成 WebSocket token 事件:
注意:这里不是 LangChain 替我们完成整个 RAG。LangChain 只负责模型流式调用;FAQ 直出、文档检索、上下文筛选、引用补强、写历史这些仍由项目代码控制。
第四部分:离线入库链路中的 LangChain¶
在线问答依赖的是“可检索的知识库”。这个知识库来自离线入库链路:
flowchart LR
A["业务文件<br/>PDF/Word/MD/Excel"] --> B["Loader<br/>转成 Document"]
B --> C["Normalizer<br/>补齐 metadata"]
C --> D["Splitter<br/>父子块切分"]
D --> E["Milvus VectorStore<br/>add_documents"]
E --> F[("Milvus<br/>dense + sparse")]
style A fill:#EFF6FF,stroke:#2563EB
style D fill:#ECFDF5,stroke:#059669
style F fill:#FFFBEB,stroke:#D97706
4.1 Document:入库链路的统一数据结构¶
LangChain 的 Document 很简单,只有两个核心字段:
本项目的约定:
page_content存正文,用于 embedding、BM25 和最终上下文。metadata存来源、场景、权限、知识库版本、文件名、行号等治理字段。
需要记住:RAG 入库不是只存文本,还要存 metadata。没有 metadata,就无法做来源引用、权限过滤、版本隔离和质量追踪。
4.2 Document Loader:不同文件格式统一成 Document¶
项目文件:qa_core/indexing/document_loaders.py
项目用注册表模式管理 Loader,而不是在主流程里写一堆 if/elif:
重点不是背 Loader 名称,而是理解模式:
文件格式不同,但进入后续入库链路之前,都要统一成
list[Document]。
4.3 Text Splitter:为什么默认使用 RecursiveCharacterTextSplitter¶
项目文件:qa_core/indexing/chunking.py
为什么用它作为默认方案:
- 它快、稳定、便宜,不依赖 embedding 模型。
- chunk 大小可控,适合向量库检索。
- 对制度、流程、手册、FAQ、Markdown 这类企业资料足够可靠。
- 参数容易解释,适合学习、排查和生产调优。
SemanticChunker 不是不能用,但不适合作为默认切分器。它更适合无明显结构、话题自然漂移的长文,例如访谈、会议纪要、研究报告。企业 RAG 的主链路更需要稳定、可控、可复现。
4.4 MarkdownHeaderTextSplitter:先保留章节结构¶
项目中 Markdown 文件会先按标题拆分,再进入递归切分:
这样做的好处是:标题层级会进入 metadata,后续回答可以显示更清晰的来源,例如:
4.5 父子块策略:检索要精确,回答要完整¶
flowchart TD
Doc["原始文档"] --> Parent["父块<br/>较大,保留完整上下文"]
Parent --> Child["子块<br/>较小,用于精确召回"]
Child --> VectorDB[("Milvus<br/>page_content = 子块")]
Parent --> Meta["metadata.parent_content<br/>保存父块全文"]
VectorDB --> Hit["检索命中子块"]
Meta --> Context["回答时补充父块上下文"]
style VectorDB fill:#ECFDF5,stroke:#059669
style Context fill:#EFF6FF,stroke:#2563EB
一句话解释:
子块负责“找得准”,父块负责“答得完整”。
更深入的 chunk size、overlap 和质量检查放到 附录 G:文档切分策略 和第 16 讲展开。本讲只建立在 LangChain 生态中的位置感。
4.6 VectorStore:把 Document 写入 Milvus¶
项目文件:qa_core/retrieval/store.py
这里先只讲抽象:
embedding_function生成 dense 向量。builtin_function=bm25_function()让 Milvus 服务端生成 sparse 向量。add_documents()写入Document。similarity_search_with_score()检索并返回Document + score。
下一讲再进入 Milvus 自身:Collection、Schema、Index、Load、Insert、Search。
第五部分:Runnable 和 LCEL 只作为统一接口理解¶
5.1 Runnable 的意义¶
Runnable 是 LangChain 的统一调用协议。无论 Prompt、Model、Parser,核心调用都收敛到三个方法:
| 方法 | 语义 | 本项目对应 |
|---|---|---|
invoke() |
一个输入,返回完整结果 | 意图识别、查询改写、结构化输出 |
stream() |
一个输入,持续返回片段 | 最终答案逐 token 输出 |
batch() |
多个输入,批量处理 | 批量测试、批量解析、离线评测可用 |
5.2 LCEL 是线性链路语法,不是项目主流程¶
LCEL 可以把 Prompt、Model、Parser 串成线性管道:
它适合简单线性任务:
但本项目的 RAG 主流程不是一条直线:
flowchart TD
Q["用户问题"] --> R["Stage 1 查询路由"]
R -->|问候/越界/转人工| Direct["直接返回"]
R -->|FAQ 精确命中| FAQDirect["FAQ 标准答案直出"]
R -->|需要检索| Prep["意图识别 + 改写 + 检索计划"]
Prep --> FAQ["FAQ 检索"]
FAQ -->|高分命中| FAQAnswer["FAQ 直出"]
FAQ -->|未直出| Doc["文档检索"]
Doc --> Context["上下文构建"]
Context -->|无可靠上下文| Insufficient["信息不足"]
Context -->|有上下文| LLM["ChatOpenAI.stream"]
LLM --> Save["写历史 + Trace"]
style Direct fill:#FEF2F2,stroke:#DC2626
style FAQAnswer fill:#ECFDF5,stroke:#059669
style LLM fill:#EFF6FF,stroke:#2563EB
所以本项目选择显式编排,而不是高层 Chain:
可以这样总结:
LCEL 很适合教“组件怎么串起来”,但企业 RAG 主链路有大量分支、阈值、提前退出和诊断信息,所以本项目不用一条 LCEL 链包到底。
第六部分:自研与生态的分工¶
6.1 交给 LangChain 的部分¶
| 能力 | 原因 |
|---|---|
| LLM 客户端 | 多厂商 OpenAI-compatible 统一接口 |
| Message 类型 | 对话历史结构稳定 |
| 结构化输出 | Pydantic 约束,减少自由文本解析 |
| SQLChatMessageHistory | 省掉历史消息 CRUD |
| Document / Loader | 文件解析后统一结构 |
| Text Splitter | 成熟切分策略,减少手写边界问题 |
| VectorStore | 统一向量库写入和检索入口 |
6.2 项目自己实现的部分¶
| 能力 | 为什么自己做 |
|---|---|
| 查询路由 | 要优先处理问候、转人工、越界、FAQ 精确命中 |
| 意图规则 | 高频确定场景不应都调用 LLM |
| 检索计划 | 不同意图、风险类别、source 需要不同参数 |
| FAQ 直出 | 标准答案命中后不需要 LLM 生成 |
| 上下文筛选 | 要做分数阈值、重排、来源整理 |
| Prompt Profile | 不同业务类别要有不同口径 |
| 引用补强 | 企业 RAG 必须给出可追溯来源 |
| Trace 和诊断 | 调试、验收和生产排障都需要可解释过程 |
这就是本项目的工程取舍:底层组件用生态,业务编排自己掌控。
第七部分:学习路径建议¶
为了避免把 LangChain 学成零散组件,建议按下面顺序学习,而不是按组件名孤立学习:
| 顺序 | 学什么 | 目标 |
|---|---|---|
| 1 | LangChain 在项目里的边界 | 先防止误解成“一键 RAG” |
| 2 | 在线问答主线 | 看清 Message、ChatOpenAI、结构化输出在哪里用 |
| 3 | 离线入库主线 | 看清 Document、Loader、Splitter、VectorStore 在哪里用 |
| 4 | Runnable / LCEL | 解释统一接口,但不把主项目讲成 LCEL 链 |
| 5 | 自研 vs 生态 | 回答“为什么不用 RetrievalQA” |
| 6 | 进入 demo | 用小 demo 验证每个组件,不把 demo 当项目主线 |
配套 demo 可以按这个顺序练习:
| demo | 建议放在 |
|---|---|
demo02_runnable_interface.py |
Runnable 统一协议 |
demo03_chatopenai_structured_output.py |
ChatOpenAI + 结构化输出 |
demo04_message_types.py |
Message 类型 |
demo05_prompt_templates.py |
Prompt 模板 |
demo09_sql_chat_history.py |
历史持久化 |
demo10_document_loaders_splitters.py |
Loader + Splitter |
demo11_loader_registry.py |
注册表模式 |
demo14_vectorstore_milvus.py |
VectorStore + Milvus |
第八部分:常见误区¶
8.1 误区一:用了 LangChain 就应该用 RetrievalQA¶
不对。RetrievalQA 适合快速 demo,但企业级 RAG 需要:
- FAQ 标准答案直出
- 意图识别
- 追问改写
- 多场景 source 过滤
- 知识库版本隔离
- 风险 Prompt Profile
- 引用来源补强
- Trace 和诊断面板
这些都很难塞进一个黑盒 Chain 里。
8.2 误区二:LCEL 越多越工程化¶
LCEL 适合表达线性链路,但不是所有流程都应该写成管道。复杂业务分支用显式函数更清楚,也更容易调试。
8.3 误区三:SemanticChunker 一定比 RecursiveCharacterTextSplitter 更好¶
不一定。企业 RAG 里的切分要可控、稳定、便宜、可复现。RecursiveCharacterTextSplitter 更适合作为默认方案;SemanticChunker 可以作为特殊文档的增强策略,而不是主链路默认切分器。
8.4 误区四:VectorStore 就是 Milvus¶
不是。VectorStore 是 LangChain 的抽象,Milvus 是具体后端。本项目选择 Milvus,是因为需要服务端 BM25、混合检索、多集合、多版本和企业级部署能力。
本讲实践闭环¶
| 项目 | 内容 |
|---|---|
| 本讲类型 | 项目地图 + 组件定位 |
| 主线 | 在线问答链路、离线入库链路 |
| 核心产物 | 能画出 LangChain 在项目中的使用位置 |
| 是否进入最终项目 | 是,多个组件已在 qa_core 中使用 |
| 验收方式 | 能解释为什么项目“用 LangChain 组件,但不用高层 Chain 包办 RAG” |
| 后续落点 | 第 4 讲 Milvus、第 5 讲意图识别、第 8 讲混合检索、第 10 讲 RAG 主流程、第 16 讲入库 |
重点掌握¶
| 优先级 | 内容 | 要求 |
|---|---|---|
| 必会 | LangChain 在本项目中的定位 | 生态适配器,不是业务编排核心 |
| 必会 | ChatOpenAI 工厂 |
知道 streaming=True/False 分别用于哪里 |
| 必会 | Message 类型 | 能解释多轮对话为什么要带历史消息 |
| 必会 | with_structured_output() |
能解释它如何让 LLM 输出变成业务对象 |
| 必会 | Document / Loader / Splitter / VectorStore | 能放回离线入库链路 |
| 理解 | Runnable 的 invoke/stream/batch |
知道统一调用协议 |
| 理解 | LCEL 的适用边界 | 适合线性任务,不适合本项目完整 RAG 主链路 |
| 了解 | SQLChatMessageHistory | 知道它负责历史持久化,不负责检索 |
本讲小结¶
- LangChain 在本项目里不是“一键 RAG 框架”,而是一组工程适配器。
- 在线问答链路中主要用到
ChatOpenAI、Message、结构化输出、Prompt Profile 和流式生成。 - 离线入库链路中主要用到
Document、Loader、Splitter 和 Milvus VectorStore。 - Runnable 统一了
invoke / stream / batch,LCEL 统一了简单线性链路写法。 - 本项目没有使用高层 Chain 包办 RAG,是因为企业级链路有分支、阈值、直出、过滤、追问、版本、引用和诊断。
- 先看项目主线,再看组件 API,会更容易建立整体感。
下一讲:Milvus 索引机制与基本操作 — 进入向量库底层:Collection、Schema、Index、Insert、Search,以及 LangChain Milvus 封装隐藏了哪些操作。