AI和LLM工程实战到了RAG这一块,最容易被高估的不是模型能力,而是“把文档塞进知识库之后能不能稳定拿到答案”。这个GenAI、RAG完整指南的第三部分,我的理解就是把重点从“跑通Demo”挪到“处理真实文档”:文档加载、切分、向量检索、Prompt拼接、答案校验,以及最后怎么用指标判断知识库到底好不好用。
如果你已经知道什么是LLM,也见过几个RAG示例,现在想自己搭一套能够处理PDF、Word、Markdown、HTML网页内容的问答系统,这篇内容会比较合适。我不打算只贴一张架构图,而是按实际落地顺序把链路拆开:先看环境选型,再看文档解析,然后讲检索质量指标,最后给一套批量任务和生产化的参考流程。你可以把这篇当作一个“从能跑到能用”的经验记录。
1. 先确认第三部分要解决的核心问题:RAG离可用还差在哪
1.1 RAG不是“向量库+ChatGPT”这么简单
很多人在第一次搭RAG时,会把注意力放在用什么模型、用哪个向量库,以为只要“LLM + Embedding + 向量数据库”三个组件连起来,知识库问答就能用了。实际跑下来你会发现,真正影响结果的往往是那些不起眼的环节:文档加载完不完整、切分粒度是否合理、检索TopK设置多大、Prompt里怎么把检索结果塞进去、最终答案要不要做校验。
RAG解决的问题是“让LLM具备外部知识”:模型不记得你公司内部文档,也不记得某份协议的具体条款,所以需要先检索出相关片段,再让模型基于这些片段生成回答。这句话说起来容易,但“检索出相关片段”本身就可能出问题:片段切太碎,上下文语义断掉;切太大,向量编码时内容太杂,检索精度下降;检索出来的片段排在前面的未必是真正能回答问题的内容。
第三部分真正该练的,是把这个链路每一层都验证一遍。先能用,再稳定,最后再谈批量。
1.2 适合谁看,需要哪些前置基础
这篇内容更适合已经具备以下条件的人:
- 知道LLM的基本概念,能调用大模型接口或本地部署模型。
- 写过至少一个最简单的RAG Demo,哪怕只是用框架把文档灌进向量库。
- 手头有真实文档需要做问答,例如产品手册、设计方案、协议条款、论文、课程资料。
如果完全没有接触过LLM,建议先把“什么是Token”“什么是Embedding”“什么是上下文窗口”这几个概念补上,再来读RAG实战。否则很多参数调整会找不到方向。
还有一点容易被忽略:RAG不是一门纯算法课,它很依赖工程能力。不会调试代码没关系,但至少要有耐心看日志、逐段打印中间结果、手动检查解析出来的文本。很多时候问题不是模型不行,而是文档解析后的文本本身就是乱的。
1.3 一套完整RAG链路有哪些环节
在进入具体操作前,先把链路画清楚。一个常规的RAG系统至少包含以下几个环节:
- 文档加载:从PDF、Word、Markdown、HTML、TXT等来源读取内容。
- 文档解析与清洗:去页码、去页眉页脚、修正表格、处理扫描件OCR。
- 文本切分:把长文档切成适合向量化的Chunk,设置合适的重叠度。
- Embedding:将每个Chunk转成向量并写入向量库。
- 用户查询处理:必要时对问题做改写、扩展,比如把口语化问题转成检索友好的描述。
- 相似度检索:从向量库召回TopK个相关片段。
- 上下文组装:把片段和对话历史拼进Prompt,控制Token长度。
- 生成与验证:LLM生成答案,必要时检测答案是否引用了检索内容,避免编造。
很多教程把第1步到第4步叫作“知识库构建”,把第5步到第8步叫作“问答链路”。实际上这两段是连在一起的,任何一段出现问题,最终答案都会偏差。后面所有排查,也都是围绕这个链条逐层找问题。
我比较建议在刚开始搭建时,就把这8个步骤分别做成函数或独立脚本。这样每跑一步都能检查输出。否则所有代码混在一起,出了错很难快速定位是哪一层的问题。
2. 环境与选型:LLM、Embedding、向量库怎么搭不踩坑
2.1 本地还是API:先回答三个问题
先不要急着装组件,先确认自己的运行条件。环境选择其实只需要回答三个问题:数据是不是敏感、有没有GPU、你是不是需要长期跑批量任务。
如果数据敏感,比如公司内部协议、未公开文档,建议优先考虑本地模型。本地方案通常用量化后的开源模型,在显存8GB到16GB的机器上可以跑。如果只是学习,用API方案最省事,不用研究显卡驱动、CUDA版本、推理框架这些杂事。
我的习惯是:学习阶段用API模型,因为会把复杂问题暴露在“检索质量”上,而不是“部署环境”上。等流程稳定后,再根据数据要求把模型切成本地部署。
这里还有一个判断标准:如果你的知识库只有几十篇文档,用户量也不大,API方案完全可以支撑。但如果是生产系统,需要每天处理大量请求,独立部署或自建推理服务会更可控,成本也更可预测。
2.2 向量库选型对比
向量库的选型,取决于数据量和部署方式。可以先按下面这个思路判断:
| 方案 | 适合场景 | 优点 | 注意事项 |
|---|---|---|---|
| FAISS | 单机、百万级以内 | 轻量、Python集成方便 | 不提供服务,适合脚本和内部工具 |
| Chroma | 学习、小批量、本地 | 安装简单,默认持久化 | 高并发和分布式能力较弱 |
| Milvus | 生产、大规模、高并发 | 功能全、支持分布式 | 部署较重,需要单独维护 |
| pgvector | 已有PostgreSQL系统 | 复用数据库,管理统一 | 需要额外装插件,性能在超大向量量级有限 |
如果只是搭一个知识库先跑通,FAISS或Chroma足够。等数据到了几十万条以上,检索速度明显变慢,再迁移到Milvus或pgvector。不用一开始就把架构做重。
这里特别说明一下:向量库不是RAG的“主角”,它只是帮你在海量片段里快速找候选。我见过很多人花大量时间研究向量库性能,结果原始文档解析和切分都没做好。向量库选一个你熟悉的、能持久化的就好,把时间留给更需要关注的上游环节。
2.3 一个最小可运行的RAG链路配置示例
下面给一个最小链路示例。注意这是示例,不是固定标准;版本和依赖要以当前环境为准。重点是你能在配置里看到“文档切分、Embedding、向量库、检索、生成”这几个部分分别在哪里。
# 最小RAG链路示例(伪代码) from langchain_community.document_loaders import TextLoader from langchain.text_splitter import RecursiveCharacterTextSplitter from langchain_community.embeddings import OpenAIEmbeddings from langchain_community.vectorstores import FAISS from langchain.chains import RetrievalQA from langchain_openai import ChatOpenAI # 1. 加载文档 loader = TextLoader("handbook.txt", encoding="utf-8") docs = loader.load() # 2. 切分 splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=50 ) chunks = splitter.split_documents(docs) # 3. 向量化入库 embeddings = OpenAIEmbeddings(model="text-embedding-3-small") vectorstore = FAISS.from_documents(chunks, embeddings) # 4. 检索 + 生成 llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0) qa = RetrievalQA.from_chain_type( llm=llm, retriever=vectorstore.as_retriever(search_kwargs={"k": 4}) ) answer = qa.run("公司的年假规定是什么?") print(answer)这段代码里最值得关注的参数是chunk_size、chunk_overlap和k。它们不是越大越好,也不是越小越好。下面会专门讲。
这里的代码只是一个示例,具体模型名、API地址、依赖版本,必须结合你自己环境里的实际组件。现在很多教程喜欢直接复制一段“能跑”的代码,但如果你用的Embedding模型或向量库不同,参数和调用方式都会有差异。建议先跑通再优化。
2.4 Embedding模型选择:别只盯着大模型
RAG里,LLM负责生成答案,Embedding模型负责判断“文本之间像不像”。很多同学会花大力气选ChatGPT或开源LLM,却随手用一个默认Embedding模型。实际上,Embedding模型的质量直接影响检索命中率。
选择Embedding模型时,我一般会看三个维度:是否支持你文档的语言、是否有足够大的向量维度、在专业领域上的表现。比如处理中文技术文档,就用中文效果更好的Embedding模型;处理英文协议,就用英文为主的模型;如果领域很专业,比如协议、专利、3GPP规范,最好拿你的一小批真实文档做个对比测试,看看哪个模型能把同类内容聚得更近。
不要根据模型参数数量去猜效果。最快的验证方法是准备10个检索问题,分别用不同Embedding模型构建索引,看Top5召回率。这一步花不了多少时间,但能避免后续返工。
3. 文档加载与切分:知识库准不准,很多问题出在这一步
3.1 不同文档格式的加载方式
RAG效果不好的原因,有一大半不在模型,而在文档加载阶段。因为现实里的文档太乱:PDF里可能有扫描图片、Word里有表格、Markdown里有代码块、HTML里有广告导航。你需要先判断每个文件该用哪种解析器。
以PDF为例,文本型PDF可以直接提取文字;扫描型PDF需要先做OCR,否则检索出来的片段全是空的。Word文档里的表格如果直接提取成纯文本,行列关系会丢失,导致检索时语义不完整。HTML网页需要过滤掉导航、页脚、脚本标签,再提取正文。
我自己处理文档时,会按这个顺序做一遍:
- 先抽样打开几个文件,确认格式。
- 用不同的加载器分别处理,比如PDF用 PDFPlumber 或 PyMuPDF,HTML用 BeautifulSoup,纯文本直接用编码读取。
- 输出清洗后的文本文件,先肉眼检查前几段。
- 确认没有乱码、截断、重复页眉,再进入切分。
这一步不能省。如果你直接把几百个文件一股脑灌进去,之后检索结果乱掉时,很难判断是哪个文件出了问题。
3.2 切分策略:固定长度、递归切分、语义切分怎么选
文本切分决定了检索的最小单位。切得太小,一个完整知识点被拆成两半,检索到前半段却找不到后半段;切得太大,一个Chunk里包含多个主题,向量化后的语义过于分散,相似度检索会不准。
常见的切分策略有三种:
- 固定长度切分:按Token或字符数切,逻辑简单,但容易切断句子。
- 递归切分:按段落、句子、单词逐级切,整体效果更接近自然语言。LLM框架里常用这个方法。
- 语义切分:根据句子向量相似度自动判断断点,效果更好,但计算成本更高。
在还不清楚自己文档结构时,先用递归切分,chunk_size从500到1000字符之间试,chunk_overlap设为chunk_size的10%到20%。这个范围是常见起点,不是绝对标准。
判断切分是否合适,不需要看理论,直接看输出。把一段真实文档切完,逐一查看Chunk内容,重点看三个点:
- 有没有句子被拦腰切断。
- 每个Chunk是否包含一个相对完整的主题。
- 相邻Chunk之间是否有足够重叠,保证跨Chunk的信息不丢。
如果文档本身有清晰的标题结构,比如Markdown的二级标题、协议里的条款编号,最好先按标题或章节边界切,再对超过长度限制的块做二次切分。这种方式比纯靠字符数切更稳定。
3.3 Prompt拼接和上下文长度控制
切分完成之后,检索回来的多个片段要拼进Prompt。这里常犯的错误是把所有TopK片段一次性塞进去,不做长度控制。
LLM上下文窗口有上限。如果文档片段太长,拼接后可能把用户问题和指令挤出上下文,模型就开始乱编。我的做法是给每个片段设置一个最大字符数,在拼入Prompt前先截断,保证总长度不超过模型上下文窗口的三分之二,留出输出空间。
Prompt里要明确告诉模型:“只能根据提供的片段回答;如果片段中没有答案,就说不知道。”这不是废话。LLM有很强的补充倾向,不加约束时,它会把检索到的片段和自己记忆里的内容混在一起,生成一个看起来更完整但不可靠的答案。
Prompt的写法可以很简单,但一定要包含三层指令:角色定位、引用要求、拒答要求。例如:
你是一个知识库问答助手。 请根据下面提供的资料片段回答用户问题。 如果资料片段中没有足够信息,请明确回答“资料中未找到相关内容”,不要自行补充。 回答时尽量引用片段原意,不要编造细节。这段Prompt看着普通,但在实际运行中能显著降低幻觉。越到生产环境,越要重视Prompt的稳定性,而不是频繁换模型。
4. 检索质量怎么看:召回率、命中率、相关性指标是一套判断体系
4.1 不能只看“答没答对”
很多人测试RAG时,只问几个问题,看到答案大致对就说“行”。实际做知识库,尤其要批量处理文档时,这种验证方式远远不够。
原因很简单:问答是一个多环节链路,最终答案正确可能只是因为某个环节碰巧对了。如果检索阶段没召回关键片段,但模型凭自己的知识把答案猜出来了,你反而会误以为RAG没问题。一旦换个专业问题,模型答不上来,你才发现是检索问题。
所以要建立一套可复用的评估方式。对RAG进行质量评估,不能只看“一句话好不好”,至少要看“有没有召回到正确答案对应的片段”和“模型有没有使用这些片段生成答案”。
4.2 关键指标:召回率、命中率、MRR、上下文相关性
在RAG评估中,常用这几个指标,它们分别回答不同的问题:
| 指标 | 回答的问题 | 怎么理解 |
|---|---|---|
| 命中率 | 正确片段是否出现在检索结果里 | 只要TopK结果包含一个正确答案就算命中 |
| 召回率 | 正确片段在TopK结果中的覆盖程度 | 适合一个答案对应多个片段的场景 |
| MRR | 正确答案在结果中排得靠不靠前 | 更关注排序质量,排第一和排第五意义不同 |
| 上下文相关性 | 检索出的片段是否与问题相关 | 用来检查是不是返回了大量无关内容 |
具体计算时会有一个测试集:预先整理一批问题,并为每个问题标注哪些文档片段是正确答案。然后运行RAG,检查检索结果和最终答案。这个测试集不用很大,二三十个覆盖不同章节的问题就足以暴露大部分问题。
制作测试集时有一个小技巧:不要只从文档标题里生成问题,那样太容易命中。最好参考真实用户可能怎么问,多写一些口语化、带同义词的问题。比如文档里写“法定节假日安排”,用户可能问“过年能休几天”。这种问题能让检索压力更大,也更容易暴露问题。
4.3 指标不好时先调检索,不要急着换模型
一个常见的误区是:RAG效果不好就直接换一个更大的LLM。实际上,如果检索回来的片段就是错的,换再大的语言模型也补不回来。模型再强,也只能基于错误信息生成错误答案。
正确顺序是先调检索:
- 检查召回率和命中率。
- 检查TopK数量。
- 检查Chunk大小和重叠度。
- 检查Embedding模型是不是和文档领域匹配。
- 最后才考虑调整生成模型或Prompt。
这套顺序不用每次都完整跑一遍,但在指标明显不好时,按这个顺序排查最省时间。
如果命中率低,重点查Embedding模型和切分方式。如果MRR低,重点查排序和查询改写。如果上下文相关性低,说明检索结果里混入了大量无关片段,需要提高相似度阈值,或者减少TopK。这里说的“阈值”不是一个固定值,你需要通过一批样本观察相似度分数的分布,再决定取多少。
5. 批量任务与生产化:一套可复用的RAG处理流程
5.1 单条跑通之后,怎么设计批量导入
RAG从“能跑”到“能用”的关键,是批量文档怎么稳定处理。很多人本地跑单个文件没问题,一旦导入几十个文件,就会出现:部分文件解析失败、某些Chunk为空、向量库中混入重复内容、日志里没有任何错误。
我的建议是不要直接用一个大脚本把全部文件灌进去。先做一个“单文件导入”的脚本,先把一个文件完整跑通,确认输出没有问题。然后把文件列表改成参数,逐文件循环执行,每处理一个文件就打印文件名和Chunk数量。
批量导入时,要单独处理这几个问题:
- 文件编码:不同TXT、CSV文件的编码可能不同,统一转成UTF-8再处理。
- 重复导入:同一份文档如果重复导入,会出现多个重复片段。建议先计算文件哈希,建立已导入文件表。
- 失败跳过:单个文件失败不能中断整个批量任务,要记录失败原因,最后统一查看。
- 输出命名:向量库索引文件或结果文件建议按批次命名,避免覆盖上一次结果。
一个简单的批量循环可以写成这样:
import os from pathlib import Path input_dir = Path("./docs") failed_files = [] for file_path in input_dir.glob("*.pdf"): try: chunks = process_single_file(file_path) add_to_vectorstore(chunks, source=file_path.name) print(f"OK: {file_path.name}, chunks={len(chunks)}") except Exception as e: failed_files.append((file_path.name, str(e))) print(f"FAIL: {file_path.name}, error={e}") print("失败文件:", failed_files)这里使用try...except捕获单个文件异常,目的是让批量任务在执行过程中不中断。失败文件先记录下来,统一排查,比直接崩掉整个脚本更实际。
5.2 缓存、失败重试和日志
对于生产化RAG,还要求三个能力:缓存、重试、日志。
缓存是为了避免同样的问题反复调用LLM。常见的做法是用问题哈希值做键,把问题和答案存入数据库或缓存中间件。命中缓存时直接返回结果,不再调用模型,既省成本,也加快响应。但要注意,知识库更新后缓存要能失效,否则用户会得到旧答案。
失败重试主要针对接口调用时可能出现的限流、超时、临时网络错误。重试不是无限重试,一般建议设定最大次数,比如3次,每次间隔递增。超过重试次数后要把失败记录写入日志,方便人工排查。
日志是很多人容易忽略的。生产环境里,RAG跑得好不好不是靠“看起来对不对”,而是靠日志判断。每次问答至少记录:用户问题、检索TopK片段ID、最终答案、模型消耗Token数、耗时、是否命中缓存。这样出问题时,才能快速定位是检索问题、生成问题,还是业务逻辑问题。
我见过不少RAG项目,上线后一问三不知,但日志里没有任何记录,连“检索到了什么片段”都看不到。这种系统没办法排查。日志不用一开始就做得很复杂,但至少要能回放一次问答的完整过程。
5.3 接口化和Agentic RAG的升级方向
批量任务稳定之后,可以把RAG封装成HTTP服务,让其它系统调用。接口设计不需要复杂,但要把参数暴露清楚:输入问题、可选的历史记录、检索TopK、是否开启缓存。返回结果最好包含答案和引用片段,这样前端可以展示引用来源,用户也更容易判断答案是否可信。
再往后就是Agentic RAG。传统RAG是“一次检索、一次生成”,Agentic RAG会根据问题做多轮检索,先判断需要哪些知识,再决定用不用外部工具,甚至能调用多个知识库。这个方向适合复杂问题,但工程复杂度也更高。如果基础RAG还没稳定,不建议直接上Agent,容易把排查难度放大。
现在很多框架,比如LangChain、LlamaIndex、Dify、Spring AI,都在封装这些能力。框架封装度高,能加快开发,但同时也容易让人忽略底层链路。我的建议是先用框架跑通,再手动把每一层拆出来看一遍。这样出问题时,你至少知道该去查哪个环节。
特别是“查询改写”这个功能,在框架里经常会被默认开启,但很多人不知道它存在。比如框架可能会自动把长问题拆成子问题,或者把口语问题转成关键词。如果你发现检索结果和预期不一致,先确认框架默认做了什么,再决定要不要关掉或调整。
6. 常见问题排查清单:回答不准、内容不完整、检索不到怎么办
6.1 回答不准:先看检索到的片段,再看Prompt
如果模型回答内容“看起来像,但细节不对”,先不要怀疑模型参数。最优先的做法是打印出当前问题检索到的TopK片段,人眼判断这些片段是否真的包含正确答案。
可能的情况有三种:
- 片段里根本没有正确答案:问题出在检索环节,去查切分、Embedding、文档解析。
- 片段里有正确答案,但模型没用:问题出在Prompt,没有明确约束模型必须基于片段回答。
- 片段顺序不对:正确答案排在TopK后面,被其它相似片段干扰:可以调整TopK数量或相似度阈值。
在代码里加一行打印,把检索结果输出到日志或控制台,比反复改模型参数有效得多。有时候你只要看一次实际检索结果,就能发现文档里很多段落根本没被正确解析。
6.2 内容不完整:切分粒度、TopK、上下文长度
如果答案只覆盖了问题的一部分,比如问“请假流程和需要提交的材料”,模型只回答了流程,没提材料,通常有三个原因:
- 答案需要的信息分散在两个Chunk里,但只召回了其中一个。这时可以增大TopK,或调大Chunk Overlap。
- 相关片段被切分时拆散了。检查切分结果,如果同一个标题下的内容被拆到多个Chunk,考虑改用按标题切分。
- Prompt里片段总长度超出模型上下文限制,被截断。控制每个片段的长度,或者减少TopK数量,留出空间。
答案不完整和答案错误是两类问题,不要用同一种方式排查。错误重点看检索是否找到;不完整重点看需要的片段是否都被找到、是否都进入Prompt。
专门说一个常见场景:协议、专利、3GPP规范这类专业文档,经常用“第X条”“第Y节”来组织内容,答案可能分散在多个章节。如果切分时完全按字符数来,很容易把条款内容切断。处理这类文档时,建议先按条款编号或章节标题做结构化切分,给每个Chunk保留来源信息,比如“文档名+章节号”。这样即使答案分散,你也能看出还需要补查哪个片段。
6.3 检索不到:先查文档是否真被正确入库
检索不到时,有一种情况很隐蔽:文档确实加载了,但加载出来的全是乱码或空白。比如扫描版PDF没有OCR,表格被转成无意义字符,HTML解析只拿到了导航链接。此时向量库里存的就是错误内容,怎么检索都找不到正确答案。
排查顺序应该是:
- 用独立的脚本查看某篇文档解析后的前500字。
- 查看该文档生成了多少个Chunk,有没有空Chunk。
- 直接对单个Chunk做相似度检索,看能否召回。
- 如果召回内容不是预期,检查Embedding模型是否与文档语言、专业领域匹配。
- 如果文档来自特殊领域,比如协议、专利、3GPP规范,建议先做术语归一化和段落结构拆分,再入库。
还有一个经常被忽略的地方:文档路径和权限。如果你的程序运行在Linux服务器上,文件目录名带中文、空格或特殊符号,可能导致加载失败。不要只看“文件存在”,要确认当前用户有读取权限,进程工作目录是否正确。
最后再说一个偏经验的问题:有些知识库问题不是“检索不到”,而是“用户问法跟文档原文差距太大”。这时候不要急着改向量库,可以先做一个查询改写模块,把用户问题转换成更接近文档用语的表达。比如用户问“过年能休几天”,文档里写的是“法定节假日安排”,如果直接原句检索,可能匹配度不够;改写后才更容易命中正确的片段。
这部分在很多RAG课程里被归为“Agentic RAG”或“查询理解”,其实在基础RAG阶段就可以做,成本不高,效果明显。最简单的做法是先用LLM把用户问题改写成一个或两个检索问句,再拿改写后的句子去向量库检索。
如果按照上面的顺序把一个知识库从零搭完,你会发现RAG真正花时间的部分不在“调模型”,而在“让文档变成可检索的干净内容”、“让检索结果可解释”、“让批量任务可重跑”。这个第三部分给我的最大感受是:先解决数据问题,再谈模型能力。踩过几次之后我也更明白,很多问题不是工具不够,而是前置环境和输入材料没有处理干净。