本地知识库检索与LLM微调:构建智能问答系统实战指南
简介本资源面向希望深入理解检索增强生成RAG与本地知识库问答的开发者与算法学习者提供一套基于本地知识库检索结合LLM微调的智能问答系统完整实战方案帮助解决通用大模型在垂直领域回答不精准、知识更新滞后的问题。压缩包共61个文件约30.12MB包含13个Python脚本、31个txt文本、7个json配置、3个bin权重文件、3个ipynb实验笔记及pdf、png等辅助资料覆盖数据预处理、模型训练、检索与生成融合等关键环节。已有1829人学习下载具备一定参考热度。源码中提供ChatGLM-6B的P-Tuning v2与LoRA微调脚本、基于SBERT与Miracl的向量检索实现以及WebUI交互界面读者可据此快速搭建原型并做定制化优化同时借助notebooks理解微调与检索流程适合具备一定深度学习基础、希望掌握RAG落地方法的开发者参考实践。1. 本地知识库检索加 LLM 微调一套能跑通的智能问答系统长什么样很多团队第一次做智能问答系统都会掉进同一个坑直接拿通用大模型硬答结果它对企业内部文档、产品手册、工单记录一无所知要么胡编要么答得似是而非。RAG检索增强生成加本地知识库检索配合 LLM 微调就是目前落地这类系统最稳的一条路。它的核心逻辑不复杂用户提问后系统先去本地知识库里检索出最相关的几段内容再把这几段内容和问题一起塞给大模型让它基于真实资料作答而微调则负责让模型学会你所在领域的说话方式和输出格式。这套方案适合手里有私有文档、又不想把数据传出去的团队也适合想从零搭一个 RAG 项目实战练手的人。下面我按自己搭过几套系统的经验把选型、检索、微调、拼装和踩坑一条条讲清楚。2. 检索层怎么搭从文档切分到向量库落地2.1 为什么本地知识库检索不能只靠关键词关键词检索比如 BM25在专有名词、型号、编号上很准但它理解不了语义。用户问“设备过热怎么处理”文档里写的是“温度异常升高应对措施”关键词匹配就废了。向量检索把文本映射成高维向量用余弦相似度找语义相近的段落能补上这个短板。实际落地里我一般用混合检索BM25 负责召回精确词向量检索负责召回语义相关段落两路结果合并去重后再排序。这样既不会漏掉型号查询也不会在口语化提问上翻车。选型上嵌入模型优先选中文能力强的比如 BGE 系列或 M3E本地部署用 sentence-transformers 就能跑。向量库方面数据量在百万级以内Chroma 或 FAISS 足够要持久化、要过滤元数据Milvus 或 Qdrant 更合适。别一上来就上重型分布式向量库单机 FAISS 加一层缓存很多场景已经够用。2.2 文档切分与向量化的最小可跑脚本切分是 RAG 里最容易被低估的一步。切得太碎上下文丢失切得太粗检索精度下降。我一般按 300 到 500 字切一块块间重叠 50 到 80 字保证跨块语义不断裂。下面这段代码用 LangChain 的递归切分器加 BGE 嵌入模型把本地文档灌进 Chroma。from langchain.text_splitter import RecursiveCharacterTextSplitter from langchain_community.embeddings import HuggingFaceEmbeddings from langchain_community.vectorstores import Chroma from langchain_community.document_loaders import TextLoader import os # 1. 加载本地文档这里以 txt 为例pdf 可换 PyPDFLoader loader TextLoader(./docs/manual.txt, encodingutf-8) docs loader.load() # 2. 递归切分优先按段落切再按句子最后按字符 splitter RecursiveCharacterTextSplitter( chunk_size400, # 每块目标字数 chunk_overlap60, # 块间重叠防止语义断裂 separators[\n\n, \n, 。, , , , ] ) chunks splitter.split_documents(docs) # 3. 加载中文嵌入模型本地推理不联网 embedding HuggingFaceEmbeddings( model_nameBAAI/bge-small-zh-v1.5, model_kwargs{device: cpu}, # 有 GPU 改 cuda encode_kwargs{normalize_embeddings: True} # 归一化后余弦相似度更稳 ) # 4. 写入 Chroma 持久化向量库 vectordb Chroma.from_documents( documentschunks, embeddingembedding, persist_directory./chroma_db ) vectordb.persist() print(f已写入 {len(chunks)} 个文本块)这段代码里chunk_size和chunk_overlap是最需要按业务调的两个参数。技术手册可以切小一点300 字左右法律合同条款长可以放到 600 字。normalize_embeddingsTrue很关键不归一化的话不同长度文本的向量模长差异会干扰相似度排序。嵌入模型选 small 版是为了速度如果检索召回率不达标再换 base 或 large但延迟会明显上升。2.3 检索参数怎么调TopK、阈值与重排序向量库建好后检索质量取决于三个参数返回条数 TopK、相似度阈值、是否加重排序。TopK 太小可能漏掉关键段落太大噪声进上下文大模型反而被带偏。我一般先设 TopK5阈值 0.3 到 0.5 之间跑一批真实问题看召回。如果发现答非所问先看检索出来的段落是不是相关而不是急着换模型。重排序Rerank是提升精度的利器。先用向量检索召回 20 条再用交叉编码器如 BGE-Reranker对这 20 条精排取前 5 条给大模型。这样既保证召回又保证精度。代价是多一次模型推理延迟增加 100 到 300 毫秒看业务能不能接受。# 检索示例带分数过滤和重排序 query 设备温度过高如何处理 results vectordb.similarity_search_with_score(query, k20) # 先按分数粗筛分数越低越相关Chroma 返回的是距离 filtered [r for r in results if r[1] 0.8] # 这里可接入 BGE-Reranker 精排伪代码如下 # reranker CrossEncoder(BAAI/bge-reranker-base) # pairs [(query, r[0].page_content) for r in filtered] # scores reranker.predict(pairs) # top5 [filtered[i] for i in scores.argsort()[-5:][::-1]]注意不同向量库返回的分数含义不同Chroma 返回的是距离越小越相关FAISS 用内积时越大越相关。调阈值前先确认分数方向否则会把最相关的段落过滤掉这种翻车我见过不止一次。3. LLM 微调什么时候该微调什么时候不该3.1 RAG 和微调的分工边界很多人一上来就想微调觉得微调能解决所有问题。实际上RAG 解决的是“知识从哪来”微调解决的是“话怎么说”。如果模型答错是因为知识库里没有微调没用如果模型答对了但格式不对、语气不对、总爱加废话微调才值得做。我的经验是先用 RAG 把知识注入跑通再看输出格式和领域术语是否达标不达标再上微调。顺序反了会浪费大量标注和算力。微调数据一般构造为指令格式{instruction: 用户问题, input: 检索到的上下文, output: 标准答案}。数据量不用很大500 到 2000 条高质量样本就能让模型学会输出风格。关键是质量不是数量。一条胡编的标注会带坏整个模型。3.2 LoRA 微调的最小配置与训练脚本全量微调成本高LoRA 是当前最实用的方案。它在原模型旁挂低秩矩阵只训练这部分参数显存占用降到全量的三分之一甚至更低。下面用 PEFT 加 Transformers 跑一个 LoRA 微调基座选 Qwen2.5-1.5B 这类小模型单卡就能跑。from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments from peft import LoraConfig, get_peft_model from datasets import load_dataset from trl import SFTTrainer model_name Qwen/Qwen2.5-1.5B-Instruct tokenizer AutoTokenizer.from_pretrained(model_name, trust_remote_codeTrue) model AutoModelForCausalLM.from_pretrained( model_name, trust_remote_codeTrue, device_mapauto ) # LoRA 配置r 是秩alpha 是缩放target_modules 决定挂哪些层 lora_config LoraConfig( r8, lora_alpha16, target_modules[q_proj, v_proj], # 注意力层的 Q、V 矩阵 lora_dropout0.05, biasnone, task_typeCAUSAL_LM ) model get_peft_model(model, lora_config) # 加载指令数据集格式为 instruction/input/output dataset load_dataset(json, data_files./data/train.json, splittrain) training_args TrainingArguments( output_dir./lora_out, per_device_train_batch_size2, gradient_accumulation_steps8, # 等效 batch size 16 learning_rate2e-4, num_train_epochs3, logging_steps10, save_strategyepoch, fp16True # 有 GPU 开混合精度省显存 ) trainer SFTTrainer( modelmodel, argstraining_args, train_datasetdataset, tokenizertokenizer, max_seq_length1024 ) trainer.train() trainer.save_model(./lora_final)参数上r8是常用起点任务复杂可以加到 16 或 32但过大会接近全量微调失去 LoRA 的意义。learning_rate2e-4比全量微调高一个量级因为只训练少量参数。gradient_accumulation_steps用来在小显存上凑等效 batch size别设太大否则训练变慢且不稳定。训练完的 LoRA 权重只有几十兆推理时和基座合并即可。3.3 微调后的模型怎么接回 RAG 流程微调完不是终点要把 LoRA 权重加载回基座再和检索层拼成完整链路。推理时检索出的上下文和用户问题按固定模板拼接送进微调后的模型。模板要和训练时一致否则模型会懵。from peft import PeftModel # 加载基座和 LoRA 权重 base AutoModelForCausalLM.from_pretrained(model_name, device_mapauto) model PeftModel.from_pretrained(base, ./lora_final) model model.merge_and_unload() # 合并权重推理更快 def build_prompt(query, contexts): ctx \n.join(contexts) return f根据以下资料回答问题不要编造资料外内容。\n资料{ctx}\n问题{query}\n回答 # 检索 生成 contexts [doc.page_content for doc, _ in vectordb.similarity_search_with_score(query, k5)] prompt build_prompt(query, contexts) inputs tokenizer(prompt, return_tensorspt).to(model.device) outputs model.generate(**inputs, max_new_tokens256, temperature0.3) print(tokenizer.decode(outputs[0], skip_special_tokensTrue))temperature0.3是为了让回答稳定智能问答场景不需要创意。max_new_tokens控制回答长度设太大模型容易啰嗦。模板里的“不要编造资料外内容”这句话很管用能明显降低幻觉率但前提是检索确实召回了相关内容。4. 避坑与排查RAG 加微调最常见的五个翻车点4.1 检索召回一堆不相关段落模型跟着胡编现象用户问 A检索出来的是 B 和 C模型基于 B、C 硬答答案看似合理实则错误。原因通常是切分粒度不对或嵌入模型不匹配领域。解决先人工看 20 个问题的检索结果确认召回段落是否相关不相关就调 chunk_size或换领域嵌入模型再不行加 Rerank。别跳过人工检查直接调大模型。4.2 微调后模型变“傻”通用能力下降现象微调后领域问题答得好了但闲聊和通用问题答得一塌糊涂。原因是训练数据太单一模型过拟合到特定格式。解决训练集里混入 10% 到 20% 的通用指令数据或者降低训练轮数。LoRA 的 r 也别设太大r8 通常够用r64 很容易把基座带偏。4.3 上下文塞太多模型反而忽略关键信息现象检索返回 10 条全塞进 prompt模型却只用了其中一条甚至用错。原因是长上下文里模型注意力分散中间部分容易被忽略。解决TopK 控制在 3 到 5加 Rerank 精排把最相关的放前面。如果必须塞长上下文把关键段落放在开头或结尾中间放次要的。4.4 向量库更新后检索结果没变现象文档改了重新灌库但检索还是旧内容。原因是 Chroma 或 FAISS 的持久化目录没清或者用了缓存。解决更新文档时先删旧 collection 再重建或者用带版本号的 collection 名。FAISS 要重新 save_index别只覆盖原文件。4.5 微调 loss 降了但实际效果没提升现象训练 loss 从 2.0 降到 0.5但推理时回答格式还是不对。原因是训练数据和推理模板不一致或者评估指标只看 loss 没看实际输出。解决训练前先拿 10 条样本跑一遍基座确认模板拼接正确训练后人工评估 50 条真实问题别只看 loss 曲线。loss 低不代表业务效果好这是血泪经验。5. 进阶技巧用混合检索加查询改写把召回率再提一档基础链路跑通后想再往上提重点在检索侧。我常用的两个技巧查询改写和混合检索加权。查询改写是让大模型先把用户口语化问题改写成更适合检索的多个查询再分别检索合并结果。比如“这机器老发热咋办”改写成“设备温度过高处理方法”“过热故障排查步骤”召回率能明显上升。def rewrite_query(query, model, tokenizer): prompt f把下面的问题改写成三个适合检索的关键词查询每行一个\n{query} inputs tokenizer(prompt, return_tensorspt).to(model.device) outputs model.generate(**inputs, max_new_tokens128, temperature0.7) text tokenizer.decode(outputs[0], skip_special_tokensTrue) return [line.strip() for line in text.split(\n) if line.strip()][:3] # 多路检索合并去重 all_docs {} for q in rewrite_query(query, model, tokenizer): for doc, score in vectordb.similarity_search_with_score(q, k5): key doc.page_content[:50] if key not in all_docs or score all_docs[key][1]: all_docs[key] (doc, score) # 按分数排序取前 5 final sorted(all_docs.values(), keylambda x: x[1])[:5]混合检索加权则是把 BM25 和向量检索的分数归一化后加权求和权重按业务调。型号查询多的场景BM25 权重给 0.6口语化提问多的向量权重给 0.7。没有万能权重拿 100 条真实问题跑网格搜索选召回率最高的那组。验证方法上我习惯建一个小型评测集50 到 100 条问题每条标注正确答案所在的文档块。跑完检索看 Top5 命中率跑完生成看人工评分。命中率低于 80% 就先调检索别动微调。这套评测集每次改参数都跑一遍比凭感觉调靠谱得多。最后说个习惯我每次上线前都会把检索结果和模型回答一起打日志出问题能回溯是哪一步错了。RAG 系统不是黑匣子把中间结果暴露出来排查效率翻倍。希望帮到你。本文还有配套的精品资源点击获取