这几年经常有人跑过来问我:公司内部有一大堆文档、手册、工单记录,能不能让AI直接回答“某某流程怎么走”“某台设备的参数是多少”,而且不许乱编。我通常先反问一句:你打算让模型微调,还是直接拉长上下文?很多人第一反应是微调,但真正做下来会发现又贵又难维护;第二反应是全都塞进上下文,结果输入爆炸、费用翻倍,回答反而被无关信息干扰。
真正适合大多数场景的答案,其实是RAG智能问答系统。RAG的全称是检索增强生成,核心思路很简单:先让模型去你的知识库里检索相关内容,再把它和用户问题一起交给大模型,让模型“看着资料回答”。这样既能利用大模型的通用能力,又不让它凭空发挥。这篇文章就从一个项目标题出发,把从零搭建一个基于自有知识库的RAG问答系统的思路、细节和踩过的坑完整拆一遍。适合正在做知识管理、客服文档、内部问答工具,以及刚入门LLM应用开发的读者参考。
1. 为什么是RAG:不补知识,而是给回答装“刹车”
1.1 大模型记不住你的私域知识
大模型最尴尬的地方在于:它什么都懂一点,但只懂“公开的”东西。你公司的内部SOP、产品文档、历史故障记录,这些内容模型在预训练阶段根本没见过,你再怎么问它也只能给出泛泛而谈的答案。
有人会想,那我微调一下不就行了?微调确实能改变模型的风格和部分行为,但它本质上是把知识以参数形式“记”进模型里。这里有两个现实问题:第一,微调需要高质量的标注数据,成本不低;第二,一旦知识更新了,你还得重新微调。你想想,产品文档一个月改十几次,每次改完都要重训模型,这谁顶得住?
1.2 RAG解决的核心矛盾
RAG的思路完全绕开了“让模型记住知识”这条路。它把知识库当成一个外挂词典,每次用户提问时现场去查。这样做有三个天然优势:
第一,知识更新零成本。文档变了,只需要重新处理一遍对应的文件。第二,回答有据可循,模型生成的内容可以引用原文片段,降低幻觉。第三,敏感内容不参与训练,你的文档只用做检索,模型厂商不会因此获得你的数据。
我经常用一句话概括:RAG不是给AI补知识,而是给AI的回答装了一套刹车系统——让它先查资料再说话。
1.3 一套最小可用系统的五个环节
从架构上看,一套最小可用的RAG系统包含以下五个环节:
- 文档解析与清洗:把PDF、Word、Markdown、网页等原始文件转成干净文本。
- 文本切分:把长文本切成适合检索的片段,同时保留语义完整性。
- 向量化:用Embedding模型把所有文本片段转成向量。
- 向量检索:把用户的问题同样转成向量,在知识库中做相似度检索。
- 生成回答:将检索结果拼接成Prompt,交给大模型生成最终回答。
这五个环节缺少任何一个,整个链路都不完整。尤其是文本切分和向量化这两个环节,很多人觉得直接用默认参数就行,实际上这里恰恰是决定最终效果好坏的关键。
2. 索引构建的完整细节:文档清洗、切分与向量化
2.1 文档加载与清洗:哪些内容不该进知识库
我在处理文档时发现,很多人第一步就踩坑:把pdf直接解析成一段乱码似的文本,然后塞进切分器。结果检索出来的片段里全是页眉页脚、目录页码、表格碎片。最后模型一点忙都帮不上。
文档清洗的关键是先做格式归一化。我的习惯是:优先把文件转成Markdown或纯文本,PDF优先看是否能直接提取文本层,扫描件才做OCR,不要一上来就用OCR处理所有文件。清洗时至少要做以下几件事:
- 去掉页眉页脚、页码、版本号等重复噪声。
- 合并断行,处理表格和列表,尽量保留结构信息。
- 过滤掉无意义的重复内容,比如免责声明、版权宣传语。
- 这一步有一个很重要的原则:越靠近’检索时会被读到的样子’越好。模型是按文本块理解和检索的,如果你把表格拆成碎片,检索时就永远找不到完整的表格语义。
2.2 切分策略:chunk_size与overlap到底怎么定
切分是RAG系统里最容易被低估的环节。切大了,一个片段里塞了太多主题,查询向量被稀释;切小了,一个完整的事实被切成两半,模型拼都拼不回来。
具体参数我给出一个经过验证的参考范围:
- 默认chunk_size在200到500个字符之间,中文按字算,英文按token算,混合内容按经验值。
- overlap也就是重叠窗口,通常设置为chunk_size的10%到20%。比如chunk_size为400,overlap就设为50到80。
- 按段落切分永远优先于按固定长度切分。保留段落结构,语义完整性会好很多。固定长度切分只当作兜底方案。
我在做中文知识库时发现一个坑:如果直接用tokenizer按字节切,很容易把一句话切成两半,导致检索到一堆语义不完整的碎片。所以我的做法是:先用段落做T分割,段落过长的再按句子切,句子还是太长才用固定长度切。切分后的片段最好不超过两三个事实点。你可以这样自己判断:把切出来的片段拿给一个不懂背景的人看,他是否要费很大的劲才能看懂这个片段在讲什么?如果要想一下,那这个片段切得不够好。
2.3 向量化与存储:Embedding选型与索引结构
Embedding模型决定你的知识库如何理解文本。目前有两大类选择:一类是本地开源模型,常见的有基于中文语料优化的BGE系列、通用型的GTE系列等;另一类是商用API调用的Embedding服务。两者各有特点:本地模型部署在低配置机器上也能跑,成本可控,适合注重隐私的私域场景;商用API集成简单,但按量计费,且文档内容会经过远端服务。
我的建议是,做中文知识库优先选择对中文支持较好的开源Embedding模型,因为大多数商用API在中文长文本对语义的理解不如编码器型模型稳定。实际测试中,中文私域文档场景下本地模型和商用API的差距并不明显,但本地模型的灵活性要高很多。
向量数据库的选择上,几十万量级以内的文档,选择轻量级方案就够用了;如果做到百万量级,再考虑引入独立向量库服务。原则是:先从轻量方案起步,等数据量和并发量确实上来了再迁移,不要一开始就把架构搞得很重。
索引方面,最常用的是HNSW图索引,它在召回速度和准确率之间平衡不错。对于小规模知识库,Flat暴力检索精度最高,但速度随数据量上升下降得很快。我在本地测试中一般直接用Flat,数据超过几十万条才切到HNSW索引。
下面是轻量方案和独立服务方案的一个对比,方便你根据自己的场景选:
| 方案类型 | 部署复杂度 | 千万级M量 | 适合规模 | 场景 |
|---|---|---|---|---|
| 轻量嵌入式 | 极低,随进程启动 | 支持HNSW | 百万内 | 单机工具、小团队内网 |
| 独立向量服务 | 中,需单独部署 | 支持分布式 | 百万以上 | 企业级多并发系统 |
3. 从零搭建一个可运行的最小RAG系统(附实现代码)
3.1 环境准备与依赖安装
为了让你能直接跑通,我给出一个开发环境下的最小依赖组合。建议使用Python 3.10及以上版本:
pip install langchain langchain-community langchain-text-splitters pip install sentence-transformers pip install chromadb pip install pypdf不少教程会直接教你把LangChain全家桶都装上。我不建议这么干,依赖冲突会浪费很多时间。只装上面这几个模块,就已经足够支撑最小系统的运行了,其他组件等你需要时再加。
3.2 索引构建:从文档到向量库
下面展示一个最小索引构建流程的代码主体。我刻意隐藏了具体的模型名称和API地址,你在运行时可以替换为你的本地模型或商用API。
from langchain_community.document_loaders import PyPDFLoader from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_community.embeddings import HuggingFaceEmbeddings from langchain_community.vectorstores import Chroma # 加载文档 loader = PyPDFLoader("knowledge_base/运维手册.pdf") documents = loader.load() # 清洗文本:去掉空行和噪声 for doc in documents: doc.page_content = "\n".join( line.strip() for line in doc.page_content.split("\n") if line.strip() ) # 切分参数:按段落为主,固定长度兜底 splitter = RecursiveCharacterTextSplitter( chunk_size=400, chunk_overlap=80, separators=["\n\n", "\n", "。", ";", " ", ""] ) chunks = splitter.split_documents(documents) # 加载本地Embedding模型 embedding_model = HuggingFaceEmbeddings( model_name="你的本地模型路径或模型标识" ) # 建索引,并持久化到本地目录 vectorstore = Chroma.from_documents( documents=chunks, embedding=embedding_model, persist_directory="./rag_index" ) vectorstore.persist()这里有一个容易被忽略的点:切分参数里的separators顺序决定了递归切分时优先按什么粒度断开。我把\n\n和\n放在前面,是为了先保留段落边界;中文场景下再补上句号和分号作为兜底。这样切出来的片段语义边界更自然,而不是盲目的按字符长度截断。
3.3 问答生成:检索与Prompt编排
索引建好之后,问答阶段的核心逻辑就两步:检索相关片段,拼接prompt交给大模型。下面继续给代码:
from langchain.chains import RetrievalQA from langchain.llms.base import LLM # 使用上一节构建的向量库 vectorstore = Chroma( persist_directory="./rag_index", embedding_function=embedding_model ) retriever = vectorstore.as_retriever( search_type="similarity", search_kwargs={"k": 5} ) # 定义一个包装类,方便切换不同后端的大模型API class CustomLLM(LLM): @property def _llm_type(self): return "custom" def _call(self, prompt, stop=None, **kwargs): # 这里调用你选定的线上大模型API # 返回字符串即可 return call_your_llm_api(prompt) llm = CustomLLM() qa_chain = RetrievalQA.from_chain_type( llm=llm, retriever=retriever, return_source_documents=True ) result = qa_chain("某类故障的处理时限是多久?") print(result["result"]) print("来源片段:") for doc in result["source_documents"][:3]: print(doc.page_content)上面这个包装类的好处是你不必绑定某一个具体的模型服务商。你只需要把call_your_llm_api里面的请求换成你的API调用方式即可。至于为什么不直接使用langchain内置的很多平台封装类?因为很多封装类更新频繁,你换一个模型就要换一个类,包装一层反而稳定。
3.4 召回数量k值的取舍
检索时的k值决定了把多少个片段交给大模型。我实测下来的经验是:
- 知识库片段质量较高时,k值取3到5比较合适。
- 片段比较碎、每个片段信息量少时,k值可以适当增加到8到10。
- k值过大,无关信息反而会干扰模型,而且会显著增加token消耗。
- 别迷信一个固定值。当你发现模型总是只盯着某一小段内容答非所问时,先排查k值是否过小。
4. 检索增强优化:把“相关”变成“精准”的四个手段
4.1 重排序:解决词面匹配的无效召回
纯向量检索有一个天然缺陷:它更擅长捕捉语义相似,会被词面重复干扰。打个比方,用户问“故障处理时限”,一个片段里反复出现“故障”“时限”,但内容是别的系统的处理手册,向量相似度可能仍然很高。结果这个无关片段被排到了前面。
重排序的思路是,先用轻量向量检索召回可能相关的,召回数量可以多一些,比如10到20条,然后让一个更强的跨编码器模型对查询和候选片段逐对打分,重新排一个高质量顺序,最后只把排名前3到5的片段交给大模型。这一步对回答质量的影响非常明显。我在多个场景里观察,加上重排序后,回答的引用命中率能提升一大截。
你需要有个心理准备:重排序会引入额外的计算延迟,每几十条候选的重排可能耗时数百毫秒。但对于内部问答系统而言,这点延迟换来的是回答质量的提升,通常值得。
4.2 混合检索:向量加关键字的组合方案
有些知识库里的内容是高度专业化的,比如设备编号、报错码、型号字符串,这类内容语义上难以区分,词汇表达精准度要求很高。单纯用向量检索经常会召回语义相近但实际不对的条目。
混合检索的做法是让向量检索和BM25关键字检索并行执行,各自取TopN结果后做合并、去重、再排序。这样既保留了向量检索的语义能力,又弥补了精确匹配的短板。在实际项目中,混合检索加后面一节要说的重排序,是让答案从“差不多对”变成“精准对”的黄金组合。
4.3 Prompt与上下文压缩:过滤干扰片段
当你把检索到的片段直接一股脑拼进Prompt时,会面临一个很现实的问题:片段越多,上下文越长,模型越容易迷失重点,还可能导致生成结构变乱。质量参差不齐的片段甚至会互相冲突。
一个常用的优化手段是上下文压缩。做法是在把片段交给大模型之前,先让一个小模型或规则判断每个片段是否真实相关,过滤掉明显不相关的部分,再拼接。如果你的环境不能方便地调用压缩模型,可以手工写一个阈值规则:对每个片段的相似度分数排序,低于阈值的直接丢弃,超过阈值的进行截断压缩。即便只是用规则做这一步,效果也会明显变好。
4.4 引用溯源:让答案可验证
RAG系统落地到企业内部,用户大概率不会无条件信任AI生成的答案,尤其是涉及流程和数据的内容。如果不做引用溯源,你很难说服他们采纳这个系统。
实现起来并不复杂:
- 在返回结果时保留每个片段对应的来源文件名和页码。
- 生成回答时在相应句子的末尾插入引用标记,比如[1][2]。
- 前端展示时把引用标号和对应的原文片段渲染出来,用户点击即可查看原始出处。
这一步做不做,决定了你的系统是“技术demo”还是“可用产品”。很多项目在Demo阶段效果看着不错,一上生产环境就被用户投诉“瞎编”,根源就在于没有引用溯源,用户无法验证回答。
5. 实测效果与问题排查速查表
5.1 效果不好时先看的几个指标
RAG系统的效果问题,大部分都不是大模型的错,而是出现在上游链路。我把排查顺序固定为:召回率、相关性、Prompt上下文、生成模型。召回率先看:用户问题涉及的片段,到底有没有被检索出来?如果片段本身都没召回,后面再怎么优化生成都没有意义。
相关性再看:召回的片段虽然多,但排名靠前的是不是真正相关的?如果相关片段排在第三名开外,说明需要加Rerank或调整检索参数。接着看Prompt上下文:优秀片段是否被完整保留?无关片段是否混入?最后才是生成模型的问题。
5.2 我和团队在项目中遇到过的几个典型问题
这里我挑几个踩得比较深的问题分享:
第一个问题是扫描版PDF直接索引,导致检索为空。处理办法是先用OCR把文件里的文字提出来,再走清洗流程。第二个问题是为了省token把chunk_size调得特别小,比如100个字符。这会导致一个完整的知识点被拆成四五块,检索到的永远只是其中一小块。后来我们统一把chunk_size调到350到500,overlap设置为50到80,情况就好了很多。第三个问题是错误地把多个不同系统的文档混在同一个知识库里,检索时经常跨域混答。解法是给不同文档按业务域建多个子索引,并给每个文档打上标签,检索时先按业务域过滤一遍再检索。
| 症状 | 可能原因 | 处理方法 |
|---|---|---|
| 回答时好时坏,不稳定 | 召回片段排序不稳定 | 引入Rerank,固定候选片段数量 |
| 答非所问,答到另一个系统 | 知识库混域严重 | 按业务域拆分索引,先过滤再检索 |
| 模型复述原文而非回答问题 | Prompt指令过于简单 | 在Prompt中强调“基于资料概括后作答” |
| 引用来源找不到 | 切分时丢失文档元数据 | 切分前保留filename和page字段,随片段存入索引 |
| 回答频繁说“资料里没有” | 检索参数过于严格 | 降低相似度阈值,或把k值调大 |
| 中文近似词查询效果差 | Embedding模型对中文支持不足 | 换用更适配中文的模型,或用同义词扩展查询 |
5.3 成本与资源控制的一点心得
RAG系统的成本大头通常不是向量检索,而是大模型的token消耗,尤其是你存了很多片段又全都塞进Prompt的时候。为了控制成本,我用过两招:第一招是控制k值上限,尤其不要无限调大;第二招是给不同的问答场景设置不同的上下文长度——简单的事实型问题用短上下文,复杂分析型问题才用长上下文。
向量计算和存储成本在中小规模场景下几乎可以忽略,但如果你每天新增大量文档,建议做增量索引而不是每次全量重建。全量重建在第一批数据少时没问题,等数据量到了几十万条,全量重建一次极耗时,涉及的生产细节也复杂得多。从第一天就把“增量写入”这个动作固化进流程,能帮你省下后面很多麻烦。
6. 从Demo到生产:还需要补上的几块拼图
6.1 文档更新机制比技术本身更重要
Demo阶段你手动跑一次索引脚本就可以了。但到了生产环境,文档是持续变动的。今天有人改了产品手册,明天有人新增了工单模板,周而复始。你需要有一套文档更新机制:可以是定时任务扫描知识目录中变更的文件,增量更新索引;也可以是用户上传文档后触发异步解析和入库。
我见过很多项目在技术架构上没问题,最后却死在“没人更新知识库”。系统上线三个月后,知识库还停留在初始搭好的那批文档上,用户问新问题全部答不上来,不久整个系统就被弃用了。知识库的时效性维护,从项目初期就应该被当做一个核心模块来设计。
6.2 权限控制与多租户隔离
企业内部的知识库往往涉及权限边界。同一个RAG系统,不同部门的人能检索的内容应该是不同的。如果在落地阶段把这个问题忽略掉,后期排查数据越权会异常痛苦。
安全且省事的做法是:在建立索引时,给每一条文本片段都打上权限标签;检索的时候,根据当前用户的权限对候选片段过滤后再返回。使用独立的向量服务时,权限过滤可以走元数据条件;使用轻量方案时,就需要在检索前引入一个权限预处理器。权限的逻辑看起来很简单,但越到后期越难以改动,建议在架构设计阶段就留好这个口子。
6.3 评估反馈闭环:建立一个属于你的金标准集
最后一个压箱底的建议:从跑通Demo的第一天起,就开始搭建你自己的评估数据集。方法不复杂:收集十几条或几十条典型用户问题,为每个问题手工标注一个“理想答案应该包含的核心信息点”。每次你调整切分参数、换Embedding模型、调Prompt之后,都在这套集子上跑一遍,看关键信息点的命中情况有没有变差。
没有评估集的RAG项目,本质上是在“盲调”。你觉得效果好,往往只是基于一两个测试用例的直观感受,并不代表系统整体在线稳定。有了评估集,你才能对每一次改动给出客观判断:这次优化到底是正向的,还是反向的,一目了然。这套评估数据不需要做得很庞大,覆盖主要业务场景的几十条就足够用了。
另外对个人开发者,我还想专门提一句:RAG系统的复杂度没有外面吹的那么高,但坑确实不少。你不需要一开始就上很重的架构,先跑通最小链路,再逐个环节做优化。整个过程走下来,你会发现最大的收获其实不是代码,而是你对“知识管理”这件事的理解变得扎实了很多。