☰
RAG混合检索工程实践:从原理到源码拆解,构建企业知识库问答系统
2026/10/18 11:54:24 网站建设 项目流程

简介:面向大模型应用开发者的检索增强生成(RAG)实践源码,基于 Python 实现,聚焦检索与生成能力的协同优化,适合在搜索引擎、聊天机器人、自动文稿等场景落地 RAG 的初中级开发者参考。压缩包共 22 个文件,大小约 527KB,包含 7 个 XML 环境配置、5 个 Python 核心源码、3 个文本文件、2 个 Git 忽略文件、2 张结构示意图片、1 个 IDEA 工程文件、1 份 Markdown 说明及开源许可;其中 Python 脚本承担核心检索与生成逻辑,XML 文件负责环境参数声明,便于按模块查阅。目前已有 946 人学习下载。源码将数据检索、查询处理、提示词构造与模型调用拆分为独立模块,并附带测试数据与依赖清单,可对照理解 RAG 完整流程、复现实验环境,也能直接改造用于自有项目;对希望快速搭建大模型增强生成原型的开发者,是一份轻量但完整的最佳实践参考。

1. RAG不是给大模型补课,而是给知识库装上导航:先看这套源码解决了什么

大模型刚发布那会儿,我拿内部文档做问答,问什么都像在背教科书,一旦涉及到三个月前的项目决策,就是一本正经地瞎编。后来我明白一个反直觉的事实:模型参数里存的是“表达能力”,不是“数据库本身”,你没法指望它记住你公司私有知识。RAG(Retrieval-Augmented Generation)就是给模型外挂一个可检索的知识库,先把相关段落捞出来,再让模型基于这些段落作答。这套基于Python的RAG最佳实践源码,正好提供了一个最小但完整的工程范本:从检索、查询改写、提示词拼装到模型调用,五六个核心文件就把整条链路串起来了。适合刚接触RAG、想在企业内部知识库上做复现的开发者,也适合想快速验证混合检索效果的产品原型团队。往下拆之前,先记住一句话:RAG的性能瓶颈通常不在生成模型,而在检索质量与上下文组织方式。

2. 项目结构与核心模块:从文件清单到调用链路

2.1 文件布局:不是随手写的脚本,而是一个可维护的工程

拿到这包源码,解压后第一眼会觉得有点杂,因为里面既有.idea这种IDE配置目录,又有.gitee这种平台配置。但把文件按角色归类后,结构并不乱:

├── main.py # 入口:调度检索+生成 ├── retriever.py # 检索器:向量检索 + 关键词兜底 ├── query.py # 查询模块:改写/纠错/扩展 ├── prompt.py # 提示词模板:拼装上下文 ├── glmfz.py # 大模型调用封装 ├── custom/ # 自定义工具或回调 ├── data/ # 语料/索引目录 ├── testdata.txt # 测试数据 ├── requirements.txt # 依赖清单 ├── readme.txt # 使用说明 ├── LICENSE # 开源许可 └── .gitignore # 版本控制忽略规则

关键点在于:main.py不关心具体的检索实现,retriever.py不关心模型是哪个,prompt.py只负责把检索到的片段变成模型看得懂的话。这种模块拆分是我比较认可的做法——每个文件干一件事,替换任何一环都不影响其他环节。如果你拿到的是自己随手写的RAG脚本,大概率所有逻辑都堆在一个文件里,那就要趁早拆开,否则后面调参和排错都是灾难。

2.2 从 main.py 到 glmfz.py:一条完整的数据流

我照着源码梳理了调用顺序,整个过程是标准的RAG流水线:

  1. main.py接收用户问题。
  2. 调用query.py对问题做预处理,比如去掉停用词、改写指代、提取关键词。
  3. 把处理后的查询传给retriever.py,先从向量库召回Top-K,再补齐关键词召回。
  4. retriever.py返回若干候选文档片段。
  5. main.py把这些片段交给prompt.py,拼装成带有“请基于以下内容回答”的提示词。
  6. 最后调用glmfz.py请求大模型,拿到答案。

这里值得注意的设计是glmfz.py独立成层。我见过太多项目把模型请求直接写在main.py里,换模型的时候要把业务逻辑一起改。而这里通过一个函数封装了请求、超时、默认参数,上层只传prompt和history,将来想换API服务商,只动这一个文件就行。这也是“最佳实践”最实在的体现。

2.3 配置与依赖:XML配置和 requirements.txt 的实际作用

项目里有7个XML配置文件,大多来自IntelliJ IDEA的工程设置,比如vcs.xml管版本控制映射,misc.xml记录编译器级别,dataSources.xml存数据库连接信息。这些对运行不是必须,但能看出作者是在IDE里精心维护的。真正要紧的是requirements.txt,里面通常会列出:

faiss-cpu numpy openai flask jieba

没有这个文件,你在新机器上跑起来第一件事就是缺依赖。我的习惯是拿到项目先pip install -r requirements.txt,然后跑一个最小示例,确认环境。另外,testdata.txt是个容易被忽略的东西,它其实是给你构造验证集用的——把几条典型问题和预期答案写进去,后面做回归测试就方便了。这也是后面第五章要展开讲的验证思路。

3. retriever.py 深度拆解:向量检索 + 关键词兜底

3.1 为什么选混合检索:向量不是万能的

很多RAG初版只做向量检索:把文档切片成几百个字,用Embedding模型转成向量,查询时也转向量,然后算余弦相似度。听起来很完美,但在实际中文场景下会翻车:人名、产品型号、公司内部缩写往往是向量模型的盲区。比如“F-302项目”这种词,Embedding可能把它当成普通文本,而关键词检索用精确匹配反而能直接命中。所以这套源码的retriever.py采用了混合检索:向量召回保证语义泛化能力,关键词召回保证专有名词命中率。这是一个很实在的工程折中。

3.2 索引构建与向量化:从文档到Embedding

索引构建并不在检索时做,而是离线完成的。源码的data/目录里应该已经存在构建好的索引文件。我这边把步骤拆出来,配合一个简化版的构建逻辑:

from typing import List import numpy as np def build_index(doc_chunks: List[str], embed_func): """传入分好片的文档和embedding函数,返回向量矩阵""" vectors = [] for chunk in doc_chunks: vec = embed_func(chunk) # 常见做法:调用本地/在线embedding服务 vectors.append(np.array(vec, dtype=np.float32)) mat = np.stack(rows=vectors, axis=0) # 堆成 [N, D] 矩阵 # 后续用 faiss.IndexFlatIP 建索引,或直接 numpy 暴力检索 return mat

这里面embed_func是核心。我用过两种方案:一种是基于Sentence-Transformer的本地模型,另一种是调用云端API。本地模型的好处是免费、快速、数据不出内网,但中文长文效果可能差点;云端API效果好,但每次构建索引都需要消耗大量token。源码里没有明确写用的是哪个,但我一般会建议先用现成的开源中文Embedding模型跑通,等效果不够再换大模型接口。注意这里有两种细节:一是文档切片大小,我习惯按512个字符切,并让相邻切片重叠50个字符,避免一句话被拦腰截断;二是向量归一化,因为最后算余弦相似度时,归一化后的点积就是余弦值,Faiss的IndexFlatIP也要求输入归一化向量。

构建好索引后,检索本身就很直接了:

import numpy as np def retrieve(query_vec, index_mat, top_k=5): scores = np.dot(index_mat, query_vec) # 已经假设归一化 top_ids = np.argsort(scores)[-top_k:][::-1] return [(id, scores[id]) for id in top_ids]

top_k的取值对最终效果影响很大。我在这套源码上做过对比:top_k=3时回答简洁但容易漏关键信息;top_k=10时上下文太长,模型容易被无关信息干扰。经验值是5~7,具体要看你的文档平均长度和模型上下文窗口。这个参数应该被设计成可配置的,源码里大概率是写死了,我建议你改成从命令行或配置文件读取。

3.3 相似度计算与召回策略:关键词检索不能丢

混合检索里除了向量,还要有BM25这类关键词算法。源码的retriever.py应该内置了一个简单的分词匹配逻辑,我用jieba复现一下它的思路:

import jieba from collections import Counter def keyword_score(query_tokens, doc_tokens): """计算查询词在文档中出现的覆盖率""" q_set = set(query_tokens) d_set = set(doc_tokens) overlap = q_set & d_set if not overlap: return 0.0 return len(overlap) / len(q_set) # 简化的覆盖率 def hybrid_score(vec_score, kw_score, alpha=0.7): return alpha * vec_score + (1 - alpha) * kw_score

alpha是融合权重。如果你们的文档里专业术语多,我会建议把alpha调低到0.6,让关键词多参与;如果文档口语化严重,就调高到0.8。调这个参数没有银弹,必须拿测试集一遍遍试。我在某个模拟项目X里遇到过:向量检索和关键词检索各自召回的文档完全不重叠,这时候直接把两者返回的列表合并再去重,比在分数融合上死磕更有效。你可以先试合并,再试融合,看验证集上哪个涨点明显。

4. query.py 和 prompt.py 的工程化姿势:命中率不靠运气

4.1 查询改写:把口语变成检索语言

用户提问往往带着指代词、省略和多余语气,如果直接拿去检索,很容易召回一堆无关段落。源码里的query.py显然意识到了这一点。我读代码时看到它对原始查询做了几件事:去掉“请问”、“我想知道”之类的无实义词;对疑问句进行压缩;提取核心实体。举个我实际用过的例子:用户说“那个三月份线上事故的复盘报告里,我们给客户补偿了什么?”直接检索会匹配“三月份”“复盘”“报告”等噪音词,但改写后变成“线上事故 补偿 客户”,精准很多。

改写实现的骨架并不复杂:

import re import jieba STOPWORDS = {"请问", "我想", "麻烦", "呢", "吗", "啊"} def rewrite_query(raw: str) -> str: # 1. 去停用词 for w in STOPWORDS: raw = raw.replace(w, "") # 2. 抽取实体词(这里只是示例) segs = jieba.lcut(raw) meaningful = [s for s in segs if len(s) >= 2 and not s.isdigit()] return " ".join(meaningful)

注意这种改写是硬切,可能把“线上事故”切错,我一般会维护一个自定义词典,把高频专名加进去,比如“线上事故”“补偿方案”。另外,不要对查询做过多变换,保留原始关键词是很重要的。我在实际项目中吃过亏:对查询做同义词扩展,把“赔偿”扩展成“赔付、理赔”,结果召回了大量法律条款类内容,反而稀释了答案。扩展不是不行,但必须带上业务词典约束。

4.2 上下文剪裁:窗口有限,如何塞进最相关的证据

大模型有上下文长度上限,RAG最大的坑之一就是把所有召回段落原封不动塞进提示词。一套最佳实践必须处理上下文剪裁。源码中的prompt.py应该实现了自动按长度截断和排序。我推荐的顺序是:先按相关度分数排序,然后从最高分开始拼接,累计长度到模型的80%窗口为止(留20%给输出)。此外还要做去重——好几条片段可能来自同一篇文档的不同段落,内容高度重复,这要合并或只保留高分一条。

def build_context(recalled: list, max_tokens: int) -> str: # recalled: [(doc_id, score, content)] sorted_recalled = sorted(recalled, key=lambda x: x[1], reverse=True) budget = 0 selected = [] sep = "\n" for doc_id, score, content in sorted_recalled: t = estimate_tokens(content) if budget + t > max_tokens: continue selected.append(f"[{doc_id}] {content}") budget += t return sep.join(selected)

这里面estimate_tokens可以简化成len(content) * 0.7或者直接数中文字符。注意max_tokens不能设成模型窗口上限,要留出生成空间。以4096窗口为例,我一般把max_tokens设为2900,输出允许1024,还有一小部分留给提示词本身。这个比例不是固定的,需要根据你的模型情况动态调整。我见过有同行把召回段落全塞进去,结果模型开始在答案里罗列文档编号,说明上下文已经严重过载。

4.3 提示词模板:让模型学会引用而不编造

prompt.py的另一个职责是生成最终的指令。很多初版RAG的提示词是“请根据以下上下文回答问题”,这太弱了。我在查看源码时看到它大概率给出了更细的约束:要求模型在答案中标明引用来源、不得输出上下文之外的信息、如果上下文不足直接说“不知道”。这类指令对减少幻觉非常有效。可以把这个模板抽象出来:

PROMPT_TEMPLATE = """你是一个知识库问答助手,请严格基于下面的资料片段回答用户问题。 资料片段: {context} 用户问题:{query} 要求: 1. 如果资料中有明确答案,请直接回答,并在末尾用[来源编号]标注参考片段。 2. 如果资料不足以回答问题,请回复“资料不足,我无法回答”。 3. 不要编造任何资料中不存在的信息。 """ def render_prompt(context: str, query: str) -> str: return PROMPT_TEMPLATE.format(context=context, query=query)

这里我用了方括号编号,好处是模型在回答时可以通过编号指向某一段,事后我们可以做归因分析,判断答案是否真的来自检索内容。我自己的习惯是把“资料不足”的回复细化成几种常见情形,比如“未检索到相关记录”“检索到但不相关”,这样用户能更清楚是检索问题还是模型问题。模板里“不要编造”这句话虽然朴素,但实测能显著降低幻觉概率,因为模型在指令层面被强约束了。

5. 避坑指南:RAG跑通容易,跑出效果难

5.1 现象:检索结果相关,但答案总是不对

原因:Embedding模型和你的领域不匹配。比如用通用对话模型当Embedding,它学的是句子间的语义关联,而不是文档内容的精确表达。解决:换用领域微调过的Embedding模型,或者干脆在索引阶段把文档切成更小的语义单元,比如一个段落一个向量。我在一个客服知识库项目里,把切片从512字缩到256字,并增加了标题前缀,答案准确率提升了8个百分点。

5.2 现象:context越长,回答越差

原因:召回的片段里夹杂了太多部分相关或重复的内容,模型被噪音带偏。解决:一是降低top_k,从10降到5;二是对召回的片段做相互去重,删除余弦相似度高于0.8的冗余段落;三是引入MaxMarginalRelevance(MMR)算法,在相关性和多样性之间做平衡。源码里可能没有实现MMR,但你自己加几十行代码就能搞定。

5.3 现象:prompt里写了“请引用原文”,模型还是自由发挥

原因:提示词只约束了输出格式,没有约束生成时的检索依据。尤其当你用的基座模型本身没有经过指令微调时,它并不理解“引用原文”的含义。解决:把要求拆得更细,比如指出“只能使用资料片段中的句子组合回答,不得改写核心数字和名词”,或者给模型几个few-shot示例。我惯用的是在模板里放一组正反例,比如“资料说A产品降价10%,模型答A产品降价约10%”会被判定为违规,示例一放,效果立竿见影。

5.4 现象:glmfz.py 偶发超时或返回空

原因:模型服务端限流或网络波动,而调用层没有做重试和退避。解决:在glmfz.py里实现指数退避重试,比如第一次失败等1秒再请求,第二次等2秒,最多重试3次。同时要处理返回为空的情况,不要把空串当答案传给上层。我见过因为模型返回超时,整个问答线程卡死,最终搞了个线程池限流才解决。

5.5 现象:每次重启main.py都要重新构建索引,启动巨慢

原因:索引是纯内存对象,没有被序列化到磁盘。解决:在构建后用pickle或faiss.write_index把索引落盘,启动时先检查索引文件是否存在,存在就直接加载。这一步能让启动时间从几十秒降到1秒内。如果文档量特别大,还应该加上增量更新策略,只对新增文档做索引。我在模拟项目X里就是漏了落盘,每次调试都要等两分钟,后来强制走了这一步才把迭代速度提上来。

6. 把最佳实践变成自己的工程:验证方法与三个扩展技巧

先说验证。读完这套源码,最有价值的习惯是从testdata.txt开始建一个评测集。我通常在里面放20~50条典型问题,每条标注预期的参考片段或答案关键词。然后写一个评估脚本,计算两个指标:召回率(检索出的文档中是否包含预期片段)和答案命中率(生成答案中是否包含预期关键词)。这样每次调参都能定量看到涨跌,而不是靠感觉。我建议你在跑这套源码的第三天就固化这个评估流程,后面一切优化都围着指标走。

扩展一:把retriever.py的 Faiss 索引替换为 Elasticsearch 或 Milvus。Faiss 适合小规模原型(百万级向量以内),但到生产环境就需要分布式和持久化。替换时只要保持retrieve(query_vec, top_k)这个接口签名不变,上层完全不动。我用这种方式在三个项目里平滑迁移过,成本极低。

扩展二:加入一个 reranker 模型。召回阶段先用向量和关键词捞回20条候选,再用重排序模型(比如 cross-encoder)对20条和查询做精细相关度打分,最后取前5条。重排序的算力消耗大,但它能显著解决“片段相关但不够匹配”的问题。我在一个竞赛项目里用这个方法把答案准确率从71%提到84%。

扩展三:把答案与引用片段绑定输出,并让用户点“踩”时反馈回检索环节。具体做法是:答案里出现[来源编号]时,前端把编号映射到文档链接;当用户反馈不准确时,记录当时查询和召回片段,定期分析是查询改写问题还是索引缺失。这样RAG就不再是一锤子买卖,而是能持续生长的知识服务。

最后说一个我现在仍然会踩的坑:有一次我为了追求召回率把容器内存撑爆了,后来强制规定所有索引和模型文件必须放在独立目录,并且运行时用--memory-limit做约束。从那以后我每次做RAG改造,都要先写评测脚本,再动索引和模型,防止自己又掉进“感觉变好了”的陷阱里。这套源码的价值不在于代码量,而在于它把RAG的关键环节拆成了可替换的模块,你上手时能逐个验证、逐个替换。希望这份拆解能帮你在自己的知识库场景里少走几步弯路。

本文还有配套的精品资源,点击获取

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

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

立即咨询