上一篇我们跑通了一个最小 RAG:文档入库 → 向量搜索 → 喂给模型 → 得到回答。能用了,但离"好用"还差得远。这一篇,我们一层一层打磨 RAG 的每个环节,让检索真正靠谱起来。

先搞清楚 RAG 到底在哪儿翻车

上一篇末尾提了几个问题,这里展开说。RAG 翻车基本就三个地方:

  1. 检索不到——文档里明明有答案,但 retriever 没找回来

  2. 检索不准——找回来了,但内容和问题不相关,模型被误导

  3. 答非所问——找回来的内容沾边但不精确,模型硬往上靠

三个问题,根源往往都指向同一件事:chunk 没切好。

RAG 翻车三因素:检索不到、检索不准、答非所问——根源都是 chunk 没切好

为什么这么说?因为 RAG 的检索单位不是"整篇文档",是"chunk"。你怎么切 chunk,直接决定了 retriever 能找到什么、找回来的质量如何。chunk 是 RAG 质量的天花板——后面的 prompt 设计、模型能力再强,也弥补不了前面检索阶段的损失。

所以这一篇先从 chunk 讲起。

为什么要切 chunk?

先回答一个基本问题:为什么不能把整篇文档直接向量化?

两个原因:

第一,向量的"分辨率"问题。 一篇 50 页的文档包含几十个主题,压成一个向量,这个向量能代表什么?什么都代表,就等于什么都不代表。用户问一个具体问题,拿整篇文档的向量去匹配,精度极低。

第二,token 限制。 就算你检索到了整篇文档,塞进 prompt 也放不下。GPT-4 的上下文窗口虽然越来越大,但把 50 页文档全塞进去,成本高、速度慢,而且模型在超长上下文里"找重点"的能力并不可靠。

所以必须切。切成小块,每块单独向量化,搜索时匹配到的是最相关的那几个小块——精度高、token 省。

Text Splitter:LangChain 的切分工具

LangChain 提供了一套 Text Splitter 工具,专门干"切文档"这件事。

最基础的:按字符数切

from langchain_text_splitters import CharacterTextSplitter

text = """LangChain 是一个用于构建 LLM 应用的开发框架。它提供了链式调用、记忆管理、工具使用等核心能力。

RAG 是检索增强生成的缩写。它通过在生成前检索相关文档,让模型能够基于外部知识回答问题。RAG 的核心流程包括:文档切分、向量化、检索和生成。

FAISS 是 Meta 开源的向量检索库。它支持多种索引类型,适合大规模向量搜索场景。"""

splitter = CharacterTextSplitter(
 separator="/n/n",
 chunk_size=100,
 chunk_overlap=20,
)

chunks = splitter.split_text(text)
for i, chunk in enumerate(chunks):
 print(f"--- Chunk {i} ---")
 print(chunk)
 print()

三个关键参数:

  • separator:优先按什么分割。/n/n 表示优先按段落分
  • chunk_size:每个 chunk 的最大长度(字符数)
  • chunk_overlap:相邻 chunk 之间的重叠部分

chunk_overlap 是什么意思?

假设一段连续文本被切成了 chunk A 和 chunk B。如果 overlap=0,A 的结尾和 B 的开头是断裂的——如果一句话恰好跨了两个 chunk,两边各拿到半句,语义就断了。

设个 overlap,让 A 的最后 20 个字符也出现在 B 的开头,这样即使切在句子中间,上下文也能衔接上。

chunk overlap 示意:overlap=0 时语义断裂,overlap=20% 时通过重叠区域保持衔接

chunk A: [xxxxxxxxxx~~~~]
chunk B: [~~~~xxxxxxxxxx]
 ↑ overlap 部分

overlap 不是越大越好——太大意味着大量重复内容,浪费存储和 token。一般设 chunk_size 的 10%~20% 就够了。

更聪明的切法:RecursiveCharacterTextSplitter

CharacterTextSplitter 只能按一种 separator 切,太死板。实际文档的结构是层次化的:先有段落、段落里有句子、句子里有词。

RecursiveCharacterTextSplitter 会依次尝试多种分隔符,从大到小:

from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
 chunk_size=200,
 chunk_overlap=30,
 separators=["/n/n", "/n", "。", ",", " ", ""]
)

chunks = splitter.split_text(text)
for i, chunk in enumerate(chunks):
 print(f"--- Chunk {i} ({len(chunk)} chars) ---")
 print(chunk)
 print()

它的逻辑是:

  1. 先尝试按 /n/n(段落)分割

  2. 如果某段落太长(超过 chunk_size),退而按 /n(换行)分割

  3. 还是太长?按 (句号)分割

  4. 还不行?按 (逗号)、空格、单个字符……

这样的好处是:尽量保持语义完整。能按段落切就不拆句子,能按句子切就不拆到半个词。

RecursiveCharacterTextSplitter 逻辑:依次尝试段落、换行、句号、逗号等分隔符,逐级降级

这是实际项目中最常用的 splitter,推荐作为默认选择。

chunk_size 到底设多大?

这是被问得最多的问题,也是最没有标准答案的问题。但有几条经验规律:

太小(< 100 字符)

每个 chunk 信息量太少,可能只有半句话。检索回来的内容碎片化严重,模型需要拼凑多个 chunk 才能理解完整意思——但它经常拼不好。

太大(> 1000 字符)

一个 chunk 里包含多个主题,向量的"代表性"下降,搜索精度变差。而且每个 chunk 占的 token 多,塞进 prompt 的条数就少。

经验值

场景 chunk_size 建议 说明
FAQ / 短问答 200~400 每条 FAQ 本身就短
技术文档 500~800 一个函数说明或一段配置通常在这个范围
长篇报告 / 论文 800~1500 需要更多上下文才能理解

实际项目建议:先用 500 字符 + 50 overlap 跑个 baseline,然后根据检索效果调整。没有银弹,只有迭代。

处理真实文档:从 PDF 到 chunks

实际项目里你面对的不是字符串数组,而是 PDF、Word、网页。LangChain 提供了一系列 Document Loader 来处理这些格式。

加载 PDF

from langchain_community.document_loaders import PyPDFLoader

loader = PyPDFLoader("company_handbook.pdf")
pages = loader.load()

print(f"共 {len(pages)} 页")
print(pages[0].page_content[:200])

load() 返回的是一个 Document 列表,每个 Document 包含:

  • page_content:文本内容
  • metadata:元数据(页码、来源文件名等)

加载后切分

from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
 chunk_size=500,
 chunk_overlap=50,
)

chunks = splitter.split_documents(pages)

print(f"切分后共 {len(chunks)} 个 chunk")
print(chunks[0].page_content)
print(chunks[0].metadata) # {'source': 'company_handbook.pdf', 'page': 0}

注意用的是 split_documents 而不是 split_text——它会自动保留每个 chunk 的 metadata,方便后续追溯来源(“这个回答来自文档第几页”)。

其他常用 Loader

# 加载 Markdown
from langchain_community.document_loaders import UnstructuredMarkdownLoader
loader = UnstructuredMarkdownLoader("notes.md")

# 加载网页
from langchain_community.document_loaders import WebBaseLoader
loader = WebBaseLoader("https://docs.example.com/api")

# 加载 Word 文档
from langchain_community.document_loaders import Docx2txtLoader
loader = Docx2txtLoader("report.docx")

所有 Loader 的输出格式一样——Document 列表,后面接 splitter 和 vectorstore 的流程完全一致。

更好的检索策略

chunk 切好了,下一个要优化的是"怎么找"。

1. 相似度阈值过滤

默认的 retriever 只按相似度排序,取 top-k。但如果所有文档和问题都不太相关呢?它照样返回 top-k,只不过内容全是噪音。

加一个相似度阈值:

retriever = vectorstore.as_retriever(
 search_type="similarity_score_threshold",
 search_kwargs={
 "score_threshold": 0.7,
 "k": 5,
 }
)

只有相似度超过 0.7 的文档才会被返回。如果没有达标的,返回空列表——这时候你可以让模型直接说"没找到相关信息",比瞎答好得多。

2. MMR:多样性 + 相关性

上一篇简单提过 MMR(Maximum Marginal Relevance)。这里详细说一下它解决什么问题。

假设你有一份文档,里面有 10 段都在讲"用户留存率",措辞略有不同但意思基本一样。用相似度搜索,top-3 可能全是这 10 段里的内容——高度相关但高度重复。

MMR 的做法是:在选择下一条结果时,同时考虑两个因素:

  • 和查询的相关性(越相关越好)
  • 和已选结果的差异性(越不同越好)
retriever = vectorstore.as_retriever(
 search_type="mmr",
 search_kwargs={
 "k": 4,
 "fetch_k": 20, # 先取 20 条候选
 "lambda_mult": 0.7, # 0=完全多样化,1=完全相关性
 }
)

lambda_mult 控制相关性和多样性的权重。0.7 表示以相关性为主,兼顾多样性。大部分场景设 0.5~0.7 就行。

3. 重排序(Reranking)

向量搜索的一个固有问题是:embedding 的语义匹配是"粗粒度"的。两段文本的向量很近,不一定意味着它真的能回答你的问题。

解决办法是加一个 Reranker——先用向量搜索粗筛一批候选(比如 20 条),再用一个专门的排序模型精排,选出最相关的几条。

# 安装 reranker
# pip install cohere

from langchain.retrievers import ContextualCompressionRetriever
from langchain_cohere import CohereRerank

reranker = CohereRerank(
 model="rerank-v3.5",
 top_n=3,
)

retriever = ContextualCompressionRetriever(
 base_compressor=reranker,
 base_retriever=vectorstore.as_retriever(search_kwargs={"k": 20}),
)

results = retriever.invoke("什么是 RAG?")
for r in results:
 print(r.page_content[:100])

流程是:

  1. 向量搜索取 top-20(粗筛)

  2. Reranker 对这 20 条逐一打分,选出 top-3(精排)

  3. 返回的 3 条是真正最相关的

Reranking 对检索质量的提升非常明显,尤其是文档量大、主题复杂的场景。代价是多一次模型调用,延迟会稍微增加。

Reranking 流程:向量搜索粗筛 top-20 → Reranker 精排 → 输出 top-3 最相关文档

多轮对话 + RAG:历史感知检索

上一篇我们把 Memory 和 RAG 合在一起了。但有一个问题被忽略了:用户的追问,直接拿去检索,效果很差。

举个例子:

用户:什么是 RAG?
Bot:RAG 是检索增强生成,它通过……
用户:它和 fine-tuning 有什么区别?

第二个问题"它和 fine-tuning 有什么区别"——"它"指的是 RAG,但 retriever 不知道。它直接拿"它和 fine-tuning 有什么区别"去向量搜索,可能找到一堆 fine-tuning 的内容,但完全丢失了"RAG"这个关键信息。

解决方案:Query Rewriting(查询重写)

在检索之前,先让模型结合对话历史,把用户的问题改写成一个自包含的完整问题:

from langchain_core.prompts import ChatPromptTemplate

rewrite_prompt = ChatPromptTemplate.from_template("""
给定以下对话历史和最新的用户问题,将用户问题改写为一个独立的、包含完整上下文的问题。

对话历史:
{history}

最新问题:{question}

改写后的问题:""")

效果:

原始问题:"它和 fine-tuning 有什么区别?"
改写后:"RAG 和 fine-tuning 有什么区别?"

改写后的问题拿去检索,精度就高多了。

Query Rewriting 流程:用户追问 + 对话历史 → LLM 改写完整问题 → 精确检索

完整实现

from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser

# 查询重写链
rewrite_chain = rewrite_prompt | llm | StrOutputParser()

# 完整的多轮 RAG chain
def get_context(input_dict):
 # 先重写查询
 rewritten = rewrite_chain.invoke({
 "history": input_dict.get("history", ""),
 "question": input_dict["question"],
 })
 # 用重写后的查询去检索
 docs = retriever.invoke(rewritten)
 return "/n/n".join(d.page_content for d in docs)

rag_prompt = ChatPromptTemplate.from_template("""
基于以下上下文回答问题。如果上下文中没有相关信息,请直接说"我没有找到相关信息"。

上下文:
{context}

问题:{question}
""")

chain = (
 {
 "context": get_context,
 "question": lambda x: x["question"],
 }
 | rag_prompt
 | llm
)

这样,即使用户的追问里有"它"、“那个”、"上面说的"这类指代词,系统也能先把问题补全,再去检索。

向量数据库选型

上一篇用的是 FAISS,适合 demo 和小规模数据。真实项目里,你需要考虑更多因素。

方案 特点 适用场景
FAISS Meta 开源,纯内存,速度极快 本地开发、小数据量(< 100 万条)
Chroma 开源,API 友好,支持持久化 中小项目,快速原型
Milvus 开源,分布式,支持百亿级向量 大规模生产环境
Pinecone 托管服务,零运维 不想自建基础设施的团队
Weaviate 开源,支持混合搜索 需要同时做关键词 + 向量搜索

用 Chroma 替换 FAISS(三行代码)

from langchain_community.vectorstores import Chroma

# 创建(会自动持久化到磁盘)
vectorstore = Chroma.from_documents(
 documents=chunks,
 embedding=embeddings,
 persist_directory="./chroma_db",
)

# 下次启动时直接加载,不用重新向量化
vectorstore = Chroma(
 persist_directory="./chroma_db",
 embedding_function=embeddings,
)

Chroma 最大的好处是持久化——FAISS 每次重启都要重新建索引,Chroma 存到磁盘,下次直接加载。对于需要反复更新文档的场景,这点很重要。

一个完整的"生产级" RAG 管道

把前面学的所有优化手段串起来,形成一个相对完整的 pipeline:

生产级 RAG 完整流程:加载文档 → 切分 → 向量化存储 → 检索 → 拼接 Prompt → LLM 生成回答

from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_community.document_loaders import PyPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

# 1. 加载文档
loader = PyPDFLoader("company_handbook.pdf")
pages = loader.load()

# 2. 切分
splitter = RecursiveCharacterTextSplitter(
 chunk_size=500,
 chunk_overlap=50,
)
chunks = splitter.split_documents(pages)

# 3. 向量化 + 存储
embeddings = OpenAIEmbeddings()
vectorstore = Chroma.from_documents(
 chunks, embeddings,
 persist_directory="./chroma_db"
)

# 4. 检索器(MMR + top-4)
retriever = vectorstore.as_retriever(
 search_type="mmr",
 search_kwargs={"k": 4, "fetch_k": 20}
)

# 5. Prompt
prompt = ChatPromptTemplate.from_template("""
你是一个专业的知识库助手。基于以下上下文回答问题。

规则:
- 只基于上下文中的信息回答
- 如果上下文中没有相关信息,直接说"我没有找到相关信息"
- 回答要简洁准确,可以引用上下文中的关键信息

上下文:
{context}

问题:{question}
""")

# 6. 组装
llm = ChatOpenAI()

def format_docs(docs):
 return "/n/n---/n/n".join(
 f"[来源:{d.metadata.get('source', '未知')},第{d.metadata.get('page', '?')}页]/n{d.page_content}"
 for d in docs
 )

rag_chain = (
 {
 "context": retriever | format_docs,
 "question": lambda x: x,
 }
 | prompt
 | llm
)

# 7. 使用
answer = rag_chain.invoke("公司的年假政策是什么?")
print(answer.content)

注意 format_docs 里加了来源信息——这样模型的回答可以带出"根据公司手册第 X 页"这样的溯源,用户更信任。

你刚刚学了什么?(总结)

这一篇信息量比较大,做个梳理:

chunk 策略:切分是 RAG 质量的基础。RecursiveCharacterTextSplitter 是最常用的切分工具,chunk_size 建议从 500 起步迭代。overlap 保持 10%~20%。

检索优化:

  • 相似度阈值过滤——不相关的不要返回
  • MMR——避免重复结果
  • Reranking——粗筛 + 精排,质量大幅提升

多轮对话:用 Query Rewriting 解决追问中的指代问题,让每次检索都基于完整的查询。

向量数据库:从 FAISS 升级到 Chroma 或其他方案,支持持久化和更大规模的数据。

核心认知:RAG 的效果取决于整个 pipeline 的最短板。切分、检索、prompt 设计——每个环节都值得花时间优化。不要只盯着模型能力,前面的"数据准备"和"检索质量"往往更决定最终效果。

下一步去哪

到这里,你的 bot 已经具备了两项核心能力:Memory(记忆)和 RAG(知识)。它能记住你说了什么,也能查到它本来不知道的东西。

但它还是只能"回答问题"——你问它天气,它答不了;你让它发邮件,它做不到;你要它查数据库,它不会。

它缺的是"动手能力"——Tool Use(工具调用)。

下一篇,我们给 bot 装上"手和脚",让它不只是回答问题,而是真正能帮你做事。


下一篇:Tool Use——让你的 bot 学会调用工具,从"能说"变成"能做"


本文对应的 GitHub 源码地址如下,复制到浏览器打开即可:

https://github.com/ailifetime/agentLearn/tree/main/lessons/lesson-04-rag-advanced

这里给大家精心整理了一份全面的AI大模型学习资源包括:AI大模型全套学习路线图(从入门到实战)、精品AI大模型学习书籍手册、视频教程、实战学习、面试题等,资料免费分享

👇👇扫码免费领取全部内容👇👇
在这里插入图片描述

1. 成长路线图&学习规划

要学习一门新的技术,作为新手一定要先学习成长路线图方向不对,努力白费

这里,我们为新手和想要进一步提升的专业人士准备了一份详细的学习成长路线图和规划。可以说是最科学最系统的学习成长路线。
在这里插入图片描述

2. 大模型经典PDF书籍

书籍和学习文档资料是学习大模型过程中必不可少的,我们精选了一系列深入探讨大模型技术的书籍和学习文档,它们由领域内的顶尖专家撰写,内容全面、深入、详尽,为你学习大模型提供坚实的理论基础(书籍含电子版PDF)

在这里插入图片描述

3. 大模型视频教程

对于很多自学或者没有基础的同学来说,书籍这些纯文字类的学习教材会觉得比较晦涩难以理解,因此,我们提供了丰富的大模型视频教程,以动态、形象的方式展示技术概念,帮助你更快、更轻松地掌握核心知识

在这里插入图片描述

4. 2026行业报告

行业分析主要包括对不同行业的现状、趋势、问题、机会等进行系统地调研和评估,以了解哪些行业更适合引入大模型的技术和应用,以及在哪些方面可以发挥大模型的优势。

5. 大模型项目实战

学以致用 ,当你的理论知识积累到一定程度,就需要通过项目实战,在实际操作中检验和巩固你所学到的知识,同时为你找工作和职业发展打下坚实的基础。

在这里插入图片描述

6. 大模型面试题

面试不仅是技术的较量,更需要充分的准备。

在你已经掌握了大模型技术之后,就需要开始准备面试,我们将提供精心整理的大模型面试题库,涵盖当前面试中可能遇到的各种技术问题,让你在面试中游刃有余。

在这里插入图片描述

7. 资料领取:全套内容免费抱走,学 AI 不用再找第二份

不管你是 0 基础想入门 AI 大模型,还是有基础想冲刺大厂、了解行业趋势,这份资料都能满足你!
现在只需按照提示操作,就能免费领取:

👇👇扫码免费领取全部内容👇👇
在这里插入图片描述

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐