尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

从零实现 RAG 本地文档问答系统:完整链路与工程实践

发布时间:2026/9/1 17:31:05

资讯中心
01
ARTICLE

从零实现 RAG 本地文档问答系统:完整链路与工程实践

从零实现 RAG 本地文档问答系统:完整链路与工程实践
在 AI 应用落地过程中RAG 是当前最实用的一条技术路线。很多同学学完大模型 API 调用后下一步就是想把本地文档、企业知识库接进模型让模型基于真实资料回答问题。但真正开始做时会发现RAG 不只是“把文档扔进向量库”那么简单文档怎么清洗、切多长、用哪种向量化方式、检索阈值怎么定、Prompt 怎么写每一步都影响最终效果。这篇文章围绕 RAG 完整落地链路从核心概念讲起逐步拆解一个可运行的本地文档问答系统。代码会完整给出配置思路、参数含义、常见坑点也会一并说明。无论你是刚开始接触 AI 应用开发还是已经在实际项目中做知识库问答都能从中获得一个可以直接复用的工程方案。1. 背景与核心概念LLM、GenAI 与 RAG1.1 大语言模型只是起点大语言模型Large Language ModelLLM本质上是基于海量文本训练的概率模型它的能力来自训练数据中的统计规律。你问它“什么是事务”它能给出看起来很专业的回答但如果问它“我们公司内部运维手册里第三步是什么”它就为难了因为训练数据里根本没有这部分内容。这里引出了大模型在实际业务中面临的三个问题知识时效性差训练数据有截止时间模型不知道最新信息。私有知识缺失企业内部文档、个人笔记、专业协议模型从未见过。幻觉问题模型不知道答案时会用流畅的语言“编”一个答案而且往往不容易被发现。生成式人工智能GenAI应用要落地不能只靠模型本身的参数知识必须把外部知识接进来。RAG 就是目前解决这个问题的成熟方案。1.2 RAG 解决什么问题RAG 的全称是 Retrieval-Augmented Generation也就是检索增强生成。它的核心思路很直接模型回答问题之前先从外部知识库中检索出与问题最相关的资料片段把这些片段作为上下文拼进 Prompt再让模型基于这些上下文生成回答。这样做带来几个明显好处回答内容有依据模型不再是“凭记忆编造”而是参考给定资料作答。知识可以动态更新替换或新增文档后不需要重新训练模型。支持私有化知识接入企业内部资料不必暴露给模型训练过程。降低幻觉概率当 Prompt 中明确给出相关资料时模型倾向于遵循资料内容。RAG 的典型应用场景包括企业文档问答、客服知识库、政策法规检索、运维手册辅助、学术论文查阅、代码仓库语义搜索等。1.3 RAG 与微调怎么选很多初学者会把 RAG 和微调搞混简单做一个区分对比维度RAG微调Fine-tuning知识来源外部文档动态检索修改模型内部权重更新成本替换文档即可需要重新训练适合场景知识库问答、事实型查询改变模型风格、格式、特定任务能力幻觉风险相对较低仍可能产生幻觉成本需要向量库和检索链路需要训练资源和数据集实际项目中RAG 和微调并不是二选一。常见做法是先用 RAG 解决知识接入再用微调解决输出格式、语气、领域术语等风格问题。对大多数业务场景来说先用好 RAG 往往能解决 80% 的问题。2. 环境准备与版本说明2.1 开发环境与依赖本文示例使用 Python 编写需要准备以下环境Python 3.9 或更高版本一个可用的 OpenAI 兼容 API 服务也可以使用国内模型的兼容接口pip 包管理工具我建议先创建一个独立的虚拟环境避免依赖冲突python -m venv rag-env source rag-env/bin/activate # Windows 下使用 rag-env\Scripts\activate pip install --upgrade pip示例依赖如下版本需要根据你的项目实际情况调整pip install openai1.0.0 pip install faiss-cpu1.7.4 pip install numpy1.24.0 pip install pypdf3.17.0 pip install python-dotenv1.0.0这里说明一下各依赖的作用openai官方 Python SDK用于调用 Embedding 模型和对话模型。很多第三方模型服务都提供 OpenAI 兼容接口所以用它最通用。faiss-cpuFacebook 开源的向量检索库支持本地向量索引与相似度检索。numpy向量数值计算。pypdf读取 PDF 文本内容。python-dotenv从.env文件读取 API Key 等敏感配置。2.2 模型接入方式本文代码通过环境变量配置模型接口这样不会把密钥写死在代码里。创建.env文件OPENAI_API_KEYsk-xxxxxxxxxxxxxxxx OPENAI_BASE_URLhttps://api.example.com/v1 EMBEDDING_MODELtext-embedding-3-small CHAT_MODELgpt-4o-mini如果你的模型服务不需要自定义地址可以不设置OPENAI_BASE_URLSDK 会使用默认地址。请以你实际可访问的服务为准。2.3 示例项目结构rag-project/ ├── .env ├── requirements.txt ├── rag_system.py └── docs/ └── sample.pdfdocs目录用来放待检索的文档rag_system.py是完整的 RAG 问答程序。3. RAG 核心原理拆解3.1 文档加载与清洗RAG 的第一步是把文档内容读进来。常见格式包括 PDF、Word、Markdown、TXT、HTML 等。不同格式处理难度差别很大TXT、Markdown 最容易直接按编码读取。PDF 取决于文件是文本型还是扫描型文本型可以直接抽取扫描型需要 OCR。Word 文档建议转成纯文本或 Markdown 后再处理避免样式干扰。HTML 需要去除标签、脚本、样式保留正文。文档清洗是很多人忽略的环节。如果文档里有大量页眉页脚、重复水印、乱码字符这些内容会被切分并向量化检索时就会返回大量噪音片段。清洗的目标只有一个让进入向量库的每一段文本都是“干净、完整、可读”的。3.2 文档切分策略文档清洗完成后需要切分成若干块Chunk。为什么不能整篇文档直接向量化因为 Embedding 模型对输入长度有限制而且整篇文档向量化后语义会被稀释检索时很难精准匹配到局部答案。切分策略有几个关键参数chunk_size每个块的最大字符数或 Token 数。overlap相邻块之间的重叠长度。切分粒度按字符、按句子、按段落、按 Markdown 标题结构。实际经验是块太短比如 200 字符容易丢失上下文检索到片段但看不懂在说什么。块太长比如 2000 字符单个块包含多个主题检索命中率会下降。重叠长度一般设置为chunk_size的 10% 到 20%目的是保证被截断的句子在相邻块中依然完整。更高级的做法是按照文档本身的标题结构切分。比如一篇操作手册先按一级标题分段再按二级标题细分这样每个块都有明确的主题边界。这部分可以使用 LangChain 等框架完成但核心思想是一致的。3.3 Embedding 与向量检索切分完成后每个文本块需要转换成向量这就是 Embedding。Embedding 模型会把文本映射到一个高维向量空间语义相近的文本在向量空间中距离更近。向量化之后用户问题也要做同样的转换然后在向量库中查找与问题最相似的文本块。常用的相似度计算方法包括余弦相似度关注向量方向不关心模长最常用。内积对归一化后的向量内积等价于余弦相似度。欧氏距离值越小越相似使用场景相对少。FAISS 是本地向量检索的常见选择它支持多种索引类型。本文示例使用IndexFlatIP配合向量归一化实现余弦相似度检索。这个索引是暴力精确检索数据量不大时效率完全够用如果数据量达到百万级别再考虑IVF、HNSW等近似索引。3.4 Prompt 组装与生成检索到相关文本块之后需要把原始问题和检索结果组装成一个 Prompt送给大模型生成答案。Prompt 组装有几个要点明确告诉模型“根据参考资料回答”。给出检索到的文本块并标注编号。约束模型如果资料中没有答案要明确说不知道不要编造。控制 temperature事实型问答建议设置为 0.2 左右。一个典型 Prompt 模板如下请根据以下参考资料回答问题。如果资料中没有答案请明确说资料中未找到相关信息。 参考资料 [1] ...... [2] ...... 问题...... 回答这个设计看似简单但对效果影响很大。很多没有做过 RAG 的同学把检索结果直接丢给模型不加以约束模型还是会自由发挥。加上约束之后幻觉问题会大幅减轻。4. 完整实战实现一个本地文档问答系统下面我们来实现一个完整可运行的 RAG 问答系统。这里不依赖 LangChain 等重框架只使用 OpenAI SDK 和 FAISS代码逻辑更透明方便理解每个环节。4.1 安装依赖先把依赖写入requirements.txtopenai1.0.0 faiss-cpu1.7.4 numpy1.24.0 pypdf3.17.0 python-dotenv1.0.0执行安装pip install -r requirements.txt4.2 文档加载模块创建rag_system.py先编写文档加载逻辑# 文件路径rag-project/rag_system.py import os import dotenv from pypdf import PdfReader dotenv.load_dotenv() def load_pdf(file_path): 读取 PDF 文件返回纯文本内容 reader PdfReader(file_path) pages [] for page in reader.pages: text page.extract_text() if text and text.strip(): pages.append(text.strip()) return \n.join(pages)这里几个设计点dotenv.load_dotenv()会把.env中的配置加载到环境变量。page.extract_text()返回当前页文本如果是空白页会返回空字符串。把多页文本用换行符连接避免页与页之间粘连。如果你的文档不是 PDF而是 TXT可以换成def load_txt(file_path): with open(file_path, r, encodingutf-8) as f: return f.read()4.3 切分与向量化模块接下来实现文本切分和向量化def split_text(text, chunk_size800, overlap100): 按字符长度切分文本带重叠区间 chunks [] start 0 while start len(text): end min(start chunk_size, len(text)) chunks.append(text[start:end]) if end len(text): break start end - overlap return chunks这个切分方式是基础版按固定字符长度滑动切分。生产环境建议替换为更智能的按段落或标题切分但基础版已经能说明问题。然后创建 Embedding 调用函数from openai import OpenAI client OpenAI( api_keyos.getenv(OPENAI_API_KEY), base_urlos.getenv(OPENAI_BASE_URL) or None, ) def embed_texts(texts, modelNone): 批量获取文本向量 model model or os.getenv(EMBEDDING_MODEL, text-embedding-3-small) response client.embeddings.create(modelmodel, inputtexts) return [item.embedding for item in response.data]注意input支持批量传入多个文本但需要确认你使用的模型服务是否支持。部分兼容接口对批量数量有限制分批调用会更稳妥。4.4 向量存储与检索模块使用 FAISS 构建向量索引import faiss import numpy as np class VectorStore: def __init__(self, dimension): dimension 是向量维度 self.index faiss.IndexFlatIP(dimension) self.chunks [] def add(self, embedding, chunk): 新增一条向量和对应文本 vec np.array([embedding], dtypefloat32) faiss.normalize_L2(vec) # 归一化后内积等价于余弦相似度 self.index.add(vec) self.chunks.append(chunk) def search(self, embedding, top_k3): 检索最相似的 top_k 个文本块 vec np.array([embedding], dtypefloat32) faiss.normalize_L2(vec) scores, indices self.index.search(vec, top_k) results [] for score, idx in zip(scores[0], indices[0]): if 0 idx len(self.chunks): results.append({ chunk: self.chunks[idx], score: float(score), }) return results代码说明IndexFlatIP是内积索引向量经过 L2 归一化后内积结果就是余弦相似度。search返回相似度和文本块调用方可以拿到原始内容用于 Prompt 组装。这里把文本块保存在 Python 列表中进程重启后索引会丢失。生产环境建议持久化到磁盘或使用专门的向量数据库。4.5 检索与生成模块最后编写检索问答逻辑def build_prompt(question, docs): 根据检索结果构建 Prompt context_parts [] for i, doc in enumerate(docs, start1): context_parts.append(f[{i}] {doc[chunk]}) context \n\n.join(context_parts) prompt f请根据以下参考资料回答问题。如果资料中没有答案请明确说资料中未找到相关信息。 参考资料 {context} 问题{question} 回答 return prompt def ask(question, store, top_k3, modelNone): 执行 检索 - 组装 - 生成 完整流程 # 1. 问题向量化 query_vector embed_texts([question])[0] # 2. 向量检索 docs store.search(query_vector, top_ktop_k) # 3. 组装 Prompt prompt build_prompt(question, docs) # 4. 调用大模型生成 model model or os.getenv(CHAT_MODEL, gpt-4o-mini) response client.chat.completions.create( modelmodel, messages[ {role: system, content: 你是一个严谨的文档问答助手只依据参考资料回答。}, {role: user, content: prompt}, ], temperature0.2, ) answer response.choices[0].message.content return answer, docs这里有几个工程细节top_k控制检索返回数量太小可能漏掉关键信息太大会引入噪音。temperature0.2让输出更稳定适合事实型问答。返回docs是为了让调用方知道答案来自哪些文本块方便做引用溯源。4.6 运行入口与验证补上主流程if __name__ __main__: pdf_path docs/sample.pdf print(正在加载文档...) text load_pdf(pdf_path) print(正在切分文本...) chunks split_text(text, chunk_size800, overlap100) print(f共切分为 {len(chunks)} 个文本块) print(正在生成向量并建立索引...) vectors embed_texts(chunks) store VectorStore(dimensionlen(vectors[0])) for vec, chunk in zip(vectors, chunks): store.add(vec, chunk) print(索引完成) question 这份文档主要讲了什么 print(f\n问题{question}) answer, docs ask(question, store) print(f回答{answer}) print(\n参考依据) for i, doc in enumerate(docs, start1): print(f[{i}] 相关度 {doc[score]:.4f}) print(doc[chunk][:200]) print(---)运行命令python rag_system.py预期输出大致如下正在加载文档... 正在切分文本... 共切分为 12 个文本块 正在生成向量并建立索引... 索引完成 问题这份文档主要讲了什么 回答这份文档主要介绍了系统部署的步骤和注意事项包括环境要求、依赖安装、配置文件说明等。 参考依据 [1] 相关度 0.8213 ...如果你的问题和文档内容不相关按照我们的 Prompt 设计模型应该会回答“资料中未找到相关信息”而不是强行编造。5. 进阶优化让 RAG 更精准基础版 RAG 能跑通但离生产可用还有一段距离。下面几个优化方向是实际项目中最常涉及的内容。5.1 混合检索纯向量检索的缺点是它为语义相似而设计但有时用户问的是“文档编号 ABC-10086”这种精确匹配文本检索更合适。混合检索的思路是同时执行向量检索和关键词检索如 BM25再把两部分结果合并去重后排序。这样既能根据语义找到同义表达也能根据关键词精确命中编号、型号、专有名词。实现上通常是两路检索并行检索方式优势适合场景向量检索理解语义、容忍同义改写自然语言提问关键词检索精确匹配、可解释编号、型号、固定术语两部分结果合并后可以使用加权分数融合也可以直接交给重排序模型。5.2 重排序向量检索阶段返回的前top_k个文本块不一定真的和问题最相关因为向量相似度和“相关性”有时并不完全一致。重排序的思路是先用高效但相对粗糙的检索召回一批候选比如 20 个再用更强的 Cross-Encoder 模型对候选做精细打分只保留前 3 到 5 个。为什么top_k不能设置太大因为 LLM 对输入长度有限制而且塞入过多无关文本会干扰生成。重排序是解决“先召回、再精排”的标准做法。5.3 引用溯源知识库问答比较大的风险是模型回答错误但你不知道它依据的是哪段资料。改进方式是让 RAG 系统在返回答案的同时返回引用的原文片段和来源文档路径。在 Prompt 中要求模型标注引用编号请根据参考资料回答问题并在每个观点后标注来源编号例如 [1]。同时在代码中保留每个文本块的元数据比如章节标题、页码、来源文件名。这样答案的每个关键句都能追溯到原文使用者可以自行核对。对于企业知识库、法律文档、审计场景这项能力几乎是必须的。5.4 知识库评估指标很多人做完 RAG 想知道“效果到底怎么样”此时需要一套评估指标。常见指标如下指标关注点简单理解召回率Recall检索结果是否覆盖正确答案正确答案有没有被检索出来精确率Precision检索结果是否够干净检索出来的内容里有多少是噪音MRR倒数排名首个正确答案的排序位置答案是不是排在前面忠实度Faithfulness生成内容是否忠实于检索资料回答是否严格基于上下文答案相关性生成内容是否对得上问题答非所问的程度实际评估时建议先人工准备一组“问题-标准答案-相关文档片段”的测试集然后跑通系统统计指标。RAG 效果不好时不要盲目调 Prompt先用指标定位到底问题出在检索阶段还是生成阶段。如果召回率很低优先优化切分和检索如果召回率正常但答案仍然不准确再调整 Prompt 和生成参数。6. 常见问题与排查思路RAG 系统的错误往往不是“崩溃”而是“看起来正常但答案不对”。下面整理一张速查表再挑几个典型问题展开分析。问题现象常见原因解决思路检索结果完全不相关文档切分过大或过小调整 chunk_size / overlap观察召回效果PDF 内容提取为空扫描版或图片型 PDF先做 OCR 预处理回答内容明显编造检索结果未命中正确答案降低 top_k、优化切分、增加 Prompt 约束报错向量维度不匹配更换了 Embedding 模型删除旧索引重建统一 Embedding 模型接口超时或限流请求并发过高增加重试、限速、超时配置多轮对话上下文丢失没有维护对话历史引入消息摘要或历史记录拼接检索总是返回同一批内容文档中重复内容太多清洗去重按标题二次切分6.1 “检索结果不相关”的排查先确认问题能不能复现。如果同一个问题反复检索不到正确内容打印检索到的前 5 个文本块人工看看这些内容和问题是否有关。如果没有一个相关说明召回环节有问题检查切分粒度是否合适。如果有相关文本块但排在后面说明相似度计算不理想尝试换 Embedding 模型或加关键词检索。检查文档内容是否被错误清洗比如表格被拆得支离破碎导致语义丢失。6.2 “模型回答仍然在编”的排查这说明生成环节没有被约束住。检查 Prompt 中是否明确要求“资料没有答案时要如实说明”确认传入的参考文本块是否真的出现在了 Prompt 中确认top_k是否太小导致上下文缺失。另外要注意即使检索到了相关资料如果资料本身与问题只是“表面相关”模型也可能结合训练知识自由发挥。所以生成阶段建议把 temperature 调低必要时增加“请完全基于参考资料回答不要加入你自己的知识”的强约束。6.3 “PDF 读取为空”的处理pypdf只能提取文字型 PDF 的文本扫描版 PDF 本质是图片需要用 OCR 工具识别。常见的 OCR 流程是先把 PDF 每页转成图片再用 OCR 模型提取文字。这类场景对硬件和预处理要求较高建议单独封装成文档处理管道而不是塞进主流程。7. 最佳实践与工程建议7.1 配置与密钥管理API Key 不要写在代码里也不要提交到 Git 仓库。使用.env文件管理并在.gitignore中忽略它.env敏感配置通过环境变量注入不同环境开发、测试、生产使用不同的 Key 和 Base URL。如果团队共用账号建议使用服务商提供的子 Key 或临时凭证避免密钥泄露后无法单独吊销。7.2 文档更新与索引治理知识库不是一次性建好的。文档会更新、过期、删除如果索引不跟着更新就会出现“明明文档已经改了问答结果还是旧的”的情况。生产环境中需要设计索引更新策略全量重建适合数据量不大、更新频率低的场景最简单也最稳妥。增量更新按文档 ID 或文件指纹判断哪些内容变更只更新变更部分适合大规模知识库。版本管理给文档增加版本号索引中同时保留版本信息方便追溯。无论是哪种方式更新前建议先备份旧索引新索引验证通过后再切换避免线上问答直接不可用。7.3 成本与性能RAG 的主要成本来自 Embedding 调用和对话生成调用。几个降低成本的思路对文本块和查询结果做缓存相同或相似问题直接命中缓存。控制检索返回数量避免每次塞入过多 Token。对话模型和 Embedding 模型可以选择不同规格不是越贵越好。对高频问题做单独优化比如整理成 FAQ 直接检索。性能方面如果向量库数据量增长到百万级建议从 FAISS 换成分布式向量数据库并增加连接池、超时、重试等基础设施配置。7.4 安全边界RAG 系统会把文档内容暴露给模型因此权限控制非常重要。具体注意以下几点接入鉴权知识库问答 API 需要用户认证不同用户可能有不同的文档访问范围。检索权限不要让所有用户检索到全部文档要在检索阶段就做权限过滤而不是生成后再删。数据合规涉及敏感信息的文档进入向量库前需要先确认合规要求是否允许发送给外部模型服务。输出审核部分场景需要增加输出审核环节防止模型把内部资料完整复述给越权用户。安全不是事后补救而是从文档接入阶段就要考虑的设计约束。7.5 生产部署前的检查清单上线一个 RAG 知识库问答前建议逐项确认文档是否完成清洗和格式校验文本切分策略有没有用真实问题验证过模型接口的 key、base_url 是否正确注入向量索引是否支持持久化和重建检索结果是否带引用来源方便用户核对权限与数据隔离是否做了测试接口是否有超时、重试、限流、日志是否存在成本失控和 Prompt 注入风险8. 后续学习路线8.1 从 RAG 到 Agentic RAG基础 RAG 是一次“检索-生成”的单次流水线。但在真实场景中用户问题往往很复杂比如“先查故障码再找对应的操作步骤最后对比历史案例”。这时候一次检索就不够用了。Agentic RAG 的思路是让大模型自主规划检索过程它可以根据中间结果决定是否需要再次检索、使用哪种检索工具、如何综合多轮检索结果。相比固定流程这种方式在复杂问题上表现更好但工程复杂度也更高需要设计可观测的任务编排和防死循环机制。8.2 关注模型推理精度问题部署自建大模型时经常会接触到 FP16、BF16、FP32 这些精度概念。简单理解FP32精度高显存占用大推理速度相对慢。FP16显存占用减半但表示范围有限容易出现溢出。BF16表示范围和 FP32 相同适合大模型训练和推理但尾数精度较低。实际项目中模型量化、精度选择会直接影响问答效果和部署成本。如果你计划从 API 调用转向本地部署建议系统学习精度和推理优化的基础避免“为什么本地模型效果比 API 差”这类问题无从下手。8.3 动手做一个端到端项目学 RAG 最好的方式是动手做完整的项目。这里给出一个可以逐步深入的路线先复现本文的代码换自己的文档跑通基础问答。增加混合检索和重排序对比效果变化。增加引用溯源输出来源文档和原文片段。准备一组测试集计算召回率、MRR、忠实度等指标。把代码封装成 Web 服务设计鉴权和权限过滤。尝试引入记忆、多轮对话、主动追问向 Agent 方向演进。我建议你从一个小领域开始不要太快追求复杂架构。先把检索准确性、回答质量、指标评估这三点做好再去研究更复杂的编排与优化这样每一步都能看清效果差异也更容易沉淀出可复用的工程经验。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。