
简介这是基于LangChain和ChatGLM-6B等系列大语言模型搭建的本地知识库自动问答系统项目实践包适合从事NLP、想要快速落地RAG应用或私有化知识库问答的开发者学习。包内覆盖文档加载、中文长文本切分、文本向量化、向量检索到大模型生成回答的完整链路并提供可运行的Web界面与命令行接口。资源共75个文件压缩包约17.77MB以pickle数据、Python源码、md/txt说明文档、jpg示意图为主同时包含Dockerfile、pyproject.toml、requirements依赖清单等便于复现环境与二次开发。已有936人学习使用适合有一定Python基础、希望跑通LangChain与ChatGLM官方示例后进一步增强实战能力的读者。内容涉及PaddlePaddle向量模型接入、modelscope模型加载、离线部署配置、FAQ与更新日志等能有效降低环境配置和调试成本帮助更快掌握本地知识库问答系统的搭建思路。1. 为什么单靠大模型答不好内部文档一份 zip 里的本地知识库问答链路如果你手里有一批内部文档比如设备排障手册、验收记录、历史工单直接把问题丢给通用大模型它大概率会一本正经地编答案——因为它从没见过这些资料。标题里这套“基于 LangChain 和 ChatGLM-6B 等系列 LLM 的本地知识库自动问答”方案解决的就是让开源模型读懂私有语料的问题先把本地文档切块、向量化用户提问时先检索出最相关的几段文本再把“检索结果 问题”拼进 prompt 交给 ChatGLM-6B 生成回答。对数据不能出内网、预算有限又要私有化部署的团队来说这是当下最务实的一条路。接下来按“原理—环境—入库—排查—落地”的顺序把它拆开讲零基础也能照着搭。2. 为什么是“外部知识库 LLM”而不是微调先看懂 RAG 这条链路把标题压缩成一句话给 ChatGLM-6B 外挂一个可以检索的记忆库。这个方案在业界叫 RAG检索增强生成。对比把文档拿去微调模型RAG 的优势非常具体文档更新不需要重新训练模型回答时可附上原始段落作为依据对 GPU 显存和标注数据的要求也低一个数量级。先把这条链路看明白后面配置参数时才知道每个旋钮在拧什么。2.1 先过一遍流程加载、切分、向量化、检索、拼装、生成RAG 的完整流程可以拆成六个动作。第一步是文档加载用 LangChain 里的 Loader 把 PDF、Word、Markdown 读成文本第二步是切分因为 LLM 的上下文窗口有限几千页手册不可能一次塞进去必须切成几百字一块的文本块第三步是向量化用一个 embedding 模型把每个文本块转成一串浮点数也就是向量第四步是检索用户提问时把问题也转成向量在向量库里找最接近的若干个文本块第五步是拼装把“检索到的文本块 用户问题”一起放进 prompt 模板第六步才是生成交给 ChatGLM-6B 这类 LLM 输出答案。这六步里LangChain 作为 LLM 框架提供的是标准化组件Loader、TextSplitter、Embeddings、VectorStore、Retriever、Chain。对 LangChain 入门者来说最容易犯的错是跳过其中一步比如不做切分直接把整篇文档塞进 prompt或者检索出来却不把原文给模型让模型凭空猜。后面每一章的代码都会落在这六个环节上你可以对照这个流程找位置。检索和生成是串行关系检索质量差后面 LLM 再强也答不对生成侧 prompt 写得差检索到正确答案也表达不出来。为什么要走“先检索再生成”而不是“全文塞给模型”因为本地知识库往往有成百上千份文档总量轻易超过十万 token。全量塞进上下文先不说窗口放不放得下模型对长文本中间部分的注意力会衰减结果就是它“看”了但没“记住”。RAG 的本质是让模型每次只读与问题最相关的几百到一千字注意力集中在那几段材料上回答质量自然比“硬读全文”好。这个设计思路贯穿整个标题项目后面所有调参都围绕它展开。2.2 中文本地问答的模型选型ChatGLM-6B 系列为什么是主力标题点名 ChatGLM-6B不是没理由的。6B 参数量在开源 LLM 里属于“显存友好”的档位量化后单卡能跑不挑机器中文语料完成度在同期开源模型里属于第一梯队而且它跟 LangChain 的兼容性经过了大量项目验证网上能搜到的中文资料也最全。这里要理解“等系列 LLM”这几个字的含义——这套方案的模型层是可以替换的今天用 ChatGLM-6B明天换成同量级的 Qwen 或 BaichuanLangChain 这边的接口不用大改只是权重路径换一下。选型的时候我会先翻 open llm leaderboard 等公开榜单做粗筛再结合自己的文档类型做小规模实测。榜单看的是通用能力真正决定成败的是模型对“给定材料做摘要和推理”这件事的服从度。实测方法很简单拿 20 条你业务里最典型的问题先人工写出标准答案再让模型分别用 RAG 方式作答对比正确率。这个动作要放在项目启动的第一周做而不是等全部文档入库之后。对比一下 RAG 和微调两条路线能更清楚为什么标题项目选前者对比项RAG检索增强生成全参数微调新增知识换文档重新入库分钟级生效需要整理训练集并重新训练小时到天级训练成本不需要训练只调检索和 prompt 参数需要 GPU 集群与较长训练周期显存需求LLM 原样运行加一个轻量向量库微调时额外占用大量显存常需多卡回答依据可返回来源段落可追溯知识固化在权重里弱解释性更新频率支持高频增量更新更新成本高一般低频全参数微调不是不好而是它解决的是“模型不会某种任务”的问题不是“模型不知道某份文档”的问题。知识库问答的需求本质是后者用 RAG 是更便宜、更可维护的解法。2.3 embedding 模型决定检索的上限从 text2vec 到 bge很多人搭完 RAG 效果差第一反应是换大模型实际上检索上限是由 embedding 模型决定的。embedding 模型负责把中文句子映射成向量如果它本身理解不了“备件替换周期”和“更换周期”是同一个意思那后面再怎么调 LLM 也是白搭。中文场景我一般优先考虑 bge 系列或者 text2vec、m3e 这类以中文语料为主的模型。bge-base-zh-v1.5 的输出维度是 768bge-large 是 1024维度越高索引体积越大但语义区分度通常会好一些。embedding 模型还有一个容易忽略的细节检索时要给它加“任务前缀”。bge 系列官方建议在将 query问题编码时加上指令前缀“为这个句子生成表示以用于检索相关文章”而 passage文档段落不加。这个小动作能让检索命中率上一个台阶但很多人从国外英文教程里抄代码完全没意识到这个问题。另一个坑是默认加载的 embedding 模型可能是英文的比如 sentence-transformers 的默认模型拿来做中文检索效果基本靠运气。代码里显式指定model_name指向本地中文 embedding 目录比依赖默认值可靠得多。embedding 和 LLM 是两套模型显存开销也是分开算的。embedding 模型通常只有几百 MB放 CPU 完全跑得动只是入库慢一点。我一般把 embedding 放 CPU把 GPU 显存全部留给 ChatGLM-6B 做生成这个分配方式在后面遇到 OOM 时会省很多事。检索质量优先于生成质量这句话值得记在本子上。3. 用 LangChain 在本地跑通 ChatGLM-6B从裸模型到第一个回答这章的目标很窄把 ChatGLM-6B 作为 LLM 接进 LangChain完成一次单轮问答不做检索。先把模型层跑通再谈知识库否则后面出了问题你分不清是模型的问题还是链路的问题。环境准备和参数设置是这一章的两块硬骨头也是大多数项目在第一天就翻车的地方。3.1 环境准备显存预算、Python 版本与依赖清单先说显存。ChatGLM-6B 的 FP16 权重加载后大约需要 12GB 显存16GB 显存的卡能比较从容地跑8GB 显存的卡必须走量化8bit 量化后能压到 8GB 上下4bit 还能更低但生成质量和速度都会有损失。我见过不少人在 8GB 笔记本上强行跑 FP16结果加载到一半就 CUDA out of memory。如果你的机器是 8GB直接上quantize(8)不要犹豫。Python 环境建议 3.8 到 3.10太新的版本容易碰到个别依赖还没有预编译 wheel。核心依赖就这么几类PyTorch、Transformers、LangChain 全家桶、sentence-transformers用于 embedding、chromadb向量库。用 requirements 管理的话常见做法是先装 torch 再装其余组件并且把版本锁定住避免 LangChain 小版本升级导致接口变化# 先装 torch根据你的 CUDA 版本选对应命令这里以 CUDA 11.8 为例 pip install torch --index-url https://download.pytorch.org/whl/cu118 # 再装 LangChain 相关与环境依赖 pip install langchain langchain-community langchain-huggingface pip install transformers sentence-transformers chromadb pip install pypdf说明一下版本接口的坑LangChain 早期版本把ChatGLM封装直接放在langchain.llms里后续主包收缩模型接入逐渐迁移到langchain-community和langchain-huggingface。如果你照着老教程写from langchain.llms import ChatGLM报了 ImportError不是你的错是包的版本结构变了。我一般直接用HuggingFacePipeline这个跨版本稳定的封装它不绑定某个具体模型ChatGLM-6B、Qwen 都能包。模型权重下载不用多说把 ChatGLM-6B 的模型目录放到本地磁盘比如./chatglm-6b里面至少要有config.json、pytorch_model.bin和分词器文件。加载时一定要传trust_remote_codeTrue否则会报加载失败这不是玄学是 ChatGLM 系列的自定义代码随权重一起发布Transformers 默认不执行未信任的外部代码。3.2 第一步用 HuggingFacePipeline 把 6B 模型接进 LangChain模型加载和封装在一块代码里完成。下面这段是我常用的最小示例先把模型从本地路径加载到 GPU再包成 LangChain 的 LLM 对象from transformers import AutoTokenizer, AutoModel, pipeline from langchain_huggingface import HuggingFacePipeline from langchain.llms import LLMChain from langchain.prompts import PromptTemplate model_path ./chatglm-6b # 权重在本地不走公网 tokenizer AutoTokenizer.from_pretrained(model_path, trust_remote_codeTrue) model AutoModel.from_pretrained(model_path, trust_remote_codeTrue).half().cuda() # 显存不足 16G 时把上面一行换成 # model AutoModel.from_pretrained(model_path, trust_remote_codeTrue).quantize(8).half().cuda() pipe pipeline( text-generation, modelmodel, tokenizertokenizer, max_new_tokens512, temperature0.3, top_p0.8, do_sampleTrue, ) llm HuggingFacePipeline(pipelinepipe) prompt PromptTemplate.from_template(请回答下面的问题{question}) chain LLMChain(llmllm, promptprompt) print(chain.run(什么是本地知识库))逻辑拆开讲from_pretrained从本地目录加载权重.half()把 FP32 权重转成半精度显存占用直接减半.cuda()把模型搬到 GPU。transformers.pipeline在这里生成了一个标准文本生成接口HuggingFacePipeline再把它包装成 LangChain 认识的 LLM。最后用LLMChain串联 prompt 模板和模型chain.run触发一次完整调用。参数注意三点max_new_tokens限制新生成的 token 数量不是限制输入长度知识问答场景设 256 到 512 足够temperature控制随机性设成 0.3 左右可以显著减少胡编乱造do_sampleTrue表示开启采样如果设成 Falsetemperature和top_p都会失效。第一次跑通后我建议先直接调model.chat(tokenizer, 你好)验证模型本身正常再走 LangChain 链路这样能把“模型坏了”和“封装坏了”快速分开。3.3 必调的三个生成参数max_length、temperature 与 top_p模型能跑只是开始真正影响问答体验的是三个生成参数。它们不是越大越好也不是越小越好要按场景匹配。下表是我调参后的常用基线参数常见范围作用知识问答建议max_new_tokens1281024限制新生成 token 数256512过长容易开始编temperature01控制采样随机性0.20.5越高发散越快top_p0.80.95核采样概率阈值0.80.9与 temperature 配合max_new_tokens不是越大越好。生成长度超过 600 之后模型很容易脱离上下文开始自由发挥编出文档里没有的内容。如果答案确实需要很长优先考虑让检索返回更多文本块而不是单纯放宽生成长度。temperature和top_p是两道随机性闸门先固定temperature在 0.3再微调top_p两个一起乱动输出会变得很难排查。检索问答属于“低随机性任务”目标是让模型忠实复述材料不是让它发挥创意所以温度偏低是常态。跑通这段之后你已经拥有一个“能聊天但不知道你业务”的裸模型。下一步就是把文档灌进去让它从“聊天模型”变成“知识库助手”。4. 把私有文档真正喂给知识库splitter、向量库与检索参数调参记录这一章是整套方案的核心工作量所在。文档加载、文本切分、向量化入库、检索参数调优四件事环环相扣。很多人把时间花在调 prompt 上其实知识库问答的效果大头在文本切分和检索参数这两块没做好prompt 写得再漂亮也是无米下锅。4.1 从 Word/PDF 到文本块中英文分割器的选型与 chunk 参数先看文档加载和切分的代码。以 PDF 为例用DirectoryLoader读取整个目录再用RecursiveCharacterTextSplitter切块from langchain_community.document_loaders import DirectoryLoader, PyPDFLoader from langchain.text_splitter import RecursiveCharacterTextSplitter # 第一步加载 docs 目录下所有 PDF loader DirectoryLoader( ./docs, glob**/*.pdf, loader_clsPyPDFLoader, ) docs loader.load() print(f加载文档数: {len(docs)}) # 第二步切分 splitter RecursiveCharacterTextSplitter( chunk_size500, # 每个文本块目标字符数 chunk_overlap80, # 相邻块重叠字符数 separators[\n\n, \n, 。, , , , ], ) chunks splitter.split_documents(docs) print(f切分后文本块数: {len(chunks)})这段代码里最值得讲的是separators。RecursiveCharacterTextSplitter会按这个顺序尝试分割先按段落分段落太长再按换行分再不行按句号、分号、逗号分。中文场景必须把中文标点加进去否则它会按英文空格硬切一个句子的语义被拦腰截断。chunk_size500表示目标块大小是 500 个字符注意这里是字符不是 token500 字符大约对应 200 到 300 个 token留足了 prompt 余量。chunk_overlap80让相邻块保留 80 字的重叠避免“一个知识点恰好被切缝切走”的尴尬。切分粒度是效果的分水岭。粒度太细比如 chunk_size200一个完整的技术方案被切成好几块检索时只能捞到其中一小块模型看到的上下文不完整粒度太粗比如 chunk_size2000块内混入大量无关内容向量相似度被稀释检索精度下降。我一般从 500 起步看具体文档调整技术手册类文档句子结构完整可以放到 600公告通知类文档段落短400 更合适。另外表格和列表是切分重灾区PyPDFLoader读出来的表格内容经常是乱的后续检索命中也是白命中。如果你的文档里有大量表格最好先用工具把 PDF 转成 Markdown 再加载保留表格结构。4.2 向量化与首次入库用 Chroma 建立本地索引切好的文本块要转成向量并入库。这里用HuggingFaceEmbeddings加载本地中文 embedding 模型用 Chroma 做向量库from langchain_huggingface import HuggingFaceEmbeddings from langchain_community.vectorstores import Chroma # 加载本地中文 embedding 模型放 CPU 即可 embedding_model HuggingFaceEmbeddings( model_name./bge-base-zh-v1.5, model_kwargs{device: cpu}, encode_kwargs{normalize_embeddings: True}, ) # 入库切好的文本块转向量写入本地目录 vectorstore Chroma.from_documents( documentschunks, embeddingembedding_model, persist_directory./db/qa_index, ) vectorstore.persist() # 构建检索器默认返回最相似的 4 个文本块 retriever vectorstore.as_retriever(search_kwargs{k: 4})normalize_embeddingsTrue这个参数很重要它把向量归一化成单位向量这样余弦相似度等价于向量内积不同模型之间才能用同一套相似度阈值。persist_directory指定索引落盘位置第一次入库要调persist()真正写盘之后重启程序直接Chroma(persist_directory./db/qa_index, embedding_functionembedding_model)就能加载不需要重复切分。将 embedding 模型放 CPU 是刻意为之。它只有几百 MBCPU 推理慢一点但能接受GPU 显存要留给 ChatGLM-6B。如果你的文档量很大比如几万个文本块一次from_documents可能把内存撑爆常见做法是分批次add_texts循环入库。Chroma 胜在轻量没有独立服务适合单机部署数据量到百万级向量时再考虑 FAISS 或 Milvus这个项目规模一般用不上。入库完成后as_retriever(search_kwargs{k: 4})指定每次检索返回 4 个文本块。这个数字不是拍脑袋4 个块大约 2000 字符配合 512 的max_new_tokens模型刚好读完材料再作答。块数太少容易漏信息块数太多 prompt 过长且注意力被稀释4 到 6 是经验区间。4.3 检索效果的两道闸门top_k 与相似度阈值怎么联动默认的k4是无差别召回不管检索结果跟问题相不相关都会硬塞给模型。这会导致一个问题知识库里没有答案时模型也会拿最不相关的几个块硬编。解决方法是给检索加一道分数闸门低于阈值的直接丢弃retriever vectorstore.as_retriever( search_typesimilarity_score_threshold, search_kwargs{ score_threshold: 0.35, # 低于此相似度的文本块直接丢弃 k: 4, }, )score_threshold要警惕一个认知误区向量库返回的“分数”在不同组件里口径不一样。Chroma 返回的是余弦距离数值越小越相似有些向量库返回的是相似度数值越大越相似。同样是 0.35在这两套口径里完全是两个含义。我一般先写一段调试代码打印出前 10 个检索结果的 score 分布观察命中块和噪音块的分数分界线再定阈值。这个分界线是玄学不是它由 embedding 模型的向量分布决定每个模型都不一样。top_k 和阈值是联动的不是独立参数。阈值卡得严top_k 就要调大否则有效块被阈值滤掉后剩下不到 2 个模型没材料可读阈值放得松top_k 就要调小否则噪音块混进来干扰判断。我常用的组合是从k6, score_threshold0.3起步用调试脚本看命中的块是否包含正确答案再逐步收紧阈值到 0.4 左右。检索确认没问题后把它接进RetrievalQA链这才是完整的自动问答。顺带把 prompt 模板里的信息层级理清楚from langchain.chains import RetrievalQA from langchain.prompts import PromptTemplate prompt_template 你是知识库问答助手只能依据下面的资料回答问题。 资料中没有提到的内容明确回答“未在知识库中找到相关答案”。 资料 {context} 问题{question} 回答 qa RetrievalQA.from_chain_type( llmllm, chain_typestuff, retrieverretriever, chain_type_kwargs{prompt: PromptTemplate( templateprompt_template, input_variables[context, question], )}, return_source_documentsTrue, ) result qa({query: 设备备件替换周期是多久}) print(result[result])这里的 prompt 模板里正好体现 LLM 的 token 三角色关系key 是系统角色定义告诉模型“你是谁、能做什么”query 是用户问题即“我在找什么”value 是检索出来的资料段即“我能提供什么”。三者关系摆清楚模型才会把资料当依据而不是当摆设。chain_typestuff表示把检索到的文本块直接拼进 prompt适合块数量少的场景如果块太多超过上下文窗口再考虑 map_reduce 或 refine 方式这里用不上。5. 本地知识库问答的 5 类典型翻车从显存 OOM 到答非所问的排查手记这一章写的是我实际调试这类项目时踩过、也帮人排查过的坑。顺序很重要遇到问题先查检索再查 prompt最后查模型和显存。很多人一上来就调 temperature那是舍本逐末。每条按“现象 → 原因 → 解决”展开你可以当排查手册用。5.1 模型加载成功答案跟文档毫无关系先看检索“黑匣子”现象ChatGLM-6B 正常回答但答出来的内容跟本地知识库八竿子打不着像是模型在凭常识硬撑。原因检索环节出了问题要么根本没召回相关文本块要么召回了但分数太低被忽略要么 embedding 模型对中文理解不到位。排查方法只有一个——把检索结果打印出来看不要猜。我是这样做的# 直接查看检索器返回了什么在调 prompt 之前先确认“输入给模型的是否正确” question 设备备件替换周期是多久 docs retriever.get_relevant_documents(question) for i, doc in enumerate(docs[:5]): print(f--- 第 {i 1} 个文本块分数相关度见 retriever.search ---) print(doc.page_content[:150])如果打印出来的文本块跟问题语义明显无关问题出在检索侧要依次检查embedding 模型是不是中文的、query 编码时有没有加检索前缀、top_k 是不是设得太小。如果打印出来的文本块相关但最终答案还是不对问题才在 prompt 或生成侧。这一步能避免至少一半的无意义调参。另外一个血泪经验调试检索时不要用qa({query: ...})这种封装调用直接用retriever.get_relevant_documents看原始命中把黑匣子打开。5.2 FP16 加载必显存 OOM量化、半精度与缓存的三层取舍现象执行model.half().cuda()时报 CUDA out of memory或者跑第一条问答时显存爆掉。原因ChatGLM-6B 的 FP16 权重加载就占约 12GB加上生成时的中间激活值16GB 显卡也常常只剩几个 G 余量如果同时把 embedding 模型也放 GPUOOM 就是必然。解决方式分三层第一层embedding 模型固定放 CPU第二层LLM 用半精度加载已经是底线8GB 显存必须量化第三层控制max_new_tokens生成长度越长中间激活值占用越大。量化加载的写法在第一阶段的注释里给过这里强调一个顺序问题quantize(8)要在.cuda()之前调用否则量化只在 CPU 上完成模型搬到 GPU 后依然按 FP32 计算。显存余量可以用nvidia-smi实时看建议留出至少 2GB 余量否则长问答随时可能 OOM。5.3 换了文档集之后效果断崖持久化目录里的“旧索引”作祟现象把./docs下的旧文档删掉放进一批新文档重新执行入库脚本但问答时模型回答的还是旧文档里的内容。原因十有八九是persist_directory指向了同一个目录而 Chroma 默认是“追加写入”而不是“清空重建”旧向量还在索引里。解决入库前先清空向量库目录或者换一个新的persist_directory如果同一个目录要管理多个知识集用collection_name隔离。我现在每次重建索引前都强制加一步rm -rf ./db/qa_index然后重新执行Chroma.from_documents。这虽然粗暴但能保证数据库状态跟文档目录一致不会出现“索引里有鬼”这种难以察觉的问题。另外要养成分批入库时查看collection.count()的习惯确认新块真的写进去了。5.4 多轮对话越答越空历史消息把上下文窗口撑爆现象单轮问答正常连续聊三轮以上模型开始答非所问甚至直接报错。原因多轮对话时往往会加ConversationBufferMemory把全部历史消息拼进 prompt。前三轮历史可能有几千字再加上检索文本块轻松突破模型上下文窗口超长输入被截断后检索到的关键材料反而被挤掉了。解决知识库问答不需要无限记忆把对话历史限制在最近两到三轮。常见做法是换用ConversationSummaryMemory它会把旧对话压缩成摘要而不是逐字保留或者干脆不把历史拼进检索链每次问答都基于新的检索结果。我的观点是知识库问答的核心是“当前问题 相关材料”历史记忆是锦上添花为了它牺牲上下文窗口不划算。5.5 模型开口就“编角色”prompt 模板没有锁死信息边界现象用户问“这个阀门多久换一次”模型回答“作为 AI 助手我可以帮你查询……”或者扯一堆通用知识就是不碰文档内容。原因prompt 模板里只写了“请回答问题”没有声明“只依据资料回答”模型默认进入了自由聊天模式。解决把信息边界写进系统提示里并且对低分检索结果的兜底话术做明确要求。我在 4.3 节给的模板就是这么设计的“资料中没有提到的内容明确回答‘未在知识库中找到相关答案’。”这句话一定要写清楚否则模型会用自己的常识强行补全。还要配合相似度阈值使用检索分数低于阈值时宁可直接拒绝回答也不要给模型编造的机会。很多项目把这一步省了结果上线后被业务方拿着错误答案质问“系统是不是瞎了”其实是 prompt 没锁住边界。6. 让答案“有据可循”给问答系统加引用溯源与回归验证问答能跑通只是第一步能上线被业务方信任是另一回事。业务方不会因为你用了大模型就相信答案他们需要看到依据。这章讲两个让 AI 真的“下地干活”的收尾动作引用溯源和回归验证。6.1 打开 return_source_documents把依据贴回回答里RetrievalQA构造时传入return_source_documentsTrue结果里就带着命中的源文档关键在于把它格式化输出给用户result qa({query: 设备备件替换周期是多久}) print(回答:, result[result]) print(依据:) for doc in result[source_documents]: source doc.metadata.get(source, 未知来源) print(f- {source}: {doc.page_content[:80]})这样业务方看到答案后能自己翻原文核对。我现在的项目凡是接知识库问答必做引用溯源不做的话后续一旦出错责任全在系统做了之后业务方可以核实信任感完全不一样。这个动作成本极低收益却最大强烈建议保留。6.2 上线前的快速回归用固定题目集评估检索与生成最后一个建议是建立自己的回归测试集选 20 到 30 条业务真实问题人工写好标准答案。每次调整 splitter、阈值、prompt 之后跑一遍全量题目对比检索命中和生成答案的变化。检索评估单独做看标准答案对应的文档是否出现在source_documents里这一步不依赖生成质量能单独暴露检索问题。生成评估靠人工打分重点关注“忠实于材料”而不是“看起来流畅”。这个机制帮我挡住了无数次“调好了一题却搞坏了全局”的回归问题。整套方案走到这里已经具备实用的基础能力。如果后续要接入业务系统常见做法是用 FastAPI 把问答包装成 HTTP 服务多轮控制交给 LangGraph模型加载也可以换成 Ollama 这类工具来管理。但不管外层怎么演进知识库问答的内核永远是“切好块、检得准、答得稳”这三件事。我现在的习惯是先做检索评估再做生成评估固定题目集回归这个习惯帮我少踩了很多坑。希望帮到你。本文还有配套的精品资源点击获取