> ## Documentation Index
> Fetch the complete documentation index at: https://www.yuan111.asia/doc/llms.txt
> Use this file to discover all available pages before exploring further.

# 17 · 实战：给真实政策文件做问答（把所有学过的用在一份真文件上）

> 实战：给真实政策文件做问答，把切块、向量化、分 collection 检索全走一遍。

# 17 · 实战：给真实政策文件做问答（把所有学过的用在一份真文件上）

## 开头的现象

老王把两份文件发到小林的 data 目录里，说："这两份是国家'十五五'规划的真实文件——一份就业、一份教育。你学了两个月，别老拿员工手册那 3000 字玩具练手。用这两份真文件，把你学的切块、向量化、检索全走一遍。"

小林打开文件，眼睛直了：**《十五五就业优先规划》7448 字，《十五五教育规划》7682 字——每一份都比员工手册长一倍多。** 他咽了口口水，心里既有压力也有兴奋：**"终于要拿真家伙练手了。"**

他定的目标是：让机器人能回答这两份政策文件里的问题——"就业目标是什么？""立德树人怎么落实？"——**而且两份文件要能区分，问就业的问题不能答成教育的。**

## 第一幕：小林发现第一个坑——文件名会变，代码别写死

小林第一版代码是这么写的：

```python theme={null}
with open("data/十五五就业优先战略.txt", encoding="utf-8") as f:   # ← 死记文件名
    text = f.read()
```

他运行——**FileNotFoundError。** 他愣了一下，去 data 目录一看：文件名根本不是"就业优先战略"，是"就业优先"，而且**还多了一份"教育规划"**。他这才想起第 7 篇的教训，当时老王说过：**"文档会更新。"**

他改成了"自动发现"——用 `glob` 匹配"十五五"开头的所有 txt：

```python theme={null}
import glob, os
files = sorted(glob.glob(os.path.join("data", "十五五*.txt")))
print(f"发现 {len(files)} 个十五五文件")
# → 发现 2 个：就业优先、教育规划

# 以后加新政策文件，代码不用改，自动生效！
```

小林点点头：**"写死文件名 = 埋雷。自动发现 = 以后老王丢进第三份文件，我代码都不用碰。"** 他心想：**"这跟我第 8 篇说的'一个库多个 collection'是一个道理——结构要能容纳变化，别写死。"**

> 想验证"自动发现"吗？往 data 目录丢一个 `十五五XXX.txt`，重跑 `07_load_split.py`——它自动多处理一份文件，代码一行没改。文件多了，glob 自动发现。

## 第二幕：小林决定"一个库、两个 collection"——政策文件各归各的

小林第 8 篇学过"一个库 = 一个目录 = 多个 collection"。他决定不另开目录，**把两份政策文件塞进同一个向量库 `data/chroma_db`，用不同 collection 区分**：

```python theme={null}
from langchain_chroma import Chroma

DB_DIR = "data/chroma_db"   # 一个库

for fp in files:
    with open(fp, encoding="utf-8") as f:
        text = f.read()
    chunks = splitter.split_text(text)   # 第 7 篇的刀

    # 从文件名推断 collection 名（就业→employment，教育→education）
    if "就业" in fp:
        col_name = "employment_15_5"
    elif "教育" in fp:
        col_name = "education_15_5"

    # 只清理【自己的】collection——绝不碰员工手册的
    try:
        Chroma(collection_name=col_name, embedding_function=embeddings,
               persist_directory=DB_DIR).delete_collection()
        print(f"已清理 {col_name}（保留其他 collection）")
    except Exception:
        pass   # 首次运行，collection 不存在

    vs = Chroma(collection_name=col_name, embedding_function=embeddings,
                persist_directory=DB_DIR)
    vs.add_texts(chunks, ids=[f"{col_name}_chunk_{i}" for i in range(len(chunks))])
    print(f"「{fp}」切 {len(chunks)} 块 → 存入 {col_name}")
# → 就业优先 54 块 → employment_15_5
# → 教育规划 62 块 → education_15_5
```

小林特别强调了那行 `delete_collection()`——他第 8 篇踩过 `shutil.rmtree` 整个库的坑，这次只删自己的 collection，员工手册的库动都不动。**"合租房的自觉：只扔自己房间的垃圾。"**

他验证了一下最终结构——用一个 sqlite 查询看这个库里有几个 collection：

```text theme={null}
data/chroma_db 这一个库里的 collection：
  - employee_manual   9 个向量   ← 员工手册（第 05~07 篇的，还活着）
  - employment_15_5  54 个向量   ← 十五五就业优先（新）
  - education_15_5   62 个向量   ← 十五五教育规划（新）
```

小林盯着这行输出，心里踏实了：**"一个库装三份文档，各检索各的，互不干扰。这就是第 8 篇说的'一个库多个 collection'的实战版。"**

> 想验证"一个库三个 collection"吗？跑 `08_embed_search.py`——它会自动发现两份政策文件、各自入库。然后用 Python 查 `data/chroma_db/chroma.sqlite3` 的 collections 表，你会看到三个 collection 平起平坐。

## 第三幕：分 collection 检索——问就业的不会答成教育的

小林开始测试检索。他问了两个问题，各进各的 collection：

```python theme={null}
# 问就业的问题 → 查 employment_15_5
vs = Chroma(collection_name="employment_15_5", embedding_function=embeddings,
            persist_directory="data/chroma_db")
r = vs.similarity_search("就业目标是什么", k=1)
print(r[0].page_content[:60])
# → "就业是最基本的民生，事关人民群众切身利益……"  ← 就业规划的开头

# 问教育的问题 → 查 education_15_5
vs2 = Chroma(collection_name="education_15_5", embedding_function=embeddings,
             persist_directory="data/chroma_db")
r2 = vs2.similarity_search("立德树人怎么落实", k=1)
print(r2[0].page_content[:60])
# → "1.高质量实施新时代立德树人工程……"  ← 教育规划的"立德树人"段落
```

小林又故意试了"串门"——用教育的问题去查就业的库：

```python theme={null}
# 用「教育」的问题查「就业」的库（模拟搞混 collection）
r_wrong = vs.similarity_search("立德树人怎么落实", k=1)
print(r_wrong[0].page_content[:50])
# → 就业规划里跟"立德树人"最像的一段——答非所问！
```

他倒吸一口凉气：**"collection 隔离是'物理的'——不隔离的话，问教育的问题，就业库会硬凑一段最像的出来。分 collection 就是给每份文档一个'专属抽屉'，别混着放。"**

他还对比了关键词搜索和向量检索（第 8 篇的结论重现）——搜"人工智能对就业有什么影响"，关键词找"人工智能"命中 4 块（靠字面猜），向量检索精确命中"适应人工智能发展促进就业创业"那一块（靠意思）：

> 想验证"分抽屉"吗？跑 `08_embed_search.py` 的第三部分——就业问题查 employment\_15\_5、教育问题查 education\_15\_5，各答各的。你把 collection 名互换，立刻答非所问。

## 第四幕：边界——政策文件的检索，跟员工手册有什么不一样

小林用真实文件跑完，发现了几个"员工手册时代没遇到"的边界：

**边界一：政策文件是"长段落 + 序号结构"（一、（一）、1.），普通切分器会切得有点碎。** 他数了数：就业 54 块、教育 62 块，很多块只有一两句话。**政策文件的自然单位是"条目"，而不是"句子"**——理想的做法是按"（一）（二）（三）"这种条目边界切，但 `chunk_size=200` 的通用切法也能用，只是块更碎。

**边界二：两份文件用语高度相似（都是"深入贯彻""推动""促进"），向量容易混淆。** 小林发现"就业"和"教育"的开头段落用词很像，如果检索时不指定 collection，可能串。**分 collection 就是应对这个的。**

**边界三：政策文件的问题通常是"总结性提问"（"就业目标是什么"），需要检索到"总体要求"那一整段。** 单块 200 字符可能只覆盖一半，`k=2` 或 `k=3` 更稳。

**边界四：真实文件的 metadata 很重要。** 他给每个块打了 `col_name` 前缀的 id，这样检索到任何一块，都能知道它来自哪份文件——**"回答标出处"就靠这个。**

> 想验证边界吗？问"人工智能对就业的影响"（就业库）和"人工智能对教育的影响"（教育库）——注意，政策文件里两处都提到人工智能，但如果 collection 不分，检索会串到另一份文件去。

### 🔧 技术细节：实战用到的全部签名汇总（前文各章 + 本章新增）

这一章是把前面所有章串起来实战，小林把**全程用到的签名**汇总成一张"最终兵器谱"：

**① 文件自动发现（glob）——本章新增**

```python theme={null}
import glob, os
files = sorted(glob.glob(os.path.join("data", "十五五*.txt")))
# → ['data/十五五就业优先.txt', 'data/十五五教育规划.txt']
# glob 支持通配符 *，文件名写活，新增文件不用改代码
```

**② 切分（第 07 篇）**

```python theme={null}
RecursiveCharacterTextSplitter(
    chunk_size=200, chunk_overlap=20,
    separators=["\n\n", "\n", "。", "！", "？", "；", " ", ""],
).split_text(text)   # → list[str]
```

**③ 向量库：一个库多个 collection（第 08 篇）**

```python theme={null}
Chroma(
    collection_name="employment_15_5",   # 每份文档一个 collection
    embedding_function=embeddings,       # 建库/检索必须同一个模型
    persist_directory="data/chroma_db",  # 同一个库目录
)
vs.add_texts(chunks, ids=[...])          # 入库
vs.delete_collection()                   # 只删自己的，不碰别人的
vs.similarity_search(question, k=2)      # 检索
```

**④ 分 collection 检索（本章核心用法）**

```python theme={null}
# 问就业的查 employment，问教育的查 education —— 各答各的
vs_emp  = Chroma(collection_name="employment_15_5", embedding_function=emb, persist_directory="data/chroma_db")
vs_edu  = Chroma(collection_name="education_15_5",  embedding_function=emb, persist_directory="data/chroma_db")
vs_emp.similarity_search("就业目标是什么", k=1)   # → 就业规划开头
vs_edu.similarity_search("立德树人怎么落实", k=1)  # → 教育规划段落
```

**⑤ 从文件名推断 collection 名（id 命名溯源）**

```python theme={null}
if "就业" in fp: col_name = "employment_15_5"
elif "教育" in fp: col_name = "education_15_5"
vs.add_texts(chunks, ids=[f"{col_name}_chunk_{i}" for i in range(len(chunks))])
# → id 带 collection 前缀：检索到任何块都能知道它来自哪份文件
```

> 想验证吗？跑 `08_embed_search.py`——自动发现两份政策文件、各自入库（就业 54 块 / 教育 62 块）、分 collection 检索互不串门。查 `data/chroma_db/chroma.sqlite3` 的 collections 表，能看到三个 collection 平起平坐。

## 结论（小林用一天换来的）

**给真实文件做问答 = 把学过的东西串起来：`glob` 自动发现文件（别写死文件名）→ 切块（第 7 篇的刀）→ 一个库多个 collection 区分文档（第 8 篇，只删自己的 collection）→ 分 collection 检索（第 06/07 篇）。** 真实文件的边界：政策文件是"条目结构"（切得碎）、两份文件用语相似（必须分 collection 隔离）、总结性提问要 k 大一点、metadata 记好出处。**员工手册学的是"机制"，政策文件练的是"实战"——机制没变，但真实文件会逼你想清楚"怎么组织"（文件名、collection、边界）。**

## 复现信号：什么时候你会想起这一章

1. **文档会更新/新增**——文件名用 glob 自动发现，别写死。
2. **多份文档要共存**——一个库多个 collection，各检索各的，别混。
3. **要清理重建**——只删自己的 collection（`delete_collection()`），别 `rmtree` 整个库。
4. **检索结果答非所问**——先查 collection 是不是选错了，再查 embedding 模型是不是换了。
5. **真实文档结构特殊（政策/合同/报告）**——通用切分可能碎，考虑按文档自己的条目结构切。

***

## 后记：小林学到了什么（整个系列的收尾）

小林合上电脑，望着窗外。他想起了老板最初的需求——"基于你的文档回答问题"。现在他能说清楚这整条路是怎么走的了：

**第 01\~04 篇：会说话。** 模型统一插口（invoke/stream/with\_structured\_output/thinking）——换供应商只改三行。 **第 05\~07 篇：有资料。** Document 切块、Embedding 向量化、Chroma 检索、RAG 流水线——模型"翻手册"回答你的文档。 **第 08\~09 篇：会记住、会干活、会转弯。** 记忆（历史 + session\_id）、工具（@tool）、Agent（create\_agent 自动循环）、LangGraph（自己搭图、checkpointer 持久化记忆）。 **第 10\~12 篇：懂地基。** AIMessage 统一信封、模板管结构不管内容、langchain\_core 三层架构。 **第 13 篇：能实战。** 真实政策文件走全流程。

他想起老王最后说的话："小林，你现在不是'会用 LangChain 了'，是'知道 LangChain 为什么长这样了'。这两者的区别，就是以后遇到新问题，你是去查文档，还是能自己推断出答案。"

小林笑了笑，把最后一句话写在工位贴纸上：

> **LangChain 三层架构：协议层（langchain\_core 定义接口）→ 实现层（集成包连具体服务）→ 编排层（langgraph 管流程/状态/记忆）。** 你学的是这三层怎么配合——换模型改实现层，加能力改协议层，改流程改编排层。**这就是全部。**

### 验证清单（整个系列的收官测试）

1. 换模型供应商，你的代码要改哪几行？（提示：第 1 篇）
2. 模型为什么"失忆"？怎么让它记住？（提示：第 4 篇）
3. 用户问"工作满一年能休几天"，关键词搜索为什么漏？（提示：第 8 篇）
4. RAG 和"把文档塞进 prompt"的本质区别？（提示：第 9 篇）
5. create\_agent 返回的是什么类型？内部长什么样？（提示：第 11 篇）
6. 三份协议（OpenAI/Anthropic/Responses）的取文本方式分别是什么？（提示：第 2 篇）
7. create\_agent 内部用 ChatPromptTemplate 吗？为什么？（提示：第 3 篇）
8. langchain\_core 的 29 个模块里，你天天用的 10 个是哪些？（提示：第 12 篇）
9. 多份文档共存一个向量库，怎么组织？（提示：第 13 篇）

小林的故事到这里**还差一步**：老板看了演示，眼睛一亮——"做成接口，让全公司的人都能问。"

小林愣住了。他是 Java 后端出身，写 Spring Boot 时从没操心过线程池——那是 Tomcat 的事。现在用 Python 暴露 API，他得从头想：**Python 后端自带线程池吗？langchain\_core 自己会发 HTTP 请求，怎么和后端框架配合？**——[翻到第 18 篇：后端接口，把链变成 API](/doc/doc/narrative-course/18-后端接口)。
