首页 / AI教程 / 正文
AI教程

RAG教程:从零搭建本地知识库问答系统,告别大模型幻觉与知识过时

chuanbook chuanbook
发布于 2026 年 10 月 06 日
阅读 约21分钟
浏览 4
评论 0

1.1 RAG是什么:解决大模型幻觉、知识过时与私有数据问答的核心思路

我第一次认真琢磨RAG这个东西,是因为被大模型“一本正经胡说八道”坑过好几次。问它某个内部项目的接口文档,它能编出一套看似合理实则完全不存在的方法名,还贴心地配上了示例代码。后来我才明白,大模型本质上是个概率机器,它的知识冻结在训练数据的那一刻,对于训练时没见过的东西,它只能靠“猜”。这种猜有时候很准,有时候离谱得让人哭笑不得。

RAG全称Retrieval-Augmented Generation,中文叫检索增强生成。这个词拆开看就很直白,检索加生成。思路也简单——别让模型光靠记忆回答问题,先帮它去知识库里找相关资料,再把找到的内容塞进提示词里,让它照着材料说话。我自己的体会是,这相当于给模型配了个随身图书馆,它不需要把所有知识都背下来,只需要学会怎么查、怎么用。

这套思路能同时解决三个让人头疼的问题。幻觉问题靠“有据可依”来压制,模型看到的是实际检索出来的文本,编造空间小了很多。知识过时问题靠“实时检索”来规避,资料更新了,检索结果就更新了,不用重新训练模型。私有数据问答靠“外挂知识库”来实现,公司内部的文档、数据库、工单记录,不需要喂给模型训练,检索时能找到就行。我后来做项目时深深感受到,RAG最大的价值不是让模型变聪明,而是让它变得“可靠”——知道什么该说,什么不该说。

1.2 RAG与微调、长上下文、搜索增强的对比及适用场景

刚接触RAG那会儿,我特别容易把它和微调搞混。两个都是为了解决模型知识不足的问题,路线完全不同。微调是让模型重新学习,把新知识“焊”进参数里,成本高、周期长,数据一更新就得再来一遍。RAG是让模型学会查阅资料,知识放在外部,随时可换。我个人的经验是,如果任务涉及风格迁移、格式对齐、特定领域的语言习惯,微调更合适;如果任务需要频繁更新知识、要求答案可溯源,RAG是更务实的选择。

长上下文能力这几年进步很快,有些模型能直接吞下几十万字的文档。我一开始也想过,既然模型能读这么长的内容,是不是直接把全部资料塞进去就完事了?实测下来问题不少。上下文越长,注意力越容易涣散,关键信息淹没在噪声里,模型反而抓不住重点。成本也是实打实的,每次请求都处理海量token,账单会让人清醒。RAG的检索环节本质上是在做信息筛选,把最相关的内容挑出来,让模型在有限的上下文里聚焦。

搜索增强这个词有时候会和RAG混用,我更愿意把它们看作不同层次的东西。传统搜索增强偏向关键词匹配和排序,返回的是链接列表。RAG把检索结果直接喂给生成模型,让模型消化后输出自然语言答案。这个区别决定了RAG更适合问答式交互,搜索增强更适合信息发现。我现在的判断标准很简单——用户想要一个直接答案,上RAG;用户想自己浏览筛选,传统搜索够用。

1.3 RAG标准流水线:文档加载、文本切分、向量化、存储、检索、重排与生成

把RAG拆成流水线来看,每个环节都有坑,也都有优化空间。文档加载是第一步,听起来简单,做起来琐碎。PDF里的表格、扫描件的OCR、网页的正文提取、Markdown的层级结构,每种格式都有自己的脾气。我记得处理过一批产品手册,PDF里全是双栏排版,直接抽取文字后顺序全乱,读起来像乱码。后来老老实实上了版面分析工具才解决。

文本切分是我踩坑最多的地方。切得太碎,语义不完整,检索出来的片段缺少上下文;切得太粗,噪声太多,模型容易被无关信息干扰。我试过固定长度切分,也试过按标点、按章节、按语义切分。现在的习惯是先用递归切分保底,再根据文档结构做定制化处理。代码文档按函数切,法律合同按条款切,聊天记录按对话轮次切,效果比一刀切好很多。

向量化、存储、检索这三个环节连在一起说。向量化是把文本变成一串数字,让语义相近的内容在向量空间里距离更近。Embedding模型的选择直接影响检索效果,开源的有BGE、M3E,闭源的有OpenAI的text-embedding系列。我通常先用小规模数据对比几个模型,看哪个在自己场景下召回更准。存储用向量数据库,FAISS、Chroma、Milvus、Qdrant都用过,本地测试FAISS最轻便,生产环境Milvus和Qdrant的分布式能力更省心。

重排和生成是最后两步。检索回来的Top-K结果按相似度排序,但向量相似度高不代表对回答最有帮助。重排模型用交叉编码器对候选片段重新打分,把真正相关的往前排。我做过对比实验,加上重排后答案准确率能提升一截。生成环节就是把重排后的上下文和用户问题一起塞进提示词,让模型组织语言输出答案。这一步的提示词设计很讲究,后面会单独聊。

1.4 检索质量关键:分块策略、Embedding选择、相似度度量与Top-K调优

分块策略这件事,我现在的理解是没有万能方案,只有针对场景的权衡。固定长度切分实现简单,适合快速验证。按语义切分依赖模型判断句子边界,质量高但计算开销大。滑动窗口在块之间保留重叠,缓解语义断裂问题。我常用的组合是递归字符切分加段落感知,优先在段落边界断开,段落太长再按句子切,句子还长才硬切。

Embedding选择上,我走过一段弯路。一开始迷信排行榜,哪个模型在MTEB榜单上分数高就用哪个。实际用起来发现,榜单分数和业务效果不完全对等。中文场景下,BGE系列和M3E表现稳定;多语言场景,paraphrase-multilingual系列更均衡;如果预算充足,OpenAI的text-embedding-3-large确实省心。我的做法是准备一批标注好的问答对,拿几个候选模型跑召回率,用数据说话。

相似度度量常见的有余弦相似度、点积、欧氏距离。文本Embedding通常做归一化,归一化后余弦相似度和点积等价。我习惯用余弦相似度,数值范围直观,容易设阈值。Top-K调优是个经验活,K太小召回不足,K太大引入噪声。我一般先设K=10做基线,看命中率和答案质量,再往上加。加了重排之后,可以适当放大初始检索的K,比如20到50,让重排模型有更多选择余地。

1.5 Prompt设计与上下文组织:让模型基于检索结果回答并抑制幻觉

提示词设计是我认为RAG里最像“手艺活”的部分。同样一批检索结果,提示词写得好,答案精准且有引用;写得差,模型要么忽略材料自己编,要么被无关片段带偏。我总结了几条实用原则。明确告诉模型“只根据提供的上下文回答”,这条能大幅减少幻觉。要求模型在答案里标注引用来源,比如“根据文档A第3段”,方便人工核查。

上下文组织也有讲究。检索回来的片段不能一股脑全塞进去,顺序会影响模型注意力。我通常把最相关的放最前面,次相关的放最后面,中间放补充信息。片段之间加分隔符和来源标识,比如用“---”隔开,每段前面写上文档名。如果片段太长,可以做摘要压缩,或者只保留与问题最相关的句子。我试过把检索结果按相关度分组,高相关的详细呈现,低相关的只给标题,模型反而抓重点更准。

还有个小技巧是给模型“退路”。提示词里加一句“如果上下文中没有足够信息,请直接说不知道”,比硬逼着它回答要安全得多。我在内部问答系统里加了这条之后,用户反馈“胡编乱造”的情况少了很多。模型不是万能的,让它承认无知比让它假装全知更负责任。

1.6 最小RAG实战:用Python读取本地文档并完成一次问答

理论说了一堆,跑通一个最小系统才算真正入门。我带过几个新人,发现他们卡住的地方往往不是原理,而是环境配置和代码串联。这里给一个最简版本,用Python加FAISS加OpenAI兼容接口,本地文档问答跑起来。

先准备依赖。pip install langchain langchain-community faiss-cpu openai tiktoken,这几个包够用了。文档放在一个文件夹里,支持txt和md就行。读取用TextLoader或DirectoryLoader,切分用RecursiveCharacterTextSplitter,chunk_size设500,overlap设50。Embedding用OpenAIEmbeddings或者本地的HuggingFaceEmbeddings,存进FAISS向量库。

检索器用vectorstore.as_retriever(),设search_kwargs={"k": 5}。提示词模板长这样:

根据以下上下文回答问题。如果上下文没有相关信息,请回答“我不知道”。

上下文:
{context}

问题:{question}
答案:

链式调用用RetrievalQA.from_chain_type,把LLM、retriever、prompt串起来。跑一个qa_chain.invoke({"query": "你的问题"}),就能看到基于本地文档的回答。我建议第一次跑通后,故意问一个文档里没有的问题,看模型是不是老实说“不知道”。这个测试比问对问题更能验证系统是否可靠。

代码跑通只是起点。我通常会加日志,把每次检索到的片段和最终答案都记下来。回头分析哪些片段被用了,哪些被忽略了,对调优分块和提示词特别有帮助。

1.7 从入门到实战的评估闭环:命中率、忠实度、答案相关性、延迟与成本

RAG系统不评估就是盲人摸象。我见过太多项目,demo阶段效果惊艳,上线后问题百出,就是因为没有建立评估闭环。评估指标我分四类看。检索侧看命中率,标准问题对应的文档片段有没有出现在Top-K里。生成侧看忠实度,答案里的每句话是不是都能在检索结果里找到依据。答案相关性衡量回答是否切题,有没有答非所问。工程侧看延迟和成本,响应时间用户能不能接受,每次调用的token消耗划不划算。

评估数据准备是个体力活,但值得投入。我通常从真实用户问题里采样,人工标注每个问题的标准答案和对应文档片段。数量不用多,几十到一百条就能看出趋势。跑评估时,检索命中率用程序自动算,忠实度和相关性可以用另一个LLM做裁判,也可以人工抽检。我习惯两者结合,程序跑全量,人工看bad case。

延迟和成本容易被忽视。检索加生成,端到端延迟超过3秒用户就会烦躁。优化手段包括向量索引调参、缓存高频问题、异步处理、模型蒸馏。成本方面,Embedding调用和LLM调用是大头,能本地化的尽量本地化,能缓存的绝不重复计算。我现在的习惯是每次迭代都记录这三个数字:平均延迟、每次查询成本、用户满意度。数字不会骗人,比感觉靠谱多了。

评估闭环建立起来之后,RAG系统的迭代就有了方向。分块策略改了,看命中率变化;提示词调了,看忠实度波动;换了Embedding模型,看综合指标。没有评估,调优就是碰运气。有了评估,每一步都踩在实处。

## 2.1 LangChain核心模块与本地知识库项目结构规划 我搭建本地知识库时,LangChain 的模块划分帮了大忙。它把整个流程拆成了几个可替换的零件:Document Loaders 负责把各种格式的文件读成文本,Text Splitters 负责切分,Embeddings 负责转向量,VectorStores 负责存储和检索,Retrievers 负责封装查询逻辑,Chains 负责把检索和生成串起来,Memory 负责记住对话历史。每个模块都有标准接口,想换实现只要改一行配置。我刚开始用的时候总想自己写全套,后来发现 LangChain 的抽象虽然有时候显得啰嗦,但工程化程度确实高,省了很多重复劳动。 项目结构规划这事,我吃过亏。最早把所有代码塞进一个 `main.py`,后来加功能时改一处崩三处。现在我会按职责分目录:`config/` 放模型路径、向量库路径、切分参数;`data/` 放原始文档和清洗后的文本;`models/` 放本地 Embedding 和 LLM 的加载逻辑;`chains/` 放检索链和生成链的组装代码;`ui/` 放 CLI 或 Web 界面;`logs/` 放运行日志和检索记录;`tests/` 放评估脚本。这样拆开之后,调参不用翻遍整个项目,换模型也只需要改 `models/` 里的一个文件。 我还会在根目录放一个 `settings.py`,用 Pydantic 的 BaseSettings 管理配置。环境变量、默认值、类型校验都集中在这里。本地知识库经常需要在不同机器上跑,配置和代码分离能省不少事。比如向量库路径,开发机是 `./chroma_db`,服务器上可能是 `/data/vector_store`,改环境变量就行,代码不用动。 ## 2.2 环境准备与依赖安装:Python、LangChain、向量数据库与本地模型 Python 版本我推荐 3.10 或 3.11。3.12 有些库的预编译轮子还没跟上,装起来容易卡在编译环节。虚拟环境用 conda 或者 venv 都行,我习惯用 conda 建一个干净的环境,避免和系统 Python 打架。命令大概是这样:`conda create -n rag python=3.11`,激活后 `pip install langchain langchain-community langchain-core`。这三个包是基础,langchain-community 里有很多社区维护的加载器和向量库集成。 向量数据库的选择看场景。本地快速验证用 FAISS,纯内存操作,`pip install faiss-cpu` 就够。想持久化用 Chroma,`pip install chromadb`,它会自动在本地建一个 SQLite 加 Parquet 的存储。生产环境如果数据量大,Milvus 或 Qdrant 更合适,但本地知识库一般用 Chroma 就够。我自己的项目里 Chroma 用得最多,API 简单,和 LangChain 集成也顺。 本地模型分两块:Embedding 和 LLM。Embedding 我常用 `sentence-transformers` 加载 BGE 系列,比如 `BAAI/bge-small-zh-v1.5`,模型小、速度快、中文效果好。LLM 可以用 Ollama 跑 `qwen2.5:7b` 或者 `glm4:9b`,也可以用 `llama-cpp-python` 直接加载 GGUF 量化模型。Ollama 的好处是管理方便,`ollama pull qwen2.5:7b` 就能用,LangChain 有 `ChatOllama` 接口直接对接。我试过在 16G 内存的笔记本上跑 7B 的 Q4 量化模型,响应速度可以接受,做本地问答完全够用。 ## 2.3 文档加载与清洗:TXT、PDF、Markdown、网页与目录批量导入 文档加载看着简单,实际做起来每个格式都有坑。TXT 和 Markdown 最省心,`TextLoader` 和 `UnstructuredMarkdownLoader` 直接读。PDF 麻烦一些,`PyPDFLoader` 能抽文字,但遇到扫描件就抓瞎,得先上 OCR。表格和双栏排版也容易乱序,我一般会先用 `pdfplumber` 检查一下抽取质量,不行就换 `unstructured` 的 `partition_pdf`,它带版面分析,效果好不少。网页用 `WebBaseLoader`,指定 URL 后它会用 BeautifulSoup 抽正文,但广告和导航栏经常混进来,需要配合 CSS 选择器过滤。 目录批量导入用 `DirectoryLoader`,可以指定 `glob` 模式,比如 `**/*.md` 只加载 Markdown。我会在 loader 里加一个 `loader_kwargs` 传编码参数,中文文档经常遇到 GBK 编码,不加 `encoding='utf-8'` 会报错。加载进来的 Document 对象带 `metadata`,里面可以塞来源路径、文件修改时间、文档类型。这些元数据后面做过滤和引用来源时特别有用,我习惯在加载阶段就补全。 清洗环节我一般分三步走。第一步去重,用 `hashlib` 对文本内容算 MD5,重复的直接丢掉。第二步去噪声,用正则删掉页眉页脚、页码、多余的空白行。第三步规范化,把全角空格转半角,把连续换行压成一个。我做过一个内部文档库,原始文件里有大量“第 X 页 共 Y 页”的标记,不清洗的话检索结果里全是这些垃圾,模型回答时也会被带偏。清洗完的文本我会存一份到 `data/cleaned/`,方便回溯和调试。 ## 2.4 文本切分与向量化:RecursiveCharacterTextSplitter、本地Embedding与向量库写入 `RecursiveCharacterTextSplitter` 是我用得最多的切分器。它的逻辑是优先按段落切,段落太长就按句子切,句子还长就按字符硬切。参数上 `chunk_size` 和 `chunk_overlap` 需要根据文档类型调。中文技术文档我一般设 `chunk_size=500`,`chunk_overlap=50`。500 个字符大约 200 到 300 个汉字,能装下一个完整的概念。overlap 留 50 是为了防止关键信息刚好卡在切分点上被截断。如果文档结构很清晰,比如 Markdown 有明确的二级三级标题,我会用 `MarkdownHeaderTextSplitter` 先按标题切,再用递归切分器做二次切分。 本地 Embedding 加载用 `HuggingFaceEmbeddings`,指定模型名和 `model_kwargs`。BGE 系列需要加一个 query 指令前缀,检索效果会更好。代码大概是这样:`model_kwargs={'device': 'cuda'}`,`encode_kwargs={'normalize_embeddings': True}`。归一化之后余弦相似度和点积等价,后面设阈值方便。我试过 `bge-small-zh-v1.5` 和 `bge-base-zh-v1.5`,小模型速度快三倍,召回率只差两三个点,本地知识库场景下小模型性价比更高。如果机器有 GPU,`device` 设成 `cuda`,向量化速度能快十几倍。 向量库写入用 `Chroma.from_documents`,把切分后的 Document 列表和 Embedding 实例传进去。`persist_directory` 指定持久化路径,下次启动直接 `Chroma(persist_directory=..., embedding_function=...)` 加载。写入前我会先跑一遍 `len(documents)` 确认切分数量合理。有一次发现切出了两万多个块,检查才发现是切分参数设成了 `chunk_size=100`,太碎了。切分数量一般控制在原始文档字符数的十分之一到五分之一之间比较正常。写入过程加个进度条,`tqdm` 包一下,大文档库写入时心里有数。 ## 2.5 检索链搭建:Retriever、MMR、相似度检索、元数据过滤与多路召回 Retriever 的获取很简单,`vectorstore.as_retriever()` 一行代码。默认是相似度检索,`search_kwargs` 里可以设 `k` 和 `score_threshold`。k 设 5 到 10 比较常见。我习惯先用 k=5 跑基线,看召回的内容够不够回答问题。如果答案经常缺关键信息,把 k 加到 10 或 15,同时观察噪声有没有变多。score_threshold 设 0.5 到 0.7 之间,低于阈值的片段直接丢掉,能减少无关内容干扰。 MMR 检索是我常用的变体,全称最大边际相关性。它在相似度和多样性之间做平衡,避免返回一堆内容重复的片段。`search_type="mmr"`,`fetch_k` 设 20,`lambda_mult` 设 0.5。`fetch_k` 是先去取 20 个候选,再从里面挑 5 个相关性高且彼此差异大的。我处理过一批产品需求文档,很多段落都在重复同一个功能描述,用 MMR 之后检索结果覆盖面明显变好,模型回答时也能综合多个角度的信息。 元数据过滤和多路召回是进阶玩法。如果文档库里有多个来源,比如技术文档和客服工单混在一起,可以在检索时加 `filter={"source": "tech_doc"}`,只搜技术文档。多路召回则是同时跑向量检索和关键词检索,比如 BM25,把两路结果合并去重。LangChain 里有 `EnsembleRetriever` 可以把多个 retriever 组合起来,用 `weights` 分配权重。我试过向量检索权重 0.7,BM25 权重 0.3,对于包含专有名词的问题,混合检索的命中率比纯向量高出一截。 ## 2.6 生成链与对话记忆:LLM接入、Prompt模板、历史会话、引用来源与流式输出 LLM 接入用 `ChatOllama` 或者 `ChatOpenAI` 兼容接口。本地模型我一般设 `temperature=0.1`,降低随机性,让回答更稳定。`max_tokens` 设 1024 到 2048,本地知识库问答不需要长篇大论。Prompt 模板我习惯写成这样:开头一句“你是一个基于本地知识库的问答助手,只根据提供的上下文回答问题”,中间放 `{context}`,然后 `{question}`,结尾加一句“如果上下文没有足够信息,请直接说‘根据现有资料无法回答’”。这个模板我迭代了十几版,现在这版对幻觉的抑制效果最好。 对话记忆用 `ConversationBufferMemory` 或者 `ConversationSummaryMemory`。前者保留完整历史,适合短对话;后者对历史做摘要,适合长对话。`memory_key` 要和 prompt 里的占位符对应。我通常会在 prompt 里加一个 `{chat_history}` 占位符,让模型能看到之前聊了什么。引用来源这块,我会在 prompt 里要求模型在答案末尾加上“来源:文档名”。检索到的每个片段都带 `metadata['source']`,模型看到这些信息后能自然引用。流式输出用 `streaming=True`,配合 `callbacks` 把 token 逐个打到终端,用户体验比等全部生成完再显示好很多。 我踩过一个坑:对话记忆和检索链一起用时,历史消息里的旧问题会干扰当前检索。比如用户先问“RAG 是什么”,再问“它怎么部署”,如果直接把历史拼进检索 query,向量库可能召回一堆 RAG 基础概念的文档,而不是部署相关的。我的解法是用 `ConversationalRetrievalChain`,它会先用 LLM 把当前问题和历史对话改写成独立的检索 query,再去检索。这个改写步骤很关键,能大幅提升多轮对话的检索准确率。 ## 2.7 本地知识库问答实战:构建可交互CLI或Web UI CLI 版本最简单,一个 `while True` 循环加 `input()` 就能跑。我会加几个命令:`/quit` 退出,`/clear` 清空记忆,`/sources` 显示上一次回答引用的文档片段。代码结构上,把检索链和生成链封装成一个 `RAGChatbot` 类,初始化时加载向量库和模型,`chat()` 方法接收问题返回答案。终端输出用 `rich` 库美化一下,答案和来源用不同颜色区分,长时间看不会太累。 Web UI 我首选 Gradio,几行代码就能搭一个聊天界面。`gr.ChatInterface` 直接对接 `chat()` 方法,自带历史记录和流式显示。如果想定制,用 `gr.Blocks` 自己拼布局:一个聊天窗口,一个输入框,一个侧边栏放参数调节滑块,比如 `top_k`、`temperature`、`score_threshold`。Streamlit 也不错,写起来更灵活,但流式输出支持稍弱。我自己的项目里用 Gradio 最多,部署也方便,`demo.launch(server_name="0.0.0.0")` 就能让局域网访问。 实战中我建议加一个“调试模式”。界面上放一个开关,打开后每次回答下面显示检索到的原始片段和相似度分数。这个功能在排查“为什么答案不对”时特别有用。有一次用户反馈某个问题回答错误,我打开调试模式一看,检索到的片段全是另一个主题的文档,原因是那批文档的 Embedding 质量太差。没有调试模式的话,只能靠猜。 ## 2.8 优化与部署:重排、混合检索、缓存、增量更新、权限控制与常见问题排查 重排是提升效果最明显的一步。检索回来的 Top-K 片段用交叉编码器重新打分,比如 `BAAI/bge-reranker-base`,它会把 query 和每个片段拼在一起过一遍模型,输出相关性分数。我一般先检索 20 个候选,重排后取前 5 个送给 LLM。加上重排之后,答案准确率能涨十到十五个百分点。代价是延迟增加,本地 CPU 跑重排模型大概多花 200 到 500 毫秒,GPU 上几乎无感。这个投入产出比很划算。 混合检索和缓存是工程优化的重点。混合检索把向量检索和 BM25 结合,用 `EnsembleRetriever` 加权合并。BM25 对专有名词、型号、代码标识符特别敏感,向量检索对语义相近但用词不同的情况更擅长。两者互补。缓存用 `GPTCache` 或者简单的内存字典,把高频问题的答案存下来。我做过统计,内部问答系统里前 20% 的高频问题占了 60% 的查询量,缓存命中后响应时间从两秒降到几十毫秒。 增量更新和权限控制是部署时绕不开的。增量更新我靠文件修改时间判断,每次启动扫描 `data/` 目录,只对新增或修改过的文件重新切分和向量化,已有的块不动。Chroma 支持按 `ids` 删除,把旧文件的块删掉再写入新的。权限控制用元数据过滤实现,每个 Document 的 `metadata` 里加一个 `access_level` 字段,检索时根据用户身份加 `filter={"access_level": {"$lte": user_level}}`。常见问题排查我整理了一个清单:检索不到内容查 Embedding 模型和切分参数,答案不准确查重排和 Prompt,响应太慢查模型量化和缓存,内存溢出查向量库加载方式和批处理大小。每次都按清单走,能省不少瞎折腾的时间。
赞0
踩0
☆收藏0
版权声明
文章版权声明:除非注明,否则均为ZBLOG原创文章,转载或复制请以超链接形式并注明出处。
分享到
chuanbook

链接已复制到剪贴板