RAG 这个词这两年已经被说烂了但很多人卡在同一个地方调 API 总觉得数据往外走不安全自己本地部署个大模型又不知道检索那套逻辑怎么接。我最近把一个知识库问答系统完全跑在了本地用的就是 Ollama 挂嵌入模型加本地生成模型整体链路从文档切片到语义检索再到答案生成全部落在一台普通开发机上。这篇文章就围绕这条链路把从零到能跑通的全过程拆开讲清楚包括我踩过的坑和最后调优用到的参数。这个方案适合谁如果你手里有一批私有文档想做一个问文档的对话工具又不想依赖外部服务同时对 Python 有一定基础但在 RAG 上还没完整实操过那这篇文章就是照着抄作业用的。我会把每个环节为什么这么设计、参数为什么这么定都交代清楚而不是只贴一段能跑但不知道原理的代码。1. 整体设计拆解先想清楚 RAG 的落地边界1.1 RAG 到底解决了什么问题大模型的知识是训练时固化的你的私有文档、最新规范、项目内部沉淀它都没见过。常见做法是微调但微调的成本和迭代周期都太高文档一变就得重训。RAG 换个思路不改变模型权重而是把用户的问题先拿去文档库里做一次语义检索把最相关的片段捞出来然后把这些片段塞进提示词让模型基于这些材料来回答。这套逻辑用一句话说就是先查后答。模型不需要记住你的文档内容只需要在回答时看得见相关内容就行。好处非常直接文档更新后重新向量化一次就好模型本身不用动回答时还能追溯引用了哪几段原文这在很多内部场景里是刚需。但 RAG 不是银弹。检索这步如果捞回的片段不对生成再强也是瞎编。我见过很多人一上来就搭了个漂亮界面结果一问三不知问题基本都出在切片策略和检索排序上。后面我会专门讲这两个坑这里先记住一个原则RAG 的上下限取决于检索质量不取决于生成模型的大小。1.2 为什么生成和嵌入都要本地化数据不出内网这个需求在很多场景里不是选择项而是硬约束。把文档传到外部接口做向量化再调用云端模型做生成等于把核心资产交到别人手里。本地化的好处有两层一是数据链路完全自主文本从切分到向量化到生成都不离开本机二是没有按 token 计费的压力跑起来就是一个电费适合文档量中等、查询频率高但并发要求不高的场景。Ollama 在这条链路里扮演的角色是模型运行时负责拉起本地推理服务同时提供调用接口。它本身不做检索也不管向量化但把模型管理、显存调度、监听端口这些都收拢了让你可以像调用本地服务一样调用模型省去自己写推理代码的麻烦。嵌入模型通常选轻量级的一个就够。我实测下来百万字符级别的文档库用轻量嵌入模型完全能抗住检索质量并不会因为嵌入模型比商用小一号就明显掉链子。真正影响效果的是切分方式和重排策略这点我后面会用数据对比说明。1.3 整体链路与关键模块划分我最终跑通的链路是这样的原始文档进入预处理模块按语义切分成若干片段每个片段经过嵌入模型变成向量写入向量数据库用户发起查询时查询文本同样向量化然后去向量库里做相似度检索取回 TopK 片段这些片段拼接进提示词模板连同原始问题交给本地生成模型最终输出答案。链路里你会接触到的核心组件就四个文档加载器、文本切分器、向量化模块和生成模块。向量数据库在这个量级下甚至可以简化为一个本地文件没必要一开始就上重型服务。我在实现里用的是一个轻量局部安装就可以承担十万级向量检索的组件完全够用。很多人一上来就搭分布式纯属给自己找事。2. 环境准备与核心工具安装2.1 安装 Ollama 并拉取本地模型Ollama 的安装本身没什么门槛一条命令就能装好。装完后先验证服务能否正常启动通过一个简单请求确认接口通了再往后走。我用的是本地模型的默认服务端口启动后会在后台常驻第一次调用时会加载对应模型。模型拉取是很多人容易忽略的一步。先想清楚你的机器配置再决定模型大小。我手头是一台 16G 内存、8G 显存的机器跑 7B 级别的量化模型毫无压力生成速度在并发低的时候也能接受。如果你的机器只有 CPU 没有独立显卡也不是不能跑只是生成会慢一些建议把模型尺寸再降一档同时把上下文长度调短一点减少推理压力。生成模型我选的是一个支持中文效果不错的 7B 模型嵌入模型选的是专门做向量化的轻量模型。两个模型各司其职不要图省事用同一个模型做嵌入和生成。嵌入模型的任务是把你的一句话变成一个固定维度的向量它不需要会说话只需要让语义相近的文本在向量空间里挨得近生成模型的任务才是组织语言回答问题两者对模型能力的诉求完全不同。提示拉取模型时注意一下量化版本。同一个 7B 模型往往有多个量化级别显存吃紧就选压缩更狠的版本效果损失在问答场景里不太明显但显存占用能降下一大截。2.2 嵌入模型与生成模型的搭配逻辑嵌入模型的核心指标是语义区分度。你可以做个最简单的自测把两句话编码成向量算一下余弦相似度。今天天气不错和明天下雨记得带伞应该比较低而今天天气不错和今天天气很好应该非常高。如果连这种基本区分都做不好那这个嵌入模型就不能用。在本地 RAG 场景里我推荐直接用语义嵌入模型而不是传统的词频向量。词频向量只看有没有共同的词语义上相关但用词不同的文本它匹配不到。比如用户问这个项目什么时候验收文档里写的是项目交付时间词面完全不同但语义是同一回事。语义嵌入模型能把这两句话映射到接近的向量位置这才是语义检索这个名字的真正含义。生成模型的大小要根据你的文档复杂度和回答要求来定。如果只是做内部制度问答答案大多能从文档片段里找到原文7B 模型足够如果要求模型做归纳总结、跨片段推理同样文档长度下更大一点的模型会更稳。但注意模型越大显存占用越高加载后留给向量检索和上下文的空间就越小需要做取舍。2.3 向量存储选型先别急着上重型组件很多教程一上来就让你装向量数据库服务还要启动容器、配置端口、建集合这一套操作对一个小型知识库来说完全是杀鸡用牛刀。我第一版实现用的是本地文件型向量存储把所有向量和原文片段按索引号存在本地检索的时候全量算一遍相似度。文档片段在几千条以下时这个方案毫秒级返回根本不需要引入额外服务。如果你的文档量到了几十万条片段这个级别再考虑换分布式向量库。从文件到服务的升级路径很平滑因为你的业务代码依赖的是按向量检索 TopK这个抽象接口换成什么都只需要改存储实现。先跑通链路再按需升级这是我做本地 RAG 的一个核心经验。注意无论用哪种向量存储都要维护好原文片段和向量之间的对应关系。检索召回的是向量索引最终拼进提示词的是索引对应的原文文本。这两者的映射关系一旦错位整个生成环节都会崩。3. 核心实现从切片到检索的完整链路3.1 文档预处理与切分策略文本切分是 RAG 里最容易被低估的一个环节。切得太粗一个片段里塞了好几层意思检索时匹配到的范围太宽噪音太多切得太细单个片段可能连一个完整的结论都表述不清楚模型拿到的是残缺信息。我踩过最典型的坑是直接按固定字符数切结果把表格从中间拦腰截断后面所有相关提问都答不对。比较实用的做法是按文档结构切分比如一级标题、二级标题作为天然边界把每个章节作为一个大片段再在超过最大长度限制时继续往下切。如果文档没有明确标题结构就退而求其次按段落切段落之间本身就是语义相对完整的单位。还有一个细节是重叠窗口。相邻切片之间保留一小段重叠文本能避免一个完整知识点恰好卡在切片边界上。我用了大约 10% 到 20% 的重叠比例你可以理解为相邻两段共享一小段文字作为衔接这样检索时不管问题落到哪一侧都能带上上下文。代码层面我做成了这样先把整个文档按换行符拆开识别出标题行和正文行标题作为新片段的起点正文持续追加如果当前片段长度超过上限就在最近的标点处截断并把截断点前移一定量的字符作为下一段的开头形成重叠。这个过程不复杂但每一步的取舍都直接影响后续效果。3.2 向量化与入库的实现思路文本切分完成后每条片段调用一次嵌入模型生成向量。这里有个性能细节嵌入模型每次调用都在本机推理几千条片段如果逐条调用耗时可能是分钟级。解决方法很简单批量编码一次性把多条文本丢给嵌入模型处理速度能提升好几倍。向量入库前要不要做归一化我建议做。归一化就是把向量的长度缩放成 1这样用点积和用余弦相似度算出来的排序结果完全一致后续做相似度计算时省掉一次除法。很多框架会在接口内部自动归一化但你最好自己在入库前显式处理一遍避免依赖框架的隐性行为。每条向量记录我存了三样东西向量本身、原文片段、来源文件名和片段编号。第三个信息很重要当模型回答完以后你需要告诉用户这个答案来自哪个文件的哪一段没有来源信息就没法做追溯。这也是 RAG 相对于纯大模型生成的一个核心优势。3.3 相似度检索与 TopK 选择检索过程很直接把用户的问题同样编码成向量然后和库里所有向量算相似度取最高的前 K 条返回。K 值怎么选我实测 3 到 5 是一个比较稳的范围。K 太小可能漏掉关键信息K 太大提示词会被无关内容稀释模型反而找不到重点。这里我要强调一个很多人忽略的点相似度阈值不能只看绝对值。不同嵌入模型的向量分布差异很大有的模型相似度普遍偏高0.85 才算相关有的模型普遍偏低0.7 已经是很强的匹配。绝对不能拿一个网上的阈值直接用要在你自己的数据上做抽样测试找到区分相关片段和无关片段的分界线。检索方式上我第一版直接暴力算全量相似度简单可靠。文档片段数在几万以内时完全够用。需要优化的信号是响应时间变慢到无法接受这时候再考虑换近似最近邻的索引方式。不要过早优化这是我反复强调的一点。3.4 提示词拼接与生成检索出的片段如何组织成提示词直接决定生成质量。最基本的模板是请根据以下材料回答问题如果材料中没有相关信息请如实说明不知道。这句话看似简单但它是在给模型设定行为边界避免模型脱离材料自由发挥。片段拼接顺序也有讲究。我按照相似度从高到低排列最相关的放在最前面。生成模型对开头的注意力权重通常更高把关键材料放前面能提升引用准确性。片段之间用分隔符隔开并给每个片段编号提示词里写明回答时可以引用材料编号这能辅助模型做来源标注。上下文长度也要算一笔账。模型的总上下文长度有限提示词里的材料越长留给生成的空间就越少。我按最大上下文 32K 来规划材料部分控制在 8K 到 12K 以内保证模型有充足的输出余量。如果你用的是更小的上下文模型就要相应减少 TopK 数量或单片段长度。4. 完整可运行的最小实现4.1 代码骨架与关键调用下面这个实现是我实际跑通的最小版本不依赖任何重型框架核心逻辑全部手写方便你理解每一步在做什么。完整代码分成了数据处理、向量入库、检索生成三块。import requests import json import numpy as np # Ollama 嵌入模型调用 def embed_texts(texts): resp requests.post( http://localhost:11434/api/embed, json{model: 嵌入模型名, input: texts} ) resp.raise_for_status() data resp.json() vectors [v[embedding] for v in data[embeddings]] # 归一化 arr np.array(vectors, dtypenp.float32) norms np.linalg.norm(arr, axis1, keepdimsTrue) return arr / norms # 文本切分按段落与长度控制 def split_document(text, max_len800, overlap100): paras [p.strip() for p in text.split(\n\n) if p.strip()] chunks [] current for para in paras: if len(current) len(para) max_len: current para \n\n else: if current: chunks.append(current.strip()) current para \n\n if current: chunks.append(current.strip()) # 处理超长段落 result [] start 0 for chunk in chunks: while len(chunk) max_len: cut chunk.rfind(。, start, start max_len) if cut -1: cut start max_len result.append(chunk[start:cut]) start max(0, cut - overlap) result.append(chunk[start:]) return result这段代码里我特别说明一下切分逻辑先按空行切段落再对超长段落做二次截断。截断时不是死板地按字符数切而是在最近的句号处切断保证每段语义完整。重叠区间通过把起点往前挪实现实际效果是在相邻两段里各保留了一段相同的文字。# 建库与检索 class LocalVectorStore: def __init__(self): self.vectors [] self.texts [] self.sources [] def add(self, vector, text, source): self.vectors.append(vector) self.texts.append(text) self.sources.append(source) def search(self, query_vector, top_k4): scores np.dot(self.vectors, query_vector.T).flatten() top_indices np.argsort(-scores)[:top_k] return [(self.texts[i], self.sources[i], float(scores[i])) for i in top_indices] # 生成调用 def generate_answer(prompt): resp requests.post( http://localhost:11434/api/generate, json{model: 生成模型名, prompt: prompt, stream: False} ) resp.raise_for_status() return resp.json()[response]生成接口的调用我只贴了非流式版本一次性把完整答案返回。对于内部工具来说这个模式够用。如果要做类似搜索引擎那种逐字输出的体验可以开启流式模式响应会变成一个流逐片段读取即可。我后面单独讲。4.2 主流程串联上面几个函数组装起来就是完整链路。我加上一个简单的问答循环用户在命令行输入问题系统返回答案和引用来源。def answer_question(question, store): q_vec embed_texts([question])[0] hits store.search(q_vec, top_k4) materials [] for idx, (text, source, score) in enumerate(hits): materials.append(f[材料{idx1}] 来源:{source}\n{text}) material_block \n\n.join(materials) prompt ( 请根据以下材料回答问题。如果材料中没有相关信息 请直接说明无法从现有资料中找到答案。回答可以引用材料编号。\n\n f材料如下\n{material_block}\n\n f问题{question}\n\n 请回答 ) answer generate_answer(prompt) return answer, hits if __name__ __main__: store LocalVectorStore() # 加载文档、切分、向量化、入库 with open(demo.txt, r, encodingutf-8) as f: doc_text f.read() chunks split_document(doc_text) vectors embed_texts(chunks) for i, (vec, chunk) in enumerate(zip(vectors, chunks)): store.add(vec, chunk, fdemo.txt#{i}) while True: q input(请输入问题q 退出) if q.strip().lower() q: break ans, hits answer_question(q, store) print(\n回答, ans) print(\n引用片段) for i, (text, source, score) in enumerate(hits): print(f[{i1}] {source} | 相似度 {score:.4f} | {text[:80]}...\n)这个主流程虽然简单但已经具备了一个可用 RAG 的全部要素。你在本地把文档换成自己的资料跑一遍这个脚本很快就能感受到语义检索 生成和纯关键词搜索的差别。比如你问文档里从未出现过的同义表达方式时这个系统依然能定位到正确段落这在老式搜索框里是做不到的。5. 常见问题与排查技巧实录5.1 检索结果不对的五个典型原因我调试这个系统的过程中遇到最多的问题是答案看起来不对。排查下来绝大多数不是模型的问题而是上游检索和提示词组织的问题。第一个典型原因是切片粒度和文档结构不匹配。比如我把一个只有一级标题的文档按二级标题切每个片段都是半截内容检索召回的信息缺失关键上下文。解决办法是降级切分策略做一层标题识别让每个片段尽量覆盖一个完整论点。第二个原因是 TopK 太小或太大。K 选 2 时如果正确材料恰好排在第 3 位答案就完全跑偏K 选 8 时无关材料太多模型抓不住重点。我的做法是先固定 K5然后打印每次检索的实际相似度观察正确材料是否稳定出现在前三再做微调。第三个原因是提示词里材料太多导致模型迷失。我把 8 个片段全塞进去后模型反而开始自由发挥从不相干片段里找信息拼凑。压缩到前 4 个高度相关片段后回答质量明显回升。这说明并查 Arnold 不是越多越好宁缺毋滥在提示词上下文里同样适用。第四个原因是相似度阈值设置不当。如果不设阈值当问题确实不在知识库里时系统会硬找出一堆低相似度片段强行回答结果就是一本正经地胡说。我设了相似度下限低于阈值的直接返回未找到相关资料反而显得更专业。第五个原因是嵌入模型对专业术语理解不够。如果你的文档涉及大量行业黑话通用嵌入模型可能无法区分相近概念。这种情况下的解法是补充同义词典在切分时把同义表达写进原文或者换一个在领域语料上训练过的嵌入模型。5.2 报错排查与性能问题速查Ollama 拉起服务后最常见的报错是模型加载失败或超时。前者多数是因为显存不足模型加载一半就退出了解决办法是换更小的量化版本或关掉其他占用显存的应用。后者是因为首次加载模型需要时间把请求超时时间调长一些就能解决。嵌入批量调用时如果一次性传入的文本太多可能出现请求体过大或者显存峰值飙升。我建议按 50 到 100 条一批去处理既能利用批量加速又不会把资源耗尽。这个数量在不同机器上表现不同你可以在自己的环境里测一下峰值。还有一个我踩过几次的坑向量归一化不一致。入库前归一化了查询向量忘了归一化导致相似度计算出的排序和真实距离不一致。我的建议是把归一化统一封装在嵌入函数里保证所有进来的向量都走同一条处理管线省得自己记着每个环节的操作。性能方面如果检索速度从毫秒级退化到秒级先用日志确认瓶颈在哪。大多数时候瓶颈在向量全量扫描上这时可以先只做降维或缓存高频查询结果。如果生成速度慢那瓶颈在模型推理需要从模型大小和量化级别上优化。5.3 我在实际调试中积累的几个避坑习惯第一样本文档不要选太少。至少准备 20 个不同主题的问题覆盖文档里的主要章节。每调整一次切片或检索参数就跑一遍这 20 个问题记录回答质量。没有这个测试集你很难判断改动到底有用还是退化。第二相似度分数的绝对大小不要过度相信。我见过同一个模型下高相似度片段答非所问低相似度片段反而正中要害。这通常意味着文档里存在重复表述或近义干扰需要检查切片内容是否冗余。第三提示词模板不要频繁大改。每次只改一个变量比如只换材料排序方式或者只换系统提示词里的保持格式要求然后对比效果。同时改三五个变量出了问题根本定位不到原因。第四本地化部署的日志记录非常重要。我给每轮问答都记录了查询向量、召回片段、相似度分数、最终答案保存到日志文件。后面做回归测试、优化参数时这些历史记录就是最可靠的依据。6. 效果优化与后续扩展6.1 提升检索质量的实测建议当你把基础链路跑通之后最先值得投入的优化点是重排。第一次检索可以适当放宽召回范围比如先取回 20 条候选再用一个轻量重排模型对候选重新打分选出前 4 条真正高质量的片段。这个先粗排、再精排的两阶段策略在实际效果上比直接加大 TopK 要好得多。第二个有效优化是查询改写。用户的问题往往带口语化表达、指代词和碎片化信息直接拿原问题去检索效果不一定好。可以花一次额外调用让生成模型先把用户问题改写成一个更完整的检索语句再用改写后的语句去查。比如用户问那第三条呢改写后会补全成项目计划的第三条要求是什么检索准确度会有很大提升。第三是切分策略精细化。我给不同文档类型准备了不同切分配置规章制度类按条款切分技术文档按标题和段落切分对话记录按说话者切分。每种配置就是一个参数模板入库前根据文档类型自动选择。这个工作初期要花些时间但对最终效果的影响是决定性的。还有一个容易被忽略的点文档去重和版本管理。知识库里同时存在旧版和新版制度时检索可能召回旧版内容导致答案来源冲突。我加了一道预处理按标题和正文哈希值做近似去重相同内容只保留最新版本。6.2 从单轮到多轮对话的扩展思路我的第一版只支持单轮问答用户每次问那这个项目的验收标准是什么都得带上完整上下文。实际使用中多轮对话是刚需。最简单的扩展方式是把历史对话记录拼进提示词让模型在理解当前问题时能参考之前几轮的内容。但多轮对话引入了一个新问题历史对话里的指代词会把检索带偏。比如用户上一轮在讨论预算表这一轮说把它改成按季度如果直接把整段历史拼进查询向量检索出来的可能是完全不相关的段落。我的解决办法是单独维护一个当前话题摘要每次把历史和最近一次检索到的材料单独拼接再做一次查询改写让检索词尽量具体化。流式输出也是体验优化的一个重要方向。Ollama 的生成接口支持流式返回把接口调用改成流式模式后前端可以一有内容就逐步展示不必等整个回答生成完。对于较长的回答这个体验差异非常明显。改造点只在我前面贴的 generate_answer 函数内部业务逻辑完全不用动。从单机到服务的扩展路径也很清晰。我的核心逻辑已经封装成三个独立模块文档处理、向量检索、生成调用。后续要接 Web 服务只需要在最外层加一个 HTTP 接口层三个模块可以原封不动复用。如果要接多用户并发才需要考虑排队和缓存策略。但在那一步之前你先把单机单人的链路调稳已经能覆盖绝大部分内部使用场景了。最后再分享一个小技巧调试 RAG 系统时不要一上来就看最终回答是否满意而是要分步检查。先看检索回来的材料对不对这一步对了再看回答是否忠实于材料。我至少有一半的排查时间花在材料明明对答案却编了这种问题上最后发现是提示词少了约束语句加一句如果没有依据请直接说不知道就解决了。这套本地方案对我来说最大的价值不是模型有多强而是每一步都可控、可查、可改。希望能给你手上的场景带来一点参考。