☰
RAG+LoRA微调:企业知识库问答系统实战指南
2026/10/7 16:38:27 网站建设 项目流程

简介:这是一份面向自然语言处理与大模型开发者的检索增强生成智能问答系统实战项目,以本地知识库检索和大型语言模型微调为核心,解决垂直领域问答中检索不准、生成不专业等问题,适合具备基础编程能力的算法工程师和学生。压缩包共61个文件,大小约30MB;其中13个脚本覆盖数据预处理、向量检索、模型训练与问答主流程,31个文本提供数据与说明,7个配置、3个权重、3个教程、2张界面图、1份报告和1份说明文件配合使用,目录按模块划分清晰。已有1832人学习下载。项目中给出向量检索实现、ChatGLM-6B的LoRA与P-Tuning微调教程、可运行Web演示界面和完整说明,能够帮助读者从数据准备、模型微调到检索生成融合逐步复现,适合快速搭建本地知识库问答原型并做二次开发。

1. 落地前先想清楚:RAG 加微调,到底在解决什么问题

企业内部每天都在回答重复问题:“这个接口为什么报 401”“报销流程走到哪一步了”。把这些答案整理进本地知识库,再做一个智能问答系统,是 RAG 最典型的落地场景。

标题里的方案把两件事绑在一起:先基于本地知识库检索增强生成,再对 LLM 做微调。它适合那种“答案就在内部文档里,但用户总是换着说法问”的场景。

先给一个反直觉的结论:纯 RAG 已经能解决八成问题,微调是处理剩下两成——比如语气、格式、术语和公司口径。后面每一章都在讲这两件事怎么配合,以及源码包里哪些代码可以沿用、哪些参数必须自己调。

2. 搭建 RAG 管道:从文档解析到向量检索的完整实现

RAG 的落地过程其实就是一条“先搜索,再生成”的管线。我一般把这条线拆成四步:文档加载成纯文本、按合理粒度切片、用 embedding 模型向量化、把问题向量拿去做召回。前两步决定了系统的“材料”质量,后两步决定了“找得快不快,准不准”。源码包通常把这几步串成一个能跑的脚本,但一个能跑的脚本和一份能用的知识库之间,隔着的就是对切片粒度和向量模型的理解。

2.1 文档解析与切片:为什么切片粒度直接决定检索质量

文档解析没有太多玄学,核心是别把噪声带进来。用 pypdf 抽 PDF 文本,用 python-docx 抽 Word,用 frontmatter 抽 Markdown。抽完之后先清洗:去掉连续出现的短行、目录页码、页眉页脚。很多人跳过这步,结果向量库里全是版权声明。

切片是 RAG 里最需要“手艺”的一步。我常用的策略是先按标点切句,再把句子按设定长度拼成块,块与块之间保留重叠。为什么要重叠?因为一个完整的解决方案可能横跨两块边界,重叠可以让前后两片都包含那部分信息。但重叠太多会让同一内容多次被召回,影响重排结果。常见做法是 chunk_size 取 400 个中文字符,overlap 取 80 字符左右。

from pypdf import PdfReader import re def extract_text(path): reader = PdfReader(path) text = [] for page in reader.pages: page_text = page.extract_text() # 去掉页眉页脚、短行等噪声 clean = [l for l in page_text.split("\n") if len(l.strip()) > 5] text.append("\n".join(clean)) return "\n".join(text) def split_chunks(text, chunk_size=400, overlap=80): # 先按中文句号、问号、分号分句 sentences = re.split(r"(?<=[。!?;])", text.replace("\n", "。")) chunks, buf, cur_len = [], [], 0 for s in sentences: if cur_len + len(s) > chunk_size and buf: # 重叠区:把上一块最后一句保留到新块开头 tail = buf[-1] if buf else "" chunks.append("".join(buf)) buf = [tail] cur_len = len(tail) buf.append(s) cur_len += len(s) if buf: chunks.append("".join(buf)) return chunks

这段代码的逻辑是:先按页抽取并过滤过短的无效行,再按句切分,把句子累积成块。当超过 chunk_size 时保存当前块,并把上一句作为新块的头,让块之间形成 overlap 区。参数说明:chunk_size 不是越大越好,操作手册可以放到 500 到 600,FAQ 类短问答放到 200 就行;overlap 太大会制造重复向量,我一般控制在 chunk_size 的 1/6 到 1/4。如果是 Markdown 这类带标题的文档,建议先按标题拆成小节,再在小节里做上面的切分,避免把两个不同主题的段落拼到同一个向量里。表格是个例外:PDF 里的表被抽出来通常是乱七八糟的文本,常见做法是先转成 CSV 再逐行处理,或者把表头跟行数据拼成一句描述文字。

2.2 向量化与存储:embedding 模型和向量库怎么选

embedding 模型的选择是 RAG 的第一个“黑匣子”。同样一句话,用 bge-m3 和用其他英文为主训练的模型,检索结果可能完全不同。我一般会先看语料语言和模型支持的最大长度,中文场景优先选中文效果稳定的开源模型。这里不推荐依赖联网的 embedding 服务,本地知识库强调的就是数据不出内网。

向量库这边,初期用 faiss 或者 chroma 都行。faiss 是个索引库,快,但需要自己维护 doc_id 到原文字的映射;chroma 自带持久化和过滤器,适合数据量在几十万以内的项目。数据量再往上,到百万级,再考虑 milvus 一类的分布式向量库。如果项目默认用 faiss,你可以先跑通,再决定要不要换成支持过滤条件的存储。

from sentence_transformers import SentenceTransformer import faiss import numpy as np model = SentenceTransformer("BAAI/bge-m3") vecs = model.encode(chunks, normalize_embeddings=True) dim = vecs.shape[1] index = faiss.IndexFlatIP(dim) index.add(np.asarray(vecs)) # 保存 id 到元信息的映射,供后面溯源 meta = [{"text": c, "source": file_path, "chunk_id": i} for i, c in enumerate(chunks)] faiss.write_index(index, "kb.index")

这段做了三件事:加载 embedding 模型,把切片编码成向量并归一化;用内积索引存储向量;把每个向量的原文字和来源文件路径保存成 meta 列表。参数说明:normalize_embeddings=True 不能省,因为 IndexFlatIP 计算内积,归一化后内积等价于余弦相似度。bge-m3 输出向量维度是 1024,如果换模型,dim 会跟着变,所以换模型后要重新建库,不能直接读旧 index。另外 faiss 保存的只是向量,真正的文本在 meta 列表里,生产环境我会把 meta 序列化到同名 json 或塞进 sqlite,避免内存里拖着几万条文本。

2.3 召回与重排:Top-K 阈值和 Reranker 的配合

向量检索一次可以召回五十条,但这五十条只算“候选”,不是“答案”。直接拿候选拼给大模型,上下文会被无关片段污染。常见做法是先把候选压缩到合理规模,再做一次精确排序。

这里有一个容易踩的误区:向量分数高不代表真的相关。双塔结构把问题和文档分开编码,它很难判断“问题中的词”和“文档中的词”是不是同一个角色。所以我会先设一个比较宽的相似度阈值,把明显不相关的过滤掉,再用交叉编码器 Reranker 做精排。交叉编码器把问题和文档拼在一起过一轮 Transformer,能考虑到交互细节,但速度慢,所以它处理的是过滤后的 Top-50。

query_vec = model.encode([question], normalize_embeddings=True) scores, indices = index.search(np.asarray(query_vec), 50) candidates = [] for score, vec_id in zip(scores[0], indices[0]): if score > 0.5: candidates.append(meta[vec_id]) # 交叉编码器重排 from transformers import AutoTokenizer, AutoModelForSequenceClassification rk_tok = AutoTokenizer.from_pretrained("BAAI/bge-reranker-large") rk_model = AutoModelForSequenceClassification.from_pretrained("BAAI/bge-reranker-large") pairs = [[question, c["text"]] for c in candidates] inputs = rk_tok(pairs, return_tensors="pt", padding=True, truncation=True, max_length=512) logits = rk_model(**inputs).logits.squeeze(-1).detach().numpy() best_3 = [candidates[i] for i in logits.argsort()[::-1][:3]]

这段代码先做宽度为 50 的召回,按分数过滤得到 candidates,然后让 reranker 给每条打一个交互得分,最后取分数最高的三条作为最终上下文。参数说明:相似度阈值可以先通过统计看看。常见做法是拿 200 条正常问题跑一遍,观察 Top-1 分数的分布,取分位数而不是拍脑袋。如果过滤后 candidates 太少,就把阈值调低,交给 reranker 处理。reranker 的 max_length 我一般给 512 token 左右,召回切片长度控制在前面的 400 字符内,刚好塞得下。到这里,RAG 管道的检索部分已经通了,下一步要解决的是:这些检索到的内容,怎么组织成 LLM 能看懂并且不乱说的 Prompt。

3. 让模型学会读上下文:Prompt 编排、上下文压缩与兜底

检索再准,生成端如果不会用上下文,整个系统还是翻车。这一章要做的三件事是:保证问题能对准知识库、保证参考内容不超限、保证模型没有答案时不硬编。这三件事大部分情况下不需要微调就能解决,把它们做好了,后面的微调才有意义。

3.1 Query 改写与对话记忆:别用原始问题直接检索

多轮对话里的问题经常是“那它改成多少合适”这种带指代和省略的表达。直接把这句话送去检索,大概率召回一堆“它是什么”的内容。至少在我做过的项目里,用户很少愿意把问题说完整,所以我会在处理检索前加一个轻量的改写步骤。

改写不是让模型做阅读理解,而是把当前问题补成一句独立的话。补全的依据是最近的对话历史。这里注意,历史不要全部拿,取最近三四轮就够,太多会稀释当前意图,改写结果反而偏离。

def rewrite_query(raw_question, history): if not history: return raw_question prompt = f"""请把下面的当前问题改写成一句不依赖对话历史的独立问句,要求意思不变、适合搜索引擎检索,只输出改写结果。 对话历史: {history[-4:]} 当前问题:{raw_question} 改写:""" return call_llm(prompt, max_new_tokens=32)

这段代码的意思是:有历史的时候,把最近四轮拼进 prompt,要求模型输出一句独立的检索问句;没有历史就直接返回原问题,减少一次模型调用。参数说明:max_new_tokens 给 32 就够,改写不会产生很长的答案;如果模型温度设得比较高,最好降一点,我一般控制在 0.2 左右。不是所有场景都要改写:如果系统只做单轮 FAQ,跳过这一步能省不少延迟。改写也会引入幻觉,比如把“它”补成“成本”,如果知识库里根本没有这个概念,反而更差。所以我习惯把改写结果拿去做检索,但最终生成时用原始问题做 prompt 主体。

3.2 上下文组装与 Prompt 模板:约束大于提示

LLM 生成回答时,上下文不是越多越好。检索来的内容可能互相矛盾,也可能把用户关心的答案埋在两百行日志里。常见做法是只保留重排后的前三段,并给每一段标注来源。Prompt 模板里最核心的一句话是“如果参考内容中没有答案,直接说没有”,这比“请仔细回答”有用得多。

def build_prompt(question, contexts): ctx_lines = [] for i, ctx in enumerate(contexts[:3]): ctx_lines.append( f"[{i+1}] {ctx['text'][:500]}\n来源:{ctx['source']}" ) ctx_text = "\n\n".join(ctx_lines) sys_prompt = ( "你是一个基于本地知识库的问答助手。请根据上面的参考内容," "用简洁、准确的中文回答用户问题。" "如果参考内容没有相关信息,请直接回答‘知识库中没有找到相关内容’,不要编造。\n\n" f"参考内容:\n{ctx_text}" ) return sys_prompt + f"\n\n用户问题:{question}\n回答:"

这段代码做了三件事:限制最多三段上下文、每段截断到 500 字符、在 prompt 里同时给出来源。参数说明:截断长度要和模型上下文窗口及生成长度一起看,我一般把整段 ctx_text 控制在 1500 到 2000 字符,给回复留足空间。来源的作用不只是溯源,它还能提醒模型:这是某个文档里的原话,别把它当成自己的记忆。如果检索到的段落本身很长,也可以在拼进 prompt 前先做压缩。常见做法是用分词器把问题里的关键词抽出来,只保留包含这些词的句子及其前后一句话,这样能把 500 字段落压到 150 字左右,减少噪声。

3.3 检索不到时的兜底策略:宁缺毋滥

检索不到或者最高分很低的时候,模型往往会开始“自由发挥”,尤其当你问它一个知识库里没有的问题,它可能会用预训练阶段记住的信息编答案。这在企业问答里是致命的,因为用户会把模型说的每个字当承诺。常见的兜底逻辑是:先判断最高分是否低于阈值,是就直接回复“未收录”。

def route(question, hits): if not hits: return "知识库中还没有找到相关内容。你可以换个说法再问,或联系管理员补充文档。" if hits[0].score < 0.45: return "知识库中还没有找到相关内容。你可以换个说法再问,或联系管理员补充文档。" return generate_answer(question, hits)

这段代码是生成前的一道闸门:没有候选或者最高分过低,就不进入生成环节。阈值 0.45 是经验值,主要看 embedding 模型的得分分布,不一定适合你的数据,所以我建议上线前先跑一批人工标注的问题,把“正确答案的最低分”找出来,再往下降一点作为兜底阈值。除了直接拒绝,还可以尝试第二次检索:把用户问题里的动词、名词拆开,用关键词检索(比如 BM25)再捞一轮。如果两轮都失败,再拒绝也不迟。这样既保证了系统下限,也不至于把用户噎住。

4. 微调 LLM 的正确姿势:LoRA 微调让回答贴合知识库

标题里点明了 LLM 微调,所以这一章重点放在 LoRA。先说清楚,微调不是 RAG 的补丁,它是用来解决“生成口径”问题的。如果检索都不到位,微调再多也救不了。但反过来,当检索结果的文本已经足够,模型还是用不合预期的方式回答,那 LoRA 微调就是性价比最高的解法。

4.1 先判断要不要微调:RAG 瓶颈在生成而不是检索才值得

我判断要不要微调,有一个很简单的对照实验:把检索到的 Top-3 上下文直接贴给一个现成的通用大模型,不附加微调逻辑,看看它输出什么。如果通用模型已经能答得七七八八,那就没必要微调;如果它经常把术语解释错、或者总是说“根据我的知识”,那才是微调的候选场景。

还有一种情况是输出格式不符合系统要求:比如你的下游系统需要模型输出 JSON,通用模型偶尔会多一句“好的,我来处理”,这种固定格式问题,微调比写一万条正则更省事。另一个信号是领域词汇稳定:比如“拉闸”“并机”“加急单”这些词,通用模型会按日常语义理解,但你们系统里它们有固定含义。最后可以通过 LLM-as-Judge 来做客观判断:让一个大模型给回答打分,如果某个错误类型反复出现,说明是生成策略问题,不是检索问题。这个阶段不要碰训练参数,先把数据攒起来,没有高质量数据前,微调只会把模型带偏。

4.2 构造微调数据集:让模型学会“根据资料回答”而不只是背答案

微调数据的核心不是让模型记住知识库,而是让它学会一套行为习惯:给定参考内容,怎么组织答案、怎么拒绝、怎么措辞。所以我搭数据集时,样本结构和第 3 章的 build_prompt 输出保持一致。如果训练时喂的模板和实际推理时不一致,微调基本白做。

常见数据格式是两条 JSONL:instruction 是系统指令,input 是一段参考内容加用户问题,output 是期望回答。下面是一条样例,你可以看到 input 里明确放进了参考内容和“参考内容没有请回答未收录”的约束,这就是在调教模型的行为。

{"instruction": "你是基于本地知识库的问答助手。请根据参考内容简洁、准确回答;参考内容没有相关信息时,回答“知识库中没有找到相关内容”。", "input": "参考内容:\n账号开通流程:第一步提交申请单,第二步由管理员审批,第三步开通后通知申请者。\n用户问题:怎么申请账号?", "output": "先提交申请单,管理员审批通过后,由管理员开通并通知你。"}

构造训练数据时,一定不要从知识库里整段复制原文当答案,否则模型学到的就是“复读”。常见做法是把一段资料交给一个强模型,让它根据这段资料生成问题,然后人工把答案改写成一句话。这样一轮下来,数据质量比自动生成高得多。同一批数据还要做去重:如果多份文档讲同一个流程,保留表述最清晰的那一份,不然模型会对同一问题学到多种口径。

4.3 LoRA 微调参数:learning_rate、r 与 target_modules 的推荐值

LoRA 的推荐值是起点,不是真理。我会在几个关键参数上给一个“至少不会翻车”的组合。下面是最常见的 QLoRA 训练脚本骨架。

from peft import LoraConfig, get_peft_model from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments, Trainer model_id = "Qwen/Qwen2-7B" model = AutoModelForCausalLM.from_pretrained( model_id, load_in_4bit=True, device_map="auto" ) tokenizer = AutoTokenizer.from_pretrained(model_id) lora_cfg = LoraConfig( r=8, lora_alpha=16, target_modules=["q_proj", "k_proj", "v_proj", "o_proj"], lora_dropout=0.05, bias="none", task_type="CAUSAL_LM", ) model = get_peft_model(model, lora_cfg) training_args = TrainingArguments( output_dir="./rag_qa_lora", per_device_train_batch_size=4, gradient_accumulation_steps=8, learning_rate=1e-4, num_train_epochs=3, logging_steps=50, save_strategy="epoch", fp16=True, )

每个参数都值得说。r 是低秩矩阵的秩,r=8 能在大多数 7B 模型上得到稳定效果;数据量超过一万条可以试 r=16。lora_alpha 和 r 的比例一般保持 2 比 1,alpha 调大等于放大注入效果的倍率,但不要单独猛升。learning_rate 是这里最容易翻车的参数,1e-4 是安全起点,5e-5 更稳,超过 2e-4 容易出现生成乱码或复读。target_modules 为什么列 q/k/v/o?因为这是因果语言模型自注意力最常见的投影矩阵。注意你的模型不一定叫这个名字,先打印 model 查一下再填,否则 LoRA 会静默地什么都不更新。fp16 在部分新显卡上可能 loss 异常,如果训练时 loss 变成 nan,把 fp16 关掉或换成 bf16。

训练完成后,不要把 LoRA adapter 马上合并回 base model。我一般保留 adapter 文件,因为后续想对比不同 rank 的模型,或者做回退,都只需要换 adapter,这一步是“后悔药”。推理时用 peft 的 from_pretrained 加载 base 加 adapter,然后跑和训练一模一样的 prompt 模板。

5. 从源码跑通到生产可用的避坑笔记:5 个常见问题排查

源码包通常已经写好了一个“能跑”的流程,但换成你自己的文档和问答数据,会出现各种 demo 阶段看不见的问题。下面五条全是我自己踩过或陪别人排查过的,现象都很典型,照着现象去比对就行。

5.1 页眉页脚混进切片,系统答非所问

现象:问“账号怎么申请”,回答里出现“第 3 页,共 10 页”或公司版权信息,模型还煞有介事地说这是申请条件。

原因:PDF 文本抽取时把页眉页脚和正文一起抽出来了,切片时没有做页面级清理,这类短行被当成正文建了向量。

解决:切分前先按页处理文本,过滤每页重复出现的短行。我一般用一个正则加一个统计:把每页所有行按内容去重,出现次数超过总页数一半、长度又小于 20 个字符的行直接删掉。对“第 x 页”这种格式再做一次正则清理。抽完文本先打印出来看前几页,不要直接进向量库。

5.2 相似度阈值拍脑袋设 0.8,大量问题触发兜底

现象:几轮人工测试发现回答质量不错,上线后却经常回“知识库未收录”。

原因:调试时只看了几条命中分数都在 0.8 以上,就顺手把阈值设成 0.8。实际业务问题表达更口语化,正常命中分数大多在 0.5 到 0.7 之间,0.8 的阈值等于把不少有效问题挡在门外。

解决:阈值不要用绝对值拍出来,先跑一批人工标注问题,统计正确结果的相似度分布,取五分之一分位数左右作为初始阈值,再人工抽检 20 条。更好的做法是阈值只作为最后一道保险,前面的过滤交给 Reranker,而不是一上来就用高分卡死。

5.3 微调后模型机械复读上下文,不会总结

现象:微调后的问答系统,把检索到的一段话原样粘出来,有时候直接断在句中间,看起来像没生成完。

原因:训练集里的 output 是直接从知识库复制出来的,模型发现复读可以让 loss 降得很低,于是学会了复读,没有学会组织语言。

解决:整理训练集时,output 改成不超过 50 字的一句话摘要,并且要求不得原样复制长句。如果已有大量自动生成的数据,可以用一个强模型先把答案重写一遍,再人工抽检 50 条,确保答案明显比原文短。这个步骤花时间,但缺了它微调意义不大。

5.4 训练和推理的 Prompt 模板不一致,微调效果归零

现象:微调后的模型离线验证很好,部署到服务上行为跟没微调一样。

原因:训练时用的是“根据参考内容回答”,推理时框架默认加载的模板是“You are a helpful assistant”,模型没进入预期的指令状态。

解决:把 prompt 模板抽成单独函数,训练数据生成和推理服务都 import 同一个函数。如果模板里有特殊 token,注意换行和 token 前后空格保持一致。升级底模后要重新评估一遍,因为新底模的 chat 模板可能有变化。

5.5 长上下文把显存吃光,先分桶再动态 Padding

现象:一张 24G 显卡做 QLoRA 微调,batch_size 调到 1 还是 OOM,训练第二步就中断。

原因:数据集中有一部分样本特别长,比如某份操作日志有 5000 字,训练时按照全局 max_seq_len 做 padding,这部分浪费了大量显存。还有些超长样本被直接截断,答案丢失。

解决:按输入长度给数据集分桶,比如 0-512、512-1024、1024-2048 三个桶,桶内动态 padding。长样本的 batch_size 调小,用 gradient_accumulation 凑数。如果文档真的很长,考虑在训练前先用 2.1 节的切片逻辑,把样本切成不超过 1024 字符的片段,保证每条样本都是一段完整的问答理由。

6. 用评测集而不是体感来验收系统:一个 100 条问题的验证方案

这是最后一步,也是最容易被人忽略的一步。很多时候我们改一个参数,觉得“今天效果变好了”,但实际上只是测试的那两条问题碰巧好。正确做法是建一个一百条问题左右的评测集,把改动前后跑一遍,用数字说话。

评测集格式很简单:一条真实问题、期望答案里必须出现的两三个关键词、希望命中的来源文档编号。跑完系统后,先判断关键词是否出现在回答里,再人工抽看十条回答,确认不是关键词堆砌。

def run_eval(system, cases): hit = 0 unknown = 0 for case in cases: pred = system.ask(case["question"]) if "知识库中没有找到相关内容" in pred: unknown += 1 continue if any(k in pred for k in case["keywords"]): hit += 1 return { "answer_hit_rate": hit / len(cases), "unknown_rate": unknown / len(cases), }

这段代码用两组指标衡量系统:answer_hit_rate 是有答案且答对的占比,unknown_rate 是“无法回答”的占比。如果 answer_hit_rate 太低,说明检索或生成有问题;如果 unknown_rate 太高,说明兜底阈值太严或召回不够。

我第一年做问答系统,凭感觉把 Top-K 从 5 调到 20,觉得找回的东西多了就一定更准,结果跑评测集发现准确率反而掉了。定位了半天才意识到是切片重叠导致同一段内容反复出现,排挤了真正有用的其他分块。从那以后,我养成了每次动任何参数都先跑一遍这 100 条问题的习惯。这套评测集会在你换 embedding、调 prompt、做微调时被反复使用,值得一开始就花半天建起来。希望帮到你。

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

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

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

立即咨询