ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从零搭建本地RAG知识库:Ollama实战与检索优化指南

从零搭建本地RAG知识库:Ollama实战与检索优化指南 RAG 这个词这两年出现的频率实在太高了高到很多人一听到就条件反射地觉得哦不就是向量检索加个大模型嘛。但真到自己动手搭一套能用的知识库时才发现事情远没有想象中那么简单——文档切分粒度怎么定、检索回来的内容答非所问、模型明明拿到了资料却还在胡编、本地跑起来慢得像蜗牛。这些问题几乎每个从零开始做 RAG 项目的人都会撞上而网上大部分教程只告诉你装个 LangChain调个 API搞定中间那些真正决定成败的细节全被略过了。我自己前前后后搭过好几套 RAG 系统从最开始用云端 API 拼凑的玩具版本到后来在本地用 Ollama 跑起来的完整知识库踩的坑足够写一本小册子。这篇内容就是把这些经验整理出来从 RAG 到底解决了什么问题讲起一路讲到怎么用 Ollama 搭一套零基础也能复制的本地知识库中间会把检索质量、切分策略、提示词设计这些关键环节拆开揉碎地讲。不管你是刚听说 RAG 想入门还是已经搭过一版但效果不理想想优化应该都能从里面找到对自己有用的东西。1. RAG 到底在解决一个什么问题1.1 大模型的三道硬伤要理解 RAG 的价值得先搞清楚大模型本身有哪些绕不过去的限制。我把它们归纳成三道硬伤每一道都直接决定了 RAG 存在的必要性。第一道是知识截止。任何大模型都有一个训练数据的截止时间点这个时间点之后发生的事情它一概不知。你问它某个新发布的框架怎么用它要么说不知道要么更危险——一本正经地编一个看起来很像那么回事的答案。这不是模型笨是它的知识边界就在那里。第二道是私有数据盲区。你公司内部的文档、你个人的笔记、某个垂直领域的专业资料这些东西从来没有出现在公开训练语料里模型自然不可能知道。你直接问它我们产品的退款流程是什么它只能给你一个通用的、大概率不符合你实际情况的回答。第三道是幻觉。这是最要命的一点。大模型本质上是根据上下文预测下一个词它的目标是生成看起来合理的文本而不是真实准确的文本。当它不知道答案时它不会像人一样说我不确定而是会顺着语言概率编下去。在闲聊场景里这没什么但在知识问答场景里一个编造的答案可能比没有答案更糟糕。这三道硬伤有一个共同的解法思路既然模型自己不知道那就在提问的时候把相关资料一起塞给它让它看着资料回答。这就是 RAG 的核心思想说白了就是开卷考试——模型不再靠记忆答题而是拿着你给的材料来回答。1.2 从闭卷到开卷的转变传统的做法叫微调就是拿你的私有数据去继续训练模型把知识灌进模型参数里。这个方法不是不行但有几个明显的缺点成本高、周期长、数据一变就得重新训练而且训练完之后模型还是可能忘或者编。RAG 走的是另一条路。它不动模型本身而是在模型外面套一层检索系统。用户提问时系统先去知识库里找到最相关的几段内容然后把这些内容和问题一起组装成一个提示词交给模型生成答案。模型全程只负责阅读理解加组织语言知识的部分完全由外部知识库提供。这个转变带来的好处是连锁性的。知识更新只需要更新知识库不用碰模型答案可以追溯到具体的资料来源可信度大幅提升私有数据不需要进入模型训练数据安全上更可控成本上检索系统的开销远低于重新训练一个大模型。提示RAG 不是要替代微调两者解决的是不同层面的问题。微调改变的是模型的能力和风格RAG 补充的是模型的知识。很多场景下两者是配合使用的。1.3 一个完整的 RAG 流程长什么样把 RAG 拆开看它其实是一条流水线分成离线和在线两个阶段。离线阶段做的是建库。把你的原始文档PDF、Word、网页、数据库记录等等读进来切成一段一段的文本块每一块通过嵌入模型转成一个向量存进向量数据库。这个过程是一次性的文档更新时增量做就行。在线阶段做的是问答。用户提问后问题同样被转成向量去向量数据库里找最相似的若干个文本块把这些块和问题拼成提示词送给大模型生成最终答案。听起来很简单对吧但魔鬼全在细节里。切分粒度切多大、嵌入模型选哪个、检索回来几块、相似度阈值怎么定、提示词怎么写每一个环节都会显著影响最终效果。后面几节我会逐个拆开讲。2. 文档切分最容易被低估的环节2.1 为什么切分策略决定了检索上限很多人搭 RAG 时把大部分精力花在选模型和调提示词上切分环节随手用默认参数就过了。这是个典型的误区。我自己的经验是切分质量决定了整个系统的检索上限后面再怎么优化都突破不了这个天花板。道理很简单检索的基本单位是文本块。如果切分切得不好一个完整的答案被切成了两半检索时只召回了一半模型拿到的信息就是残缺的自然答不全。反过来如果一个块里塞了太多不相关的内容检索时向量的语义就被稀释了可能召回了这个块但里面真正有用的信息只占一小部分还挤占了其他有用块的召回名额。我见过一个很典型的失败案例有人把整篇技术文档按固定 1000 字符硬切结果一个关键的操作步骤正好被切在中间前半段在块 A后半段在块 B。用户问这个步骤怎么做检索只召回了块 A模型给出的答案就缺了后半截用户照着做直接报错。2.2 几种切分策略的取舍切分策略没有银弹得看你的文档类型。我把常见的几种列出来对比一下。切分策略适用场景优点缺点固定长度切分结构松散的纯文本实现简单块大小均匀容易切断语义边界生硬按段落切分段落分明的文章保留自然语义单元段落长度差异大可能过长或过短递归字符切分通用场景兼顾语义和长度可设优先级参数需要调默认值不一定合适按标题层级切分结构化文档Markdown、技术手册保留文档结构语义完整依赖文档本身有清晰结构语义切分对质量要求高的场景按语义相似度切边界最自然计算开销大实现复杂实际项目里我用得最多的是递归字符切分因为它是个很好的折中。它的思路是先尝试用最大的分隔符比如段落之间的空行切如果切出来的块还是太大就用更小的分隔符比如句号、换行继续切直到块大小落在设定范围内。这样既尊重了文档的自然结构又能控制块的大小。2.3 块大小和重叠的实操参数块大小设多少合适这个问题我被问过无数次。没有一个放之四海皆准的数字但有一些经验区间可以参考。块太小比如 100 字符以下单个块承载的信息量不够检索时容易召回一堆碎片模型拼不出完整答案。块太大比如 2000 字符以上语义被稀释检索精度下降而且会浪费模型的上下文窗口。我一般把块大小设在300 到 800 字符之间具体看文档的信息密度。技术文档信息密度高可以小一点叙述性的内容可以大一点。重叠部分也很关键。相邻的块之间保留一定的重叠可以避免关键信息正好被切在边界上导致丢失。重叠比例一般设在块大小的10% 到 20%。比如块大小 500 字符重叠设 50 到 100 字符。from langchain.text_splitter import RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap80, separators[\n\n, \n, 。, , , ., , ] ) chunks splitter.split_text(document_text)注意分隔符列表里我把中文标点放在了英文标点前面因为中文文档里句号、问号这些才是主要的句子边界。如果你的文档是中英混排这个顺序能保证优先按中文语义切分。注意块大小和重叠不是设一次就完事的一定要拿实际数据跑一遍看看切出来的块长什么样。我习惯随机抽十几个块人工看一眼确认没有明显的语义断裂再往下走。3. 检索环节让系统找到真正相关的内容3.1 向量检索的基本原理检索的核心是向量相似度。嵌入模型把文本转成一个高维向量比如 768 维或 1024 维这个向量可以理解为文本在语义空间里的坐标。语义相近的文本它们的向量在空间里的距离就近。检索时把用户问题也转成向量然后去数据库里找距离最近的若干个向量对应的文本块就是召回结果。距离的度量方式常见的有余弦相似度和欧氏距离大多数场景用余弦相似度就够了因为它只关心方向不关心长度对文本长度差异更鲁棒。这里有个容易被忽略的点嵌入模型和生成模型是两回事。嵌入模型负责把文本转成向量生成模型负责根据检索结果写答案。很多人只关注生成模型选哪个却随便找了个嵌入模型结果检索质量一塌糊涂。嵌入模型的选择对检索效果的影响说实话比生成模型还大。3.2 嵌入模型怎么选选嵌入模型主要看几个维度语言支持、维度、性能、部署方式。中文场景下我比较推荐的是 BGE 系列和 M3E 系列这两个在中文语义理解上表现都不错而且有不同大小的版本可以按需选择。如果你的文档是中英混合要确认模型对两种语言都有良好的支持。维度方面768 维是个比较通用的选择维度越高表达能力越强但存储和计算开销也越大。部署方式上如果追求数据不出本地可以用 Ollama 拉取嵌入模型在本地跑如果对性能要求高且能接受云端调用也可以用各家提供的嵌入 API。本地跑的好处是数据安全可控坏处是速度受限于你的硬件。# 用 Ollama 拉取一个中文嵌入模型 ollama pull bge-m3bge-m3 这个模型我个人比较喜欢它支持多语言而且在中文上的表现相当扎实维度是 1024对大多数知识库场景够用了。3.3 检索数量与相似度阈值检索回来几个块这个参数叫 top_k。设太小可能漏掉关键信息设太大会引入不相关的内容干扰模型还浪费上下文窗口。我的经验是 top_k 设在3 到 6之间比较合适。如果知识库内容比较集中、问题比较明确3 到 4 就够如果知识库覆盖面广、问题比较开放可以设到 5 到 6。设完之后一定要实际测看看召回的内容是不是真的相关。相似度阈值是另一道过滤。有些系统会设一个最低相似度低于这个值的块直接丢弃避免把完全不相关的内容硬塞给模型。这个阈值不好定因为不同嵌入模型的相似度分布不一样。我的做法是先不设阈值跑一批测试问题观察召回结果的相似度分布再根据实际情况定一个能过滤掉明显不相关内容的阈值。3.4 混合检索向量加关键词纯向量检索有个短板它对精确匹配不敏感。比如你问某个具体的错误码 ERR_5023向量检索可能召回一堆语义相近但错误码不同的内容反而漏掉了真正包含这个错误码的块。解决办法是混合检索把向量检索和关键词检索比如 BM25结合起来。向量检索负责语义匹配关键词检索负责精确匹配两路结果融合后排序。融合的算法常见的有 RRF倒数排名融合它不需要两路分数可比只看排名实现简单效果稳定。# 混合检索的伪代码思路 vector_results vector_store.search(query, top_k5) keyword_results bm25_index.search(query, top_k5) # 用 RRF 融合两路结果 def rrf_fusion(result_lists, k60): scores {} for results in result_lists: for rank, doc in enumerate(results): scores[doc.id] scores.get(doc.id, 0) 1 / (k rank 1) return sorted(scores.items(), keylambda x: x[1], reverseTrue)这个组合在实战中提升很明显尤其是知识库里包含大量专有名词、代码、编号的场景。4. 提示词设计让模型老老实实基于资料回答4.1 检索到了不等于答对了这是很多人踩过的坑检索明明召回了正确的资料模型却还是按自己的记忆回答或者把资料和记忆混在一起编。问题出在提示词上。模型默认的行为是尽量给出一个流畅的答案它不会自动优先使用你提供的资料。你必须在提示词里明确告诉它只根据下面提供的资料回答资料里没有的信息就说不知道不要自己发挥。这句话看起来简单但有没有它效果差别巨大。4.2 一个可复用的提示词模板我用了很久的一个模板结构上分成四块角色设定、资料注入、回答约束、问题。你是一个严谨的知识库问答助手。请严格根据下面提供的资料回答用户问题。 【资料】 {context} 【回答要求】 1. 只使用资料中明确包含的信息不要添加资料之外的任何内容 2. 如果资料中没有足够的信息回答问题直接说根据现有资料无法回答该问题 3. 回答要简洁准确必要时可以引用资料中的原文 4. 不要编造资料中不存在的细节、数字或名称 【问题】 {question}这个模板的关键在于约束要具体。不要编造这种话太笼统模型不一定当回事但不要编造资料中不存在的细节、数字或名称就具体多了模型更容易遵守。4.3 资料注入的格式也有讲究资料怎么拼进提示词里也会影响效果。如果召回了好几个块最好给每个块加上来源标记和序号让模型知道有几段资料、每段来自哪里。【资料1】来源产品手册第3章 退款流程分为三步提交申请、审核、到账... 【资料2】来源客服FAQ 退款一般在审核通过后3到5个工作日到账...这样做有两个好处一是模型能更清晰地分辨不同来源的信息二是如果答案需要引用来源模型可以直接引用这些标记。另外把最相关的资料放在前面因为模型对上下文开头和结尾的内容注意力更集中中间部分容易被忽略这个现象叫迷失在中间。提示如果你的资料块比较多可以考虑在提示词里对资料做一次重排序把最相关的放两头次相关的放中间能一定程度上缓解中间内容被忽略的问题。5. 用 Ollama 搭一套本地知识库5.1 为什么选本地部署云端 API 搭 RAG 确实省事但有几个场景下本地部署是刚需数据敏感不能出本地、网络环境不稳定、想完全掌控整个流程、或者单纯想省 API 费用。Ollama 是目前本地跑大模型最省心的工具之一它把模型下载、加载、推理这些繁琐的事情都封装好了一条命令就能跑起来。本地部署的代价是硬件要求。生成模型对显存和内存的要求比较高7B 参数的模型量化后大概需要 4 到 6GB 显存13B 的模型需要 8 到 10GB。嵌入模型就轻量多了几百 MB 到 1GB 左右普通机器都能跑。如果你的机器配置一般可以选小一点的生成模型或者用 CPU 推理速度会慢一些但能用。5.2 环境准备与模型拉取先把 Ollama 装好然后拉取需要的模型。生成模型我推荐 qwen2.5 系列中文能力强有不同大小的版本可以按硬件选。嵌入模型用前面提到的 bge-m3。# 拉取生成模型7B 版本适合大多数消费级显卡 ollama pull qwen2.5:7b # 拉取嵌入模型 ollama pull bge-m3 # 确认模型都拉好了 ollama list拉取完成后可以简单测一下生成模型能不能正常工作ollama run qwen2.5:7b 你好请用一句话介绍你自己如果能看到正常的回复说明模型这边没问题了。5.3 建库流程的完整代码接下来是建库。我用 LangChain 来串联整个流程因为它对各个组件的封装比较成熟。先装依赖pip install langchain langchain-community langchain-ollama chromadb pypdf然后是把文档读进来、切分、嵌入、存库from langchain_community.document_loaders import PyPDFLoader from langchain.text_splitter import RecursiveCharacterTextSplitter from langchain_ollama import OllamaEmbeddings from langchain_community.vectorstores import Chroma # 1. 加载文档 loader PyPDFLoader(your_document.pdf) documents loader.load() # 2. 切分 splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap80, separators[\n\n, \n, 。, , , ., , ] ) chunks splitter.split_documents(documents) print(f切分完成共 {len(chunks)} 个块) # 3. 嵌入并存入向量库 embeddings OllamaEmbeddings(modelbge-m3) vector_store Chroma.from_documents( documentschunks, embeddingembeddings, persist_directory./my_knowledge_base ) vector_store.persist() print(知识库构建完成)这段代码跑完你的知识库就建好了存在./my_knowledge_base目录里。下次用的时候直接加载这个目录就行不用重新建。5.4 问答链的组装建好库之后组装问答链from langchain_ollama import ChatOllama from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import StrOutputParser from langchain_core.runnables import RunnablePassthrough # 加载已有的知识库 vector_store Chroma( persist_directory./my_knowledge_base, embedding_functionOllamaEmbeddings(modelbge-m3) ) retriever vector_store.as_retriever(search_kwargs{k: 4}) # 生成模型 llm ChatOllama(modelqwen2.5:7b, temperature0.1) # 提示词模板 prompt ChatPromptTemplate.from_template( 你是一个严谨的知识库问答助手。请严格根据下面提供的资料回答用户问题。 【资料】 {context} 【回答要求】 1. 只使用资料中明确包含的信息不要添加资料之外的任何内容 2. 如果资料中没有足够的信息回答问题直接说根据现有资料无法回答该问题 3. 回答要简洁准确 【问题】 {question} ) def format_docs(docs): return \n\n.join( f【资料{i1}】\n{doc.page_content} for i, doc in enumerate(docs) ) # 组装链 rag_chain ( {context: retriever | format_docs, question: RunnablePassthrough()} | prompt | llm | StrOutputParser() ) # 提问 answer rag_chain.invoke(你的问题是什么) print(answer)注意temperature我设成了 0.1这是个偏低的温度值让模型的输出更稳定、更少发挥。知识问答场景不需要模型有创造力稳定准确才是第一位的。6. 效果不理想时怎么排查6.1 先分清是检索问题还是生成问题RAG 效果不好第一步是定位问题出在哪一环。方法很简单把检索到的内容单独打印出来看。docs retriever.invoke(你的问题) for i, doc in enumerate(docs): print(f--- 召回块 {i1} ---) print(doc.page_content[:200]) print()如果召回的内容根本不相关那是检索环节的问题得从切分、嵌入模型、检索参数上找原因。如果召回的内容是对的但模型答错了或答偏了那是生成环节的问题得从提示词、模型选择上找原因。这个定位步骤能帮你省下大量瞎调的时间。6.2 检索不准的常见原因检索不准我按出现频率排一下常见原因。切分粒度不对是最常见的。块太大导致语义稀释块太小导致信息碎片化。解决办法是打印几个块出来看调整 chunk_size 和 overlap。嵌入模型不适合你的语言或领域。如果你用的是英文为主的嵌入模型去处理中文文档效果肯定打折。换成中文能力强的模型试试。top_k 设得不合理。太小漏召回太大引入噪声。试着调一下这个值观察召回质量的变化。问题表述和文档表述差异太大。用户用口语提问文档是书面语向量相似度可能不高。这种情况可以考虑做查询改写让模型先把用户问题改写成更接近文档表述的形式再检索。6.3 模型不听话的应对模型明明拿到了正确资料却还是编或者答非所问通常是这几个原因。提示词约束不够具体。前面强调过约束要具体到不要编造资料中不存在的细节、数字或名称这种程度。资料注入格式混乱。如果多个块拼在一起没有清晰的分隔和标记模型可能分不清哪段是哪段。加上来源标记和序号。模型本身能力不足。小模型在遵循复杂指令上确实不如大模型。如果提示词已经优化到位还是不行考虑换一个能力更强的模型或者把问题拆得更简单一些。上下文太长导致中间内容被忽略。如果召回块很多试试减少 top_k或者对召回结果做重排序把最相关的放两头。6.4 一个实用的调试习惯我养成了一个习惯每次调整参数后固定用同一批测试问题跑一遍记录召回内容和最终答案对比调整前后的变化。这样能清楚地知道哪个参数改动带来了什么影响而不是凭感觉瞎调。测试问题要覆盖几种类型知识库里明确有答案的、知识库里没有答案的看模型会不会老实说不知道、表述模糊的、需要综合多个块才能回答的。这几类问题能比较全面地暴露系统的问题。7. 几个进阶方向7.1 查询改写提升召回率用户的问题往往表述随意和文档的书面表述有差距。查询改写就是在检索前先用模型把用户问题改写成几个不同角度的表述分别去检索然后合并结果。这样能显著提升召回率尤其是用户问题比较口语化的时候。7.2 重排序让结果更精准初步检索回来的块可以用一个专门的重排序模型reranker做二次排序。重排序模型比向量检索更精细它会把问题和每个块一起输入直接打分精度更高但速度更慢。所以常见做法是先用向量检索快速召回一批比如 20 个再用重排序精选出最相关的几个比如 4 个送给生成模型。7.3 多路召回与融合除了向量和关键词两路还可以加入其他召回路径比如按文档结构召回、按时间召回等。多路结果用 RRF 之类的算法融合能覆盖更多角度的相关信息。这个方向在知识库规模大、内容类型杂的场景下收益比较明显。7.4 评估体系的建立RAG 系统要持续优化得有一套评估方法。最基础的是人工评估准备一批问题和标准答案定期跑一遍看准确率。进阶一点可以用模型来评估让一个能力强的模型当裁判给生成的答案打分。再进一步是建立自动化的评估流水线每次改动后自动跑评估用数据说话而不是凭感觉。我在实际项目里的体会是RAG 这套东西入门容易精通难。搭一个能跑的 demo 可能半天就够了但要搭一个真正好用、稳定、能应对各种问题的系统需要在切分、检索、提示词、评估这几个环节反复打磨。最开始不要追求一步到位先把主流程跑通然后拿真实数据测根据暴露出来的问题逐个优化。每次只改一个变量改完就测这样你才能清楚地知道每个改动到底有没有用、有多大用。踩过的坑多了慢慢就形成自己的手感了。
返回列表