# 使用LangChain+LangGraph开发AI应用（6） - 检索增强RAG


![](https://static.xiongneng.me/langchain-langgraph-cover-20261017175843.png)

## 学习进度

模型有两个绕不开的限制。一是上下文有限，再大的窗口也塞不下整个资料库。二是知识过期，训练数据停在某个时间点，之后的它一概不知。检索增强，RAG，就是冲着这两个限制去的，回答问题之前先把相关资料找出来给它。

本篇按官方文档的检索流水线走一遍，加载、分割、嵌入、入库、检索、生成，六步每步都有对应组件。示例在 `source/hello_rag.py`，九个 demo。完整案例是一个能用的命令行小工具 `source/kb.py`，把 markdown 笔记吃进本地向量库，随时提问，回答带出处。

## 前言用途

模型直接回答知识类问题的样子你大概见过，问它你家项目的接口约定，它一本正经地编。上下文窗口再大也有价，每轮对话都把全部资料塞进去，token 账单先扛不住。RAG 的思路是平时把资料切好块、算好向量存起来，问题来了只检索最相关的几块，带着这几块去问模型。资料存库几乎不花钱，每次回答只带几段相关内容进上下文。

什么时候需要 RAG，一句话判断，答案在你的资料里而不在模型的训练数据里。公司内部文档、个人笔记、产品手册、聊天记录，都是典型场景。答案在模型训练数据里的通用问题，直接问就行，硬套 RAG 反而多此一举。

LangChain 把这条流水线的每个环节都做成了可替换的组件，加载器有上百种，分割器按需选，嵌入模型换个名字就能换提供商，向量库从内存版到专业数据库一条线排开。本篇用零部署的组合把整条链跑通，你换任何一环，其他代码都不用动。

## 原理概念

RAG 有两种常见架构。两步式，检索永远发生在生成之前，先查资料再回答，简单可控，绝大多数问答场景够用。Agentic 式，检索被包成工具交给 agent，模型自己决定查不查、查什么、查几轮，灵活但延迟不可预期。官方文档的对比表把控制力和灵活性摆在天平两端，我的建议是先用两步式，检索质量不够了再上 agent。两步式是本篇主线，Agentic 放在核心用法最后讲。

流水线上所有环节共同认的是 `Document`，LangChain 用它统一承载各种来源的文本。一个 Document 就两个字段，`page_content` 装文本本身，`metadata` 装来源、页码、时间这类信息。加载器把 PDF、网页、数据库行都变成 Document 列表，后面的环节只认这个形状，来源是什么根本不重要。

![](https://static.xiongneng.me/rag-pipeline-20261017175843.png)

嵌入是把文本变成向量的过程，语义相近的文本向量也相近。`Embeddings` 接口只有两个方法。`embed_documents` 在入库时给文档批量算向量。`embed_query` 在提问时给问题算向量。同一个问题两种方法算出的向量必须落在同一个语义空间里，这是检索有效的前提。向量入库后，检索就是算余弦相似度，找出跟问题向量最接近的几块。

向量库是存向量并支持相似度检索的仓库。LangChain 的 `InMemoryVectorStore` 跑在内存里，零部署，`dump` 和 `load` 还能把库落成 JSON 文件。数据量大再换 FAISS、Milvus 这类专业选手，接口是同一套。

## 核心用法

九个用法，示例在 `source/hello_rag.py`。嵌入有两个来源，设了 YI 环境变量就用真实嵌入（EMBED_MODEL 指定，默认 `text-embedding-3-small`），没设就用本地的哈希替身，同样的文本永远得到同样的向量，测试可复现。

### 1. Document 的本质

先看它长什么样。

```python
from langchain_core.documents import Document

doc = Document(
    page_content="LangChain 用 Document 统一承载各种来源的文本。",
    metadata={"source": "notes.md", "page": 3},
)
print(doc.page_content, doc.metadata)
```

```text
== Document ==
  内容: LangChain 用 Document 统一承载各种来源的文本。
  元数据: {'source': 'notes.md', 'page': 3}
```

### 2. 加载

加载环节做的事就一句话，把任何来源变成 Document 列表。生态里有上百种现成加载器，TxtLoader、PDF 加载器、Notion 加载器，都是一个套路。自己写一个也就几行。

```python
from pathlib import Path

def load_text(path: Path) -> list[Document]:
    text = Path(path).read_text(encoding="utf-8")
    return [Document(page_content=text, metadata={"source": str(path)})]

docs = load_text(Path("notes.txt"))
```

```text
== 加载 ==
  notes.txt -> 1 个 Document，374 字符
```

### 3. 分割

一整个文件直接入库，检索粒度就太粗了，召回一大段里真正相关的只有两句，token 全浪费在路上。分割用 `RecursiveCharacterTextSplitter`，按分隔符列表递归地切，先按段落切，超长了再按句子切。

```python
from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=90, chunk_overlap=15,
    separators=["\n\n", "\n", "。", "，"],
)
docs = splitter.create_documents([text])
```

```text
== 分割 chunk_size=90 overlap=15 ==
  374 字符 -> 7 块
  块 1（16 字符）: LangChain 安装与初始化 ...
  块 2（56 字符）: 安装 LangChain 用 pip install ...
  块 3（37 字符）: 装完建议先跑一个 init_chat_model 的 ...
```

`chunk_overlap` 让相邻块共享一小段文字，句子正好骑在切分点上时，两边都能保住完整语义。注意块 1 只有十六个字符，它是个孤零零的标题，标题和正文被切开了，这种碎块的嵌入信息量太稀，把块放大或者入库前把标题并进正文都能解决。

### 4. 嵌入

`Embeddings` 接口就两个方法，各管一头。

```python
emb = build_embeddings()   # 真实嵌入或本地替身，约定见 demo 源码
vectors = emb.embed_documents(["北京今天晴", "上海多云"])
query = emb.embed_query("天气怎么样")
```

```text
== 嵌入 ==
  OpenAIEmbeddings: 文档 2 条，每条 1536 维；问题向量 1536 维
```

`embed_documents` 是入库时批量算向量用的，`embed_query` 是每次提问算问题向量用的。两个方法背后是同一个模型，换嵌入模型等于换语义空间，这件事的坑后面有专条。

### 5. 向量库

`InMemoryVectorStore` 从 Document 列表一步建库，`dump` 和 `load` 还能把库落成 JSON 文件带走。

```python
from langchain_core.vectorstores import InMemoryVectorStore

store = InMemoryVectorStore.from_documents(docs, emb)
store.dump("kb_store.json")

reloaded = InMemoryVectorStore.load("kb_store.json", embedding=emb)
```

```text
== 向量库 ==
  库内条目: 2
  落盘重载后条目: 2
```

库内部的结构是个字典，每一项带 `id`、`vector`、`text`、`metadata` 四个键，dump 出来的 JSON 就是这个形状，纯文本可查，排查问题时很方便。

### 6. 相似检索

接下来是检索环节，RAG 里最值得花心思的一步。`similarity_search` 返回 Document 列表，`similarity_search_with_score` 附带相似度。

```python
store = InMemoryVectorStore.from_texts(
    ["今天北京天气晴朗。", "上海明天有雨。", "Python 是一门编程语言。"], emb,
)
hits = store.similarity_search("今天北京天气晴朗。", k=2)
scored = store.similarity_search_with_score("今天北京天气晴朗。", k=1)
```

```text
== 相似检索 k=2 ==
  命中: 今天北京天气晴朗。
  命中: 上海明天有雨。
  首位相似度: 1.0000（1.0 表示完全一致）
```

两个天气句子排前面，编程语言那句被排到最后，语义检索干了关键词匹配干不了的活。`with_score` 的第二个值是相似度，1.0 表示完全一致，数值越大越相关。网上不少文章把它当距离讲，方向反了，照着抄会把过滤条件写反。

### 7. 检索器

`as_retriever` 把向量库包成统一接口，后面换向量库，检索代码不用动。参数经 `search_kwargs` 透传。

```python
retriever = store.as_retriever(search_kwargs={"k": 2})
hits = retriever.invoke("LangChain 提供模型抽象。")

mmr = store.as_retriever(search_type="mmr", search_kwargs={"k": 2, "fetch_k": 3})
hits2 = mmr.invoke("LangChain 提供模型抽象。")
```

```text
== MMR 检索 ==
  命中: LangChain 提供模型抽象。
  命中: 今天的午餐是面条。
```

库里两条 LangChain 的句子只回来了第一条，另补了一条午餐句子。这就是 MMR 的作用，在相关性和多样性之间做平衡，避免 top-k 全是同一句话的变体。资料来源多、内容重复度高时，MMR 比纯相似度实用。

### 8. 两步式 RAG

把检索结果拼进提示词，让模型只依据料回答。

```python
question = "装 LangChain 对 Python 版本有什么要求？"
context_docs = store.similarity_search(question, k=1)
context = "\n".join(d.page_content for d in context_docs)

prompt = (
    "只依据下面的资料回答问题，资料里没有就直说不知道。\n\n"
    f"资料：\n{context}\n\n问题：{question}"
)
answer = model.invoke([SystemMessage("你是简洁的问答助手"), HumanMessage(prompt)])
```

```text
== 两步式 RAG ==
  检索到 1 块: 安装 LangChain 需要 Python 3.9 以上版本。
  回答: 根据笔记，Python 3.9 以上版本即可安装 LangChain。
```

提示词里那句「资料里没有就直说不知道」是防幻觉的关键，别省。RAG 系统胡说八道，一半的锅在这句缺失上。

### 9. Agentic RAG

两步式是无脑先查再答，Agentic 把检索器包成工具，查不查由模型自己决定。

```python
from langchain_core.tools import create_retriever_tool

retriever = store.as_retriever(search_kwargs={"k": 1})
search_tool = create_retriever_tool(
    retriever, "search_notes", "搜索 LangChain 学习笔记，返回相关段落",
)
agent = create_agent(
    model=model, tools=[search_tool],
    system_prompt="回答问题前先查笔记，只依据笔记内容作答。",
)
result = agent.invoke({"messages": [HumanMessage("装 LangChain 要什么版本的 Python？")]})
```

```text
== Agentic RAG ==
  模型发起检索: search_notes({'query': 'Python 版本要求'})
  工具返回: 安装 LangChain 需要 Python 3.9 以上版本。
  回答: 根据笔记，安装 LangChain 需要 Python 3.9 以上版本。
```

第五篇提过的 `create_retriever_tool` 在这里用上了，检索器包成工具后，agent 的完整循环、错误处理、中间件全都对它生效。闲聊不用查资料的问题，模型直接回答不调工具，token 就省下来了。

## 完整案例

九个 demo 摊开讲完了。收个尾，把它们装进一个能日常用的命令行工具，`kb.py`，个人知识库的雏形。它吃 markdown 和 txt 笔记，建本地向量库，之后随时提问，回答附带每条引用的来源和相似度。

```bash
uv run python kb.py ingest rag_notes/
```

```text
吃进 2 个文件，切成 2 块，库里现在有 2 块
```

ingest 读目录下所有 txt 和 md，按 `chunk_size=200, overlap=30` 分割，嵌入后存进本地 JSON 文件。list 命令看库存。

```bash
uv run python kb.py list
```

```text
库内共 2 块
  1. [basics.md] LangChain 安装  安装 LangChain 用 pip insta
  2. [graph.md] LangGraph 要点  LangGraph 用节点和边组织流程，条件边让
```

ask 是主功能，检索 top-k 拼提示词，模型只依据料回答。

```bash
uv run python kb.py ask "LangChain 怎么安装"
```

```text
问题: LangChain 怎么安装
检索到 2 块:
  (0.620) [basics.md] LangChain 安装  安装 LangChain 用 pip
  (0.307) [graph.md] LangGraph 要点  LangGraph 用节点和边组织流程，条件边让
回答: 根据笔记，安装 LangChain 用 pip install langchain，需要 Python 3.9 以上版本。
```

basics.md 的相似度 0.620，明显高于 graph.md 的 0.307，检索排序和人的直觉一致。接上真实模型再问一个需要判断的问题。

```bash
AGENT_MODEL=openai:deepseek-v4.1-flash uv run python kb.py ask "LangGraph 的条件边是干嘛的" --k 2
```

```text
问题: LangGraph 的条件边是干嘛的
检索到 2 块:
  (0.626) [graph.md] LangGraph 要点  LangGraph 用节点和边组织流程，条件边让
  (0.257) [basics.md] LangChain 安装  安装 LangChain 用 pip
回答: 根据资料，LangGraph 的条件边用于让流程按状态分叉。
```

相关性排序正确，回答贴着资料走。整个案例不含任何数据库服务，落盘就是一个 JSON 文件，拷到别的机器 load 回来接着用。

测试在 `tests/test_hello_rag.py` 十条加 `tests/test_kb.py` 十三条，分割边界、相似度语义、落盘往返、ingest 与 ask 流程都钉住了。加上前五篇的，全仓九十四条，全绿。

## 源码

[https://github.com/yidao620c/xneng-langchain-langgraph](https://github.com/yidao620c/xneng-langchain-langgraph)

## 参考

- [LangChain 官方文档 - Retrieval](https://docs.langchain.com/oss/python/langchain/retrieval)
- [langchain-text-splitters - RecursiveCharacterTextSplitter](https://docs.langchain.com/oss/python/langchain/text-splitters)
- [LangChain 官方文档 - Agentic RAG](https://docs.langchain.com/oss/python/deepagents/retrieval)


---

> 作者: XiongNeng  
> URL: https://xiongneng.me/posts/ai/langchain/langchain-langgraph-06-rag/  

