☰
RAG 检索增强生成实战:从文档切分到混合检索的本地知识库搭建指南
2026/9/30 9:35:10 网站建设 项目流程

1. RAG 到底是什么,为什么现在人人都在聊

RAG,全称 Retrieval-Augmented Generation,中文叫检索增强生成。拆开看就三件事:检索、增强、生成。检索是从你自己的资料库里找到跟问题相关的内容,增强是把这些内容塞进大模型的上下文里,生成是让模型基于这些内容给出回答。说白了,就是给大模型配了一个“开卷考试”的小抄,让它不用全靠脑子里记的东西硬答。

我最早接触 RAG 是因为一个很实际的问题:公司内部有几千份产品文档、会议纪要、客服记录,想让大模型帮忙回答员工的问题,但直接问模型,它要么胡编,要么说“我不知道”。微调成本太高,每次文档更新还得重新训练。RAG 就不一样了,文档更新只需要重新索引,模型本身不用动。这个特性让 RAG 在过去两年里成了企业落地大模型最主流的方案之一。

RAG 适合谁?如果你是开发者,想给自己的应用加一个“懂业务”的问答能力;如果你是产品经理,在规划知识库类产品;甚至你只是个普通用户,想用本地电脑搭一个能查自己笔记的助手,RAG 都是目前门槛最低、效果最可控的路径。它不需要你懂模型训练,不需要 GPU 集群,一台普通笔记本就能跑起来。

但 RAG 也不是银弹。我见过太多人兴冲冲搭了一个 demo,结果回答质量惨不忍睹。问题往往不在模型,而在检索环节。检索没找对内容,后面生成再强也是白搭。所以这篇文章我会把重点放在“怎么让检索真正管用”上,而不是只给你一个能跑通的代码就完事。

2. RAG 的核心链路拆解与方案选型

2.1 一条完整的 RAG 链路包含哪些环节

很多人以为 RAG 就是“向量数据库 + 大模型”,其实中间有七八个环节,每个环节都会影响最终效果。我把它拆成下面这条链路:

  • 文档加载:把 PDF、Word、Markdown、网页、数据库记录等各种格式的原始资料读进来。
  • 文本切分:把长文档切成小块,因为模型上下文有限,而且检索粒度太粗会引入噪音。
  • 向量化:用嵌入模型把每个文本块转成向量,存进向量数据库。
  • 索引构建:除了向量索引,通常还会建关键词索引、元数据索引,方便混合检索。
  • 查询理解:对用户的问题做改写、扩展、意图识别,提升检索命中率。
  • 检索召回:从索引里找出最相关的若干文本块。
  • 重排序:用更精细的模型对召回结果重新打分,把最相关的排到前面。
  • 生成回答:把问题和筛选后的上下文一起送给大模型,让它组织答案。

这条链路里,切分策略和检索策略是决定成败的两个关键点。切分不好,检索再强也找不到完整信息;检索不好,生成模型只能靠猜。

2.2 为什么我推荐从本地轻量方案起步

网上有很多 RAG 教程一上来就让你买云服务、调 API、配向量数据库集群。我的建议是:先用本地方案跑通全流程,再考虑上云。原因有三个。

第一,本地方案让你能看清每个环节的数据流转。用 Ollama 跑本地模型,用 Chroma 或 FAISS 做向量库,整个链路在你自己的机器上,出问题容易排查。第二,成本几乎为零。你不需要为每次调试付 API 费用,可以反复试错。第三,数据安全可控。很多企业的文档涉及内部信息,本地跑不用担心数据外流。

当然,本地方案有性能上限。如果你要处理百万级文档、支持高并发查询,最终还是得走服务化架构。但那是第二步的事。第一步永远是:用最小成本验证你的切分和检索策略是否有效。

2.3 嵌入模型和生成模型怎么选

嵌入模型负责把文本转成向量,它的质量直接决定检索准不准。生成模型负责根据上下文写答案,它的质量决定回答读起来顺不顺。

我的经验是:嵌入模型比生成模型更值得花时间挑选。原因很简单,生成模型现在普遍不差,7B 参数的中文模型已经能写出通顺的回答;但嵌入模型如果选错了,检索出来的内容跟问题八竿子打不着,生成模型再强也救不回来。

选嵌入模型时重点看三个指标:中文支持、向量维度、推理速度。中文支持不用多说,很多英文嵌入模型在中文上表现很差。向量维度影响存储和检索速度,768 维和 1024 维是常见选择。推理速度决定你建索引要等多久,本地跑的话建议选参数量小一点的。

生成模型方面,本地跑推荐 7B 到 14B 参数区间的指令微调模型。太小了回答质量差,太大了消费级显卡跑不动。如果你有 24G 显存的卡,14B 量化版本是比较舒服的选择。

3. 文档切分:最容易被忽视但最影响效果的一步

3.1 为什么固定长度切分往往不好用

大部分入门教程会告诉你:按 500 字一段切,重叠 50 字。这个策略简单,但实际效果经常很差。问题在于,固定长度切分会把完整的语义单元切碎。比如一个操作步骤写到一半被切断了,检索到前半段,模型看到的是不完整的信息,回答自然缺胳膊少腿。

我踩过最典型的一个坑:一份产品故障排查文档,每个故障现象和解决方案是一一对应的。按固定长度切分后,某个故障现象的描述和它的解决方案被分到了两个块里。用户问“XX 故障怎么处理”,检索只召回了现象描述那块,模型看到现象但看不到解决方案,只能编一个。后来改成按标题层级切分,每个故障作为一个独立块,问题立刻解决了。

所以切分的第一原则是:优先按文档的自然结构切,而不是按字数切。Markdown 按标题切,HTML 按标签切,代码按函数切,对话按轮次切。只有在文档没有明显结构时,才退而求其次用固定长度。

3.2 切分粒度怎么定才合理

切分粒度太粗,一个块里包含多个主题,检索时容易引入无关信息;切分粒度太细,一个完整意思被拆散,模型拼不起来。我的经验值是:每个块 200 到 500 字,或者 3 到 8 句话。这个范围能容纳一个相对完整的语义单元,又不至于太泛。

但这不是死规定。技术文档可以细一点,因为概念密集;叙事类内容可以粗一点,因为需要上下文连贯。关键是做实验:拿十几个典型问题,看检索出来的块是否包含回答所需的全部信息。如果经常缺信息,就调大粒度;如果经常混入无关内容,就调小粒度。

还有一个技巧是保留父子关系。切分时记录每个块属于哪个父文档、哪个章节。检索时先召回小块,然后根据父文档 ID 把相邻的块也拉进来,这样既保证了检索精度,又保证了上下文完整。这个策略在 LangChain 和 LlamaIndex 里都有现成实现。

3.3 元数据是提升检索质量的隐藏武器

很多人切分完只存文本和向量,把元数据丢了。这是个巨大的浪费。元数据包括:文档标题、章节路径、创建时间、作者、文档类型、标签等等。这些信息在检索时能发挥大作用。

举个例子:用户问“最新的报销政策是什么”。如果你的块里存了“生效日期”这个元数据,检索时就可以优先召回日期最新的块,而不是靠语义相似度碰运气。再比如,用户问“技术方案里怎么说的”,你可以用“文档类型=技术方案”做过滤,把行政通知类的块直接排除。

我的做法是:切分时尽可能多地保留元数据,检索时先用元数据做粗筛,再用向量做精排。这个组合策略比纯向量检索的准确率高出一大截。

4. 检索策略:从“能找到”到“找得准”

4.1 纯向量检索的局限性在哪里

向量检索擅长语义匹配。用户问“怎么退款”,文档里写“如何申请退货返还货款”,向量检索能匹配上,因为它理解语义。这是它的优势。

但向量检索有三个明显短板。第一,对精确匹配不敏感。用户问“错误码 E1024 怎么解决”,向量检索可能召回一堆讲错误处理的块,但就是漏掉那个专门讲 E1024 的块,因为数字和代码在向量空间里区分度不高。第二,对否定和条件不敏感。用户问“哪些情况不能退款”,向量检索可能召回一堆讲退款流程的块,因为语义上很接近。第三,对长尾问题召回率低。训练数据里少见的表达方式,向量化后可能偏离主流语义空间。

所以,纯向量检索只适合做初筛,不能作为唯一手段。

4.2 混合检索:向量加关键词才是正解

我的标准配置是:向量检索 + BM25 关键词检索,两路召回后合并去重。向量负责语义匹配,BM25 负责精确匹配。两路各取前 20 个结果,合并后用重排序模型统一打分。

BM25 是一个经典的关键词检索算法,它考虑词频和逆文档频率,对精确术语、代码、数字特别有效。上面那个 E1024 的例子,BM25 能精准命中包含“E1024”的块,弥补向量检索的不足。

合并策略有两种:一种是简单加权,向量得分乘 0.7,BM25 得分乘 0.3,相加排序;另一种是 Reciprocal Rank Fusion,按排名倒数融合,不依赖分数绝对值。我一般用 RRF,因为它对不同检索器的分数尺度不敏感,更省心。

4.3 重排序模型:把最相关的推到最前面

召回阶段追求的是“不漏”,所以会取比较多结果。但送给生成模型的上下文有限,通常只能放 3 到 5 个块。这就需要重排序模型来精挑细选。

重排序模型和嵌入模型不同。嵌入模型是双塔结构,问题和文档分别编码,速度快但精度有限。重排序模型是交叉编码,问题和文档拼在一起过模型,精度高但速度慢。所以典型流程是:嵌入模型召回 50 个,重排序模型精选 5 个。

本地跑重排序模型推荐用轻量级的,参数量在 1B 以下,推理速度可以接受。如果不想额外部署模型,也可以用大模型本身来做重排序,让它给每个块的相关性打分。这个方式慢一些,但省了一个模型。

4.4 查询改写:让用户的问题更容易被检索到

用户的问题往往很短、很口语化,直接拿去检索效果不好。查询改写就是把用户问题扩展成更适合检索的形式。

常见手法有几种。同义词扩展:把“退款”扩展成“退款 退货 返还货款”。假设文档生成:让大模型先根据问题编一个假想的答案,然后用这个答案去检索,因为答案的表述风格更接近文档。子问题拆解:把“A 和 B 有什么区别”拆成“A 是什么”和“B 是什么”分别检索。

我实测下来,假设文档生成对提升召回率效果最明显,尤其适合文档表述和用户提问风格差异大的场景。但它会增加一次大模型调用,延迟会上升。如果对延迟敏感,可以只用同义词扩展,成本低见效快。

5. 从零搭建一个本地 RAG 知识库的完整实操

5.1 环境准备与工具选型

先列一下我这次实操用的工具栈,都是本地可跑的:

组件选型理由
生成模型Ollama 跑 7B 中文指令模型本地推理,无需 API
嵌入模型中文优化的轻量嵌入模型中文语义匹配好,速度快
向量库Chroma零配置,适合本地开发
关键词检索rank_bm25 库纯 Python,无需额外服务
重排序轻量交叉编码模型本地可跑,精度提升明显
编排框架LangChain生态成熟,组件丰富

安装依赖的命令大致如下:

pip install langchain langchain-community chromadb rank_bm25 pip install sentence-transformers pypdf markdown

Ollama 需要单独安装,装好后拉取模型:

ollama pull qwen2.5:7b

提示:模型名称根据你实际可用的中文模型调整,重点是选指令微调版本,基座模型不擅长遵循指令。

5.2 文档加载与切分的具体实现

假设你有一批 Markdown 格式的产品文档,放在docs/目录下。加载和切分的代码如下:

from langchain_community.document_loaders import DirectoryLoader, TextLoader from langchain.text_splitter import MarkdownHeaderTextSplitter # 加载文档 loader = DirectoryLoader("docs/", glob="**/*.md", loader_cls=TextLoader) documents = loader.load() # 按标题层级切分 headers_to_split_on = [ ("#", "h1"), ("##", "h2"), ("###", "h3"), ] splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on) chunks = [] for doc in documents: splits = splitter.split_text(doc.page_content) for s in splits: # 把标题路径写进元数据 s.metadata["source"] = doc.metadata["source"] chunks.append(s)

这段代码的关键点是:用 MarkdownHeaderTextSplitter 而不是 RecursiveCharacterTextSplitter。前者按标题切,每个块自带标题路径元数据;后者按字数切,会破坏语义完整性。

切完后检查一下块的大小分布。如果有些块超过 800 字,可以再对超长块做二次切分,但尽量在段落边界切。

5.3 向量化与索引构建

接下来把块向量化并存入 Chroma:

from langchain_community.embeddings import HuggingFaceEmbeddings from langchain_community.vectorstores import Chroma embedding = HuggingFaceEmbeddings( model_name="your-chinese-embedding-model", model_kwargs={"device": "cpu"}, ) vectorstore = Chroma.from_documents( documents=chunks, embedding=embedding, persist_directory="./chroma_db", ) vectorstore.persist()

同时构建 BM25 索引:

from rank_bm25 import BM25Okapi import jieba # 中文需要分词 tokenized = [list(jieba.cut(chunk.page_content)) for chunk in chunks] bm25 = BM25Okapi(tokenized)

注意:中文 BM25 必须分词,直接用空格切分效果很差。jieba 是最省事的选择,如果对分词精度要求高可以换其他分词器。

5.4 混合检索与重排序的代码实现

检索阶段把向量和 BM25 的结果合并:

def hybrid_search(query, top_k=5): # 向量检索 vector_results = vectorstore.similarity_search_with_score(query, k=20) # BM25 检索 tokenized_query = list(jieba.cut(query)) bm25_scores = bm25.get_scores(tokenized_query) bm25_top = sorted(range(len(bm25_scores)), key=lambda i: bm25_scores[i], reverse=True)[:20] # RRF 融合 rrf_scores = {} for rank, (doc, _) in enumerate(vector_results): key = doc.page_content rrf_scores[key] = rrf_scores.get(key, 0) + 1 / (60 + rank) for rank, idx in enumerate(bm25_top): key = chunks[idx].page_content rrf_scores[key] = rrf_scores.get(key, 0) + 1 / (60 + rank) # 排序取前 top_k sorted_items = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True) return [item[0] for item in sorted_items[:top_k]]

这里的 60 是 RRF 的标准平滑参数,不用改。融合后取前 5 个块送给生成模型。

5.5 生成回答的提示词设计

提示词直接决定回答质量。我的模板是这样的:

PROMPT = """你是一个知识库助手。请严格根据以下参考资料回答问题。 参考资料: {context} 用户问题:{question} 要求: 1. 只使用参考资料中的信息,不要编造。 2. 如果参考资料中没有答案,直接说“资料中没有相关信息”。 3. 回答要简洁,分点说明时用数字编号。 4. 引用具体内容时注明来自哪个文档。 回答:"""

这个模板有三个关键约束:只用参考资料、没有就说没有、注明来源。第一条防止模型胡编,第二条防止它硬答,第三条方便你追溯和验证。

把检索到的块拼成 context,调用 Ollama 生成:

import requests def generate(question, context): prompt = PROMPT.format(context=context, question=question) resp = requests.post("http://localhost:11434/api/generate", json={ "model": "qwen2.5:7b", "prompt": prompt, "stream": False, }) return resp.json()["response"]

整个链路跑通后,拿十几个典型问题测一遍,看回答是否准确、是否引用了正确来源。如果发现检索不准,回到切分和检索策略调整;如果检索准但回答不好,调整提示词或换生成模型。

6. 实战中踩过的坑与排查技巧

6.1 检索召回不准的常见原因

问题一:问题里的关键词在文档里表述不同。用户说“怎么退钱”,文档写“退款流程”。向量检索可能匹配上,但 BM25 匹配不上。解决办法是加同义词扩展,或者依赖向量检索兜底。

问题二:文档里有多个相似主题,检索分不清。比如文档里同时有“个人退款”和“企业退款”,用户问“退款”,两边的块都被召回。解决办法是在元数据里加分类标签,检索时先过滤。

问题三:块太大,一个块里混了多个主题。检索召回了块,但块里只有一小部分相关。解决办法是调小切分粒度,或者用句子级检索再合并。

问题四:嵌入模型对领域术语不敏感。通用嵌入模型没见过你行业的专有名词,向量化后区分度低。解决办法是用领域数据微调嵌入模型,或者补充关键词检索。

6.2 生成回答胡编乱造的应对方法

模型胡编通常是因为上下文里没有答案,但它又不想说“不知道”。除了在提示词里明确要求“没有就说没有”,还可以做两件事。

第一,加相关性阈值。检索结果的重排序分数低于某个阈值时,直接返回“未找到相关信息”,不调用生成模型。这个阈值需要根据你的数据调,一般从 0.5 开始试。

第二,要求模型引用原文。让模型在回答里标注每句话来自哪个块的哪部分。如果它引用的内容在上下文里找不到,说明它在编。这个约束会显著降低胡编率。

6.3 性能优化的几个实用技巧

本地跑 RAG 最容易卡在三个地方:嵌入慢、检索慢、生成慢。

嵌入慢的解决办法是批量处理,不要一个块一个块地调嵌入模型,攒够 32 或 64 个块一起编码。另外,嵌入可以离线做一次,之后增量更新即可。

检索慢通常是向量库没建好索引。Chroma 默认用暴力搜索,数据量大了会慢。超过一万个块建议换 FAISS 或 Milvus,它们支持近似最近邻搜索,速度快很多。

生成慢主要是模型太大或硬件不够。7B 模型在 CPU 上跑,每秒可能只有几个 token。如果有 GPU,用 GPU 推理会快十倍以上。没有 GPU 的话,可以考虑用量化版本,牺牲一点质量换速度。

6.4 常见问题速查表

现象可能原因排查方向
回答总是“不知道”检索没召回相关块检查切分粒度、嵌入模型、检索策略
回答内容张冠李戴召回了错误主题的块加元数据过滤、调小切分粒度
回答不完整块被切断,信息缺失调大切分粒度、加父子块关联
精确术语检索不到纯向量检索的短板加 BM25 关键词检索
回答胡编上下文无答案但模型硬答加提示词约束、加相关性阈值
检索结果重复多路召回未去重合并时按内容去重
建索引太慢嵌入模型太大或未批量换轻量模型、批量编码
查询延迟高重排序模型太重换轻量重排序或减少召回数量

7. 进阶方向:从能用到好用

7.1 Agentic RAG:让模型自己决定怎么查

基础 RAG 是“一次检索,一次生成”。Agentic RAG 是让模型自己判断:这个问题需要查吗?查哪个库?查几次?查到的够不够?不够再查什么?

举个例子,用户问“对比 A 产品和 B 产品的退款政策”。基础 RAG 可能只召回一个产品的政策。Agentic RAG 会先把问题拆成两个子问题,分别检索,再合并对比。这个模式适合复杂查询,但实现复杂度高,延迟也大。我的建议是先把基础 RAG 做扎实,再考虑上 Agent。

7.2 知识图谱增强:解决多跳推理问题

有些问题需要跨多个文档推理才能回答。比如“张三负责的项目里,哪些用了李四的技术方案”。这需要先查张三负责哪些项目,再查这些项目用了谁的技术方案。纯向量检索很难处理这种多跳关系。

知识图谱增强的思路是:把文档里的实体和关系抽出来,建成图结构。检索时先在图上做多跳查询,把相关实体和关系拉出来,再送给生成模型。这个方案适合实体关系密集的领域,比如法律、医疗、金融。但建图成本高,维护也复杂,不是所有场景都值得上。

7.3 服务化与多租户:从个人工具到团队产品

个人用的 RAG 脚本和团队用的 RAG 服务是两回事。服务化要考虑:多用户并发、权限隔离、文档版本管理、检索日志、效果监控。

权限隔离是重点。不同部门的人只能查自己部门的文档,这需要在元数据里加权限标签,检索时强制过滤。文档版本管理也很关键,政策文件更新后,旧版本要能追溯但不能被检索到。这些工程问题比算法问题更磨人,但决定了 RAG 能不能真正在团队里用起来。

我个人的体会是:RAG 的效果 20% 靠模型,80% 靠数据和检索策略。花时间整理文档结构、设计切分方案、调检索参数,比换更大的模型收益高得多。很多团队一上来就追求最新最强的模型,结果文档一团糟,检索一塌糊涂,再强的模型也救不回来。先把数据治理做好,再谈模型选型,这个顺序不能反。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询