第 14 篇我们攒下两样零件:能把文本变成向量的 embedding 模型,和能按相似度取回结果的 Chroma 向量库。零件单独看都不难,这一篇把它们和 LLM 串成一台整机——RAG(Retrieval-Augmented Generation,检索增强生成):提问时先从你自己的文档里检索相关片段,再让模型依据片段作答。企业知识库问答、产品文档助手、法规条款查询,内核几乎都是这条流水线;它也是大多数人第一个真正上生产的 LLM 应用形态——不训练模型、不动权重,知识库一更新立刻生效。
本篇从零串完整条链:加载、切分、嵌入、索引、检索、生成,六步每步都有最小可运行代码,基线是 langchain-core 1.6 与 langchain-chroma(2026-10)。全文按"全景 → 加载 → 切分 → 索引与检索 → 生成 → 边界 → Agent 之路"组织,跑完你会得到一条能对自己技术笔记提问的问答链。
1、RAG 全景图
先回答"为什么需要 RAG"。模型的知识停在下一次训练之前,你上周写的架构评审、内部 API 文档它一无所知;硬把资料塞进 prompt 又受上下文窗口与成本约束,而且每次提问都要重塞一遍。RAG 的思路是把"知道什么"从模型里挪出来,放进一个可检索的知识库,回答时现查现用——相当于给模型外挂了一份随时可更新的记忆。
整条流水线六步,一张图看全:
离线建库(只做一次)
① 加载 ─ 文件 / PDF / 网页 → Document 列表
② 切分 ─ Document → chunk 列表
③ 嵌入 ─ 每个 chunk 算出一个向量
④ 索引 ─ 向量连同原文写进向量库
在线问答(每次提问)
⑤ 检索 ─ 问题同样嵌入,取回 top-k 相似 chunk
⑥ 生成 ─ chunk 塞进 prompt,LLM 依据资料作答
解读这张图的关键是分界线:①~④ 是离线阶段,做完一次就完事(文档更新时重跑);⑤~⑥ 是在线阶段,每次提问都要跑。第 14 篇讲的嵌入与相似度正是 ③、⑤ 两步的内核,本篇补齐其余四步,最后把六步焊成一台机器。
2、文档加载
一切从 loader 开始。LangChain 的约定是所有 loader 暴露同一个入口 .load(),返回 Document 列表;Document 的关键字段只有两个——page_content 装正文,metadata 装来源信息,后面检索结果能"溯源到文件与页码"全靠它。常用三件套如下:
| 加载器 | 输入 | 输出粒度 | 额外依赖 |
|---|---|---|---|
TextLoader |
本地文本 / Markdown | 整个文件一个 Document | 无 |
PyPDFLoader |
PDF 文件 | 每页一个 Document | pypdf |
WebBaseLoader |
网页 URL | 整页正文一个 Document | beautifulsoup4 |
三个 loader 都住在 langchain-community,依赖一次装齐:uv add langchain-community langchain-text-splitters langchain-chroma pypdf beautifulsoup4 requests。最简单的 TextLoader 三行跑通:
from langchain_community.document_loaders import TextLoader
docs = TextLoader("notes/python-async.md", encoding="utf-8").load()
print(len(docs)) # 1:整个文件就是一个 Document
print(docs[0].metadata) # {'source': 'notes/python-async.md'}
解读:整份文件进一个 Document,正文原样保留,切分是下一节的活。Windows 上读中文文件务必带 encoding="utf-8",默认编码碰上 UTF-8 中文会直接 UnicodeDecodeError。PDF 换 PyPDFLoader:
from langchain_community.document_loaders import PyPDFLoader
docs = PyPDFLoader("docs/agent-whitepaper.pdf").load()
print(len(docs)) # 42:一页一个 Document
print(docs[5].metadata["page"]) # 5:页码进了 metadata
解读:按页产出是刻意设计——页是天然的引用单位,答案能定位到"第几页"。注意它抽的是 PDF 的文本层,扫描件抽不出内容,得先过 OCR,这里不展开。网页用 WebBaseLoader:
from langchain_community.document_loaders import WebBaseLoader
url = "https://lilianweng.github.io/2023/06/23/agents/"
docs = WebBaseLoader(url).load()
print(len(docs[0].page_content)) # 正文长度,HTML 标签已被剥掉
解读:内部用 BeautifulSoup 抽正文。网页加载的主要敌人是噪声——导航栏、评论区、推荐位都会混进 page_content 污染检索,必要时通过 bs_kwargs 传 BeautifulSoup 的过滤参数只取正文区域。各加载器的完整参数见 TextLoader 参考。
3、切分策略
加载只是搬运,切分才见功夫。模型并不直接"读"你的文档——它只看得到检索回来的几块;检索的匹配单位是 chunk,生成的上下文单位也是 chunk。切分决定了"需要一起被看到的内容是否落在同一块里",RAG 效果的方差大头不在模型,在切分。
3.1、chunk 参数与分隔符
核心就两个参数:
| 参数 | 语义 | 经验起点 |
|---|---|---|
chunk_size |
每块的目标长度,默认按字符计 | 300~1200,看问答粒度 |
chunk_overlap |
相邻块的重叠长度 | chunk_size 的 10%~20% |
通用文本、代码、中文三种典型配置一次看全:
from langchain_text_splitters import (
Language, RecursiveCharacterTextSplitter)
plain = RecursiveCharacterTextSplitter(
chunk_size=500, chunk_overlap=50) # 默认分隔符 ["\n\n", "\n", " ", ""]
code = RecursiveCharacterTextSplitter.from_language(
Language.PYTHON, # 预置各语言的代码分隔符层级
chunk_size=800, chunk_overlap=100)
zh = RecursiveCharacterTextSplitter(
separators=["\n\n", "\n", "。", ";", ",", " ", ""],
chunk_size=500, chunk_overlap=50) # 中文:把中文标点补进层级
chunks = zh.split_documents(docs) # 块继承 metadata,溯源不丢
解读前两件事。其一,"递归"指分隔符是一张优先级列表:先按段落(\n\n)切,切出的段仍超长才降级到行(\n)、再到空格——能沿自然边界断开就绝不拦腰截断,让每块尽量是完整的语义单元。其二,chunk_overlap 把跨块的句子在两边各留一份,检索命中哪边都拿得到完整上下文,代价是索引体积与生成 token 变大,所以经验值压在 10%~20%。
再看代码与中文的差异。代码要用 from_language:它给 Python 预置了按 class、def 断开的层级,检索回来的是整个函数而不是半截函数,对模型友好得多。默认分隔符则不含任何中文标点,超长的中文段落会一路降级到空字符串、被逐字符硬切,语义稀碎——中文知识库务必像上面那样补上"。;,"层级。另外长度默认按字符数(len),想按 token 切可以包一层 from_tiktoken_encoder;中文一个字大致一个 token 量级,按字符控制就够用。细节见文本切分文档。
3.2、chunk 大小对比实验
参数语义好懂,难的是选值。做一个五分钟实验:同一篇笔记分别按 200 和 1000 切分、各建一个索引(嵌入模型用第 14 篇的老朋友,Chroma 的细节下一节展开,先当黑盒),问同一个细节问题,看两边的 top-1 检索回什么:
from langchain.embeddings import init_embeddings
from langchain_chroma import Chroma
embeddings = init_embeddings("openai:text-embedding-3-small")
def build(size, overlap):
s = RecursiveCharacterTextSplitter(
chunk_size=size, chunk_overlap=overlap)
return Chroma.from_documents(
s.split_documents(docs), embeddings,
collection_name=f"cs{size}",
persist_directory=f"./db-{size}") # 两个索引分开落盘
small, large = build(200, 40), build(1000, 200)
q = "asyncio.wait 的 return_when 有哪些取值?"
for tag, vs in [("200", small), ("1000", large)]:
top = vs.as_retriever(search_kwargs={"k": 1}).invoke(q)[0]
print(tag, len(top.page_content), repr(top.page_content[:40]))
输出:
200 186 'return_when 可取 FIRST_COMPLETED、FIRST_EXCEPTION、'
1000 921 'asyncio.wait 接受一组任务,通过 timeout 参数控制'
两边都召回了正确内容,但性质不同。200 的 top-1 就是讲参数取值的那一段,186 字几乎全是干货,问题与块的相似度信号非常强;1000 的 top-1 是 921 字的大块,答案也在里面,但同块还混着超时控制、异常传播两节内容——chunk 的向量是整块语义的"平均值",主题一杂,与问题的相关度就被稀释,排序容易往下滑。反过来问"这篇笔记整体讲了什么",胜负反转:200 的 top-4 拼起来不到 800 字还东一块西一块,生成端只见树木;1000 的 top-1 就装下了引言加小结。
结论一句话:chunk_size 是检索精度与上下文完整性的交换——细节问答往 300~500 靠,综述理解往 800~1200 靠。没有万能值,正确姿势是拿自己的真实问题集跑一轮对比再定。
4、索引与检索
切分之后,chunk 列表要变成可查询的库。上一节当黑盒用的两行,这里拆开讲:
from langchain_chroma import Chroma
from langchain.embeddings import init_embeddings
embeddings = init_embeddings("openai:text-embedding-3-small")
vectorstore = Chroma.from_documents(
chunks, # 切好的 chunk 列表
embedding=embeddings, # 嵌入模型,算向量全靠它
collection_name="my_notes", # 集合名,一个目录可放多套知识
persist_directory="./kb") # 落盘目录,进程重启不丢
retriever = vectorstore.as_retriever(search_kwargs={"k": 4})
hits = retriever.invoke("协程里怎么安全地取消任务?")
from_documents 一步干两件事:对每块调一次嵌入模型,再把向量、原文、metadata 一起写进 Chroma。persist_directory 指定后索引落盘,下次启动不必重建,直接 Chroma(collection_name="my_notes", persist_directory="./kb", embedding=embeddings) 挂载——注意挂载必须传建库时的同一个嵌入模型,向量维度对不上会直接报错。as_retriever 把库转成检索器,search_kwargs={"k": 4} 表示每次取相似度前 4 块;嫌四块内容雷同可以换 search_type="mmr",细节留给第 16 篇。用法见 Chroma 集成文档,嵌入模型入口见 init_embeddings 参考。
invoke 回来的是什么?一个按相似度降序的 Document 列表:
>>> len(hits)
4
>>> hits[0].page_content[:50]
'取消协程的标准姿势是 task.cancel():向协程抛出 Cancel'
>>> hits[0].metadata["source"]
'notes/python-async.md'
检索原理第 14 篇已拆过:问题同样被嵌入成向量,再与库中所有块向量比相似度。这里值得多看一眼的是 metadata——每块都带着 source,意味着每个答案都能回答"这句话出自哪个文件"。没有溯源就没有信任,这是 RAG 可用的底线。
5、拼装问答链
最后一步:把检索结果喂给模型。姿势是第 4 篇的 ChatPromptTemplate,加一个 context 变量装资料:
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
from langchain.chat_models import init_chat_model
def format_docs(docs):
# Document 列表拍平成纯文本,块间空行分隔
return "\n\n".join(d.page_content for d in docs)
prompt = ChatPromptTemplate.from_messages([
("system",
"你是技术文档助手,仅依据下面的资料回答问题;"
"资料里没有的内容就直说不知道,不要编造。\n\n资料:\n{context}"),
("human", "{question}"),
])
model = init_chat_model("openai:gpt-5")
rag_chain = (
{"context": retriever | format_docs, "question": RunnablePassthrough()}
| prompt | model | StrOutputParser()
)
print(rag_chain.invoke("协程里怎么安全地取消任务?"))
逐个拆。链的第一步是个字典,而字典本身也是合法 Runnable,相当于一个并行分发器:context 键接 retriever | format_docs,把检索回的 Document 列表拍平成纯文本;question 键用 RunnablePassthrough() 把输入字符串原样放行。两路结果填进模板变量后,依次过模型与 StrOutputParser(输出从 AIMessage 变成纯字符串)。这套 | 拼装就是第 6 篇的 LCEL,没有新语法。
系统提示词里那两句是 RAG 的纪律条款:"仅依据资料""没有就说不知道",是压幻觉的第一道闸——模型被明确授权拒答,比任何参数调优都有效。跑一下:
取消协程的标准做法是 task.cancel():它向协程内部抛出 CancelledError,
协程应捕获该异常并完成资源清理,最后 await task 确认其真正退出。
资料未涉及的其他方式,这里不作补充。
答案的三个要点——做法、清理、确认——分别来自检索回的不同块;最后一句是纪律条款生效的表现,模型没有顺手展开知识库之外的内容。至此六步闭环:一份本地笔记变成了可追问、可溯源的问答系统,全部代码不到 60 行。
6、好答与难答的问题
流水线跑通只是及格线,工程上更重要的是知道边界。一张表给直觉:
| 问题类型 | 典型问法 | 效果 | 病根 |
|---|---|---|---|
| 事实细节 | "X 的默认值是什么" | 稳 | 答案整段躺在某个 chunk 里 |
| 概览归纳 | "这篇文档讲什么" | 中 | 靠 top-k 拼全景,块偏小时覆盖不全 |
| 跨块关联 | "A 方案和 B 方案差在哪" | 弱 | A、B 分属两块,未必同时被召回 |
| 推理计算 | "按文档的规则算一下" | 弱 | RAG 只递资料,不替模型推理 |
| 实时信息 | "现在最新版本是多少" | 弱 | 知识库是静态快照,不随世界变 |
三个判断。跨块关联答得差,先怀疑切分:把相关内容切进同一块(调大 chunk、补对分隔符)往往立竿见影;切分没问题再怀疑检索,那是第 16 篇混合检索与查询改写的地盘。推理与计算不是 RAG 的活——资料它递到了,账要模型自己算,这类需求应该往 Agent 走。实时性则是数据工程问题:文档变了就重跑离线管道,四步脚本定时一挂,比换什么模型都管用。
判断一个问题适不适合 RAG,先看答案能不能在原文里一段话划出来:能,RAG 稳;要跨段拼、要推算,就得往 Agent 的方向走。
7、从管道到 Agent
回头看这条链,它有个隐含假设:每个问题都必须查库。可是"帮我总结一下上面的对话"也去查笔记,就是白花 token;问题问得含糊,查一次不准,答案跟着歪。下一步进化是让模型自己决策:这个问题要不要查、怎么查、查完不够能不能换个问法再查——把检索从"必经之路"降级成"一件工具"。LangChain 把这扇门开得很轻,create_retriever_tool 一个函数:
from langchain.tools.retriever import create_retriever_tool
search_notes = create_retriever_tool(
retriever,
name="search_notes",
description="在个人技术笔记知识库里检索,返回相关片段。",
)
# 之后:create_agent(model, tools=[search_notes]) —— 第 17 篇展开
retriever 包上工具的皮,就成了第 5 篇以来 Agent 随身携带的普通工具;description 写得越准,模型越知道什么时候该调它。签名细节见 create_retriever_tool 参考。这条从管道到 Agent 的路,正是第 17 篇 Agentic RAG 的全部剧情。
8、总结
这一篇把第 14 篇的零件焊成了整机:加载(loader 统一吐 Document)→ 切分(RecursiveCharacterTextSplitter,质量的隐形决定者)→ 嵌入与索引(Chroma.from_documents 落盘)→ 检索(as_retriever 取 top-k)→ 生成(context 进 prompt,纪律条款压幻觉)。三个值得带走的判断:chunk_size 是检索精度与上下文完整性的交换,拿自己的问题集实验定值;答案能否在一段连续原文里找到,是 RAG 适用性的第一判据;检索降级为工具,就是通往 Agentic RAG 的门。
练习:挑一篇你自己的技术笔记(.md 或 PDF 都行),走完六步建库,写 5 个问题跑 rag_chain——其中 1 个故意问知识库里没有的,看模型是否老实说不知道;再把 chunk_size 换一档,对比 top-4 命中的变化;有余力就把 search_type 换成 mmr,感受结果多样性的差别。
对于想给团队搭知识库问答的同学,建议先把这条朴素流水线跑通、用真实问题把切分参数调一轮,再谈高级技巧——多数"答不准"的问题,朴素 RAG 加对参数就能解决。下一篇第 16 篇:混合检索、Rerank、查询改写与元数据过滤,把检索这一步做精。


