
最近这半年几乎每隔几天就有人拿着公司内部文档来找我员工手册、项目验收报告、新产品培训资料、客服话术库……问题都差不多——能不能让大模型直接基于这些资料回答问题做成一个内部问答机器人能而且现在做起来的成本比大多数人想象中低很多。答案就是RAG全称Retrieval-Augmented Generation检索增强生成。我第一次搭完一套能跑的RAG智能问答助手只花了两天从文档清洗、切片入库到检索验证、接入对话模型再上线跑通。这篇文章就把整个过程中的关键原理、每一步的具体操作以及运行一周后踩到的三个检索质量坑完整写出来。适合有基本Python基础、正准备给团队做私有知识库问答的系统开发者参考。原理部分我用最直白的说法讲清楚实操部分直接给可运行的代码。1. 为什么我需要一个“会查资料”的问答助手1.1 对话模型再强也答不好没让它看过的资料先说个我实际遇到的场景。某公司人力部门想用大模型回答员工关于考勤、请假、报销的问题于是直接把员工手册扔给通用对话模型问“年假怎么算”。结果模型给出的答案和公司手册对不上反而更像网上的劳动法规科普。原因并不复杂。大模型的本质是一个基于海量文本训练出来的“下一个词预测器”它记住的是互联网上的通用规律和常识并不天然知道某个公司内部手册里写了什么。训练数据里没有你的员工手册、没有你的产品文档、没有你的项目验收报告它当然不可能凭空编出正确答案。更麻烦的是模型存在知识截止时间就算你试了很多提示词技巧它也不可能知道手册今年刚修订的条款。所以想让模型回答私有领域的问题唯一可靠的做法是把相关资料作为上下文“喂”给它让它边查资料边回答。开卷考试永远比闭卷考试靠谱这就是RAG的核心思路。把文档提前切好、存好等用户提问时先检索出相关片段再让模型基于这些片段作答。1.2 RAG与微调的边界谁解决什么问题为什么先尝试RAG很多第一次接触这个问题的人第一反应是“那我去微调一个模型”。这我特别理解因为“微调”听起来更像一劳永逸的方案把知识直接写进模型参数里之后调用就不用带资料了。但实际做过几次微调项目之后我现在的结论是除非你的需求是改变模型的行为风格、输出格式或者做特定任务否则大多数“想让模型知道某份资料”的需求都应该先试RAG。我这么说的理由有三条。第一微调的成本高且周期长每次资料更新都要重新训练一轮而RAG只是把文档重新切块入库几分钟就能完成第二微调容易引入“灾难性遗忘”模型在学新知识的同时可能把原有的能力搞乱第三微调并不能根治幻觉模型在回答时依然可能“凭印象”发挥因为它本质上还是在闭卷作答。我之前遇到一个团队想做制度库问答找外部资源微调了一个开源模型前前后后花了好几周。结果上线后一问到训练数据里没覆盖的新制度模型照样一本正经地胡说。后来换成了RAG只做了一件事——把制度文档切块入库回答时强制模型引用原文片段问题立刻解决。这个对比让我印象很深知识更新的场景里RAG不是“备选方案”而是“首选方案”。2. RAG四大环节拆解从切文档到生成回答2.1 索引阶段把PDF变成一颗“可检索的树”整个RAG管道可以拆成四个阶段索引、检索、增强、生成。我建议先别急着写代码把这四个环节吃透后面遇到问题才能定位到具体环节。第一个阶段是索引也有人叫“入库”。原始文档通常是一份几十页的PDF或Word模型一次只能读有限的上下文不能把整本手册全塞进去。所以第一步是把文档拆成一个个“片段”行业内叫chunk。为什么要切块如果一整本手册作为一个片段向量的语义信息会被平均掉检索时根本无法精确找到“年假怎么算”对应的那一页如果切得太碎比如按一句话切又容易丢失上下文导致检索回来的片段只有半句结论没有前因后果。我常用的做法是先把Word或PDF解析成纯文本去掉页眉、页脚、页码和目录这些噪声然后按标题层级和段落语义把内容切成大约500个token一个的chunk每个chunk会记录来源章节、页码等元信息。这一步很像给一本书做目录和小结只不过这个“小结”是后续检索的最小单位。切好块之后要做的是Embedding也就是把每段文字转成一组向量。可以粗暴地理解为给每段文字在“语义空间”里标一个坐标语义越接近的文字坐标距离越近。这样用户提问时我只需要把问题也转成向量就能在库里找最接近的片段。最后这些向量会存进向量数据库传统数据库按字段查询向量库专门做高维向量的相似度搜索返回“语义上最相似”的TopN条记录。2.2 检索阶段向量找相近关键词找精确两者不矛盾第二个阶段是检索。用户输入一个问题系统先把它做同样的Embedding再用余弦距离等方式和库里的所有片段向量做最近邻搜索返回得分最高的前几个片段。这里有一个大多数人第一次做RAG容易踩的认知误区以为向量检索能解决一切。其实向量检索擅长的是“语义相似”它对泛化表达、同义改写特别有效。比如用户问“离职要提前多久说”手册里写的是“解除劳动合同需要提前通知”两句话没有共同词汇但向量检索能发现它们语义相近。但它也有明显短板对专有名词、编号、缩写、精确型号很钝感。公司内部系统简称“DSAP”手册里全称“业务数据支撑平台”用户问“DSAP怎么登录”向量检索很可能召回不到。这时候需要关键词检索来兜底比如BM25这种经典算法它按字面重合度打分看到“DSAP”就直接命中原文。所以生产级RAG现在普遍做混合检索向量召回一批语义相近的关键词召回一批字面精确的再把两批结果合并去重。用生活化的说法向量检索像“靠意思找人”关键词检索像“看身份证认人”两者互补才是完整的检索能力。2.3 增强与生成把“证据片段”塞进提示词里第三个阶段叫增强很多人把它理解成简单的“拼字符串”其实没那么简单。核心是把检索到的若干片段整理成有结构的上下文塞进提示词模板里让模型阅读这些片段之后再作答。我常用的提示词模板长这样你是一名企业内部知识库问答助手。请只根据以下提供的资料片段来回答用户的问题。 回答要求 1. 如果资料中有明确依据请先给出结论再引用对应片段编号说明依据。 2. 如果资料中没有相关信息请直接回答“资料中未找到相关内容”不要自行推测。 3. 不要使用资料之外的常识进行补充除非你明确说明这是推测。 资料片段 [1]来源员工手册 第3章 考勤管理 内容... [2]来源员工手册 第4章 休假制度 内容... 用户问题试用期离职需要提前几天申请最后一个阶段是生成。模型拿到增强后的提示词在自己的语言能力帮助下从给定的片段里提取并组织答案。这里有个关键参数温度。我会在RAG场景里把temperature调到0或接近0因为我们要的是忠于资料的答案而不是发散创造。整个链路的名称也由此而来先Retrieval检索再Augmented增强提示词最后Generation生成答案。3. 环境准备与选型我需要准备哪些工具和模型3.1 选型对照三套可落地方案我实际跑过的体验了解了原理接下来是选型。很多教程一上来就推荐一大堆框架反而把初学者搞晕。我按落地成本和资源条件把实际跑过的组合分成三套方案Embedding模型生成模型向量库适合场景方案A全API调用供应商的Embedding接口通用对话大模型APIChroma本地持久化快速原型验证不想折腾GPU方案B混合部署本地开源Embedding模型CPU可跑通用对话大模型APIChroma或FAISS数据量不大、对检索延迟有要求方案C全本地本地开源Embedding模型本地量化开源对话模型Milvus或Qdrant数据敏感、必须离线部署我个人的建议是第一次跑通直接用方案A或方案B先验证效果不要一上来就追求全本地。全本地方案要处理GPU资源、模型量化、推理框架等问题学习曲线陡很多。我曾经帮某公司做过一个内部知识库Demo一开始就上全本地结果一周时间大部分花在部署模型上业务方连效果都没看到。后来改成方案B一天就把Demo跑起来了效果反馈好之后再慢慢规划本地化。Embedding模型方面我常用的做法是优先选开源中文表现稳定的模型例如bge系列或m3e系列。它们在中文语义匹配上比很多通用多语言模型更讨喜而且支持CPU推理几百MB内存就能跑。向量库的选择上入门用Chroma足够因为pip安装就能用、数据自动持久化在本地目录适合中小知识库数据量到百万级向量以上再考虑Milvus这类分布式方案。框架方面LangChain和LlamaIndex确实能省不少代码但我建议第一遍手写把链路里每一行都搞明白之后再决定要不要上框架。3.2 环境搭建最容易忽略的依赖版本问题环境搭建的坑往往不在逻辑而在依赖版本。我给你们看一份我跑通全链路的最低依赖清单python3.10 chromadb0.4.24 sentence-transformers pypdf python-docx rank-bm25 openai我特别想提醒几个坑。第一个是numpy版本冲突chromadb对numpy版本有要求如果你不小心装了过新的numpy启动向量库时大概率会报底层库加载失败解决办法是把numpy固定到兼容版本再装第二个是sentence-transformers和transformers、torch三者之间有版本联动建议在干净虚拟环境里一次装齐不要零散安装第三个是中文路径问题Chroma持久化目录如果包含中文或特殊字符某些版本下会报错项目目录最好用英文。另外如果你手头没有独立GPU生成模型部分要走API路线这样开发机上只需要跑Embedding一个普通的CPU实例就能撑住。如果你坚持本地部署7B量级的对话模型建议量化到4bit并且准备至少6GB以上显存否则推理速度会让人失去耐心。装完环境后先跑一个最小的“文档-检索-回答”闭环Demo成功之后再往里填业务数据这是效率最高的推进方式。4. 手把手搭一个“某公司员工手册问答”Demo4.1 第一步文档清洗与切分这一步决定了后面90%的质量实践部分我以一个虚拟的“某公司员工手册”为例假设它是大约120页的Word文档包含考勤、休假、薪酬、报销、保密等章节。我会带着你把完整链路跑一遍。文档清洗是这个步骤里最脏最累的活但绝对值得做。我的标准流程是先用python-docx把Word转成纯文本然后用正则把页眉、页脚、页码、目录项剥掉再把“第X章”和“X.X”这类标题单独标记出来。不要小看这一步很多项目后来检索效果差查到最后发现是清洗阶段把章节标题弄丢了导致语义单元全乱。清洗完成后就是切分。我写了一个比较稳的切分函数原则是“优先按标题层级其次按自然段合并”import re def split_into_chunks(text, max_tokens500, overlap_tokens80): # 先把标题行识别出来 lines text.split(\n) chunks [] current_chunk current_title for line in lines: stripped line.strip() if not stripped: continue # 以“第X章”、“X.X”、“X.X.X”为标题边界 if re.match(r^(第[一二三四五六七八九十百千]章|\d(\.\d)*), stripped): if current_chunk: chunks.append({ title: current_title, content: current_chunk.strip() }) current_title stripped current_chunk stripped continue current_chunk \n stripped # 简单估算token数超过阈值就切一段 if len(current_chunk) max_tokens * 1.3: chunks.append({ title: current_title, content: current_chunk.strip() }) # 保留尾部少量token作为重叠避免边界断语义 overlap current_chunk[-overlap_tokens:] current_chunk overlap if current_chunk: chunks.append({ title: current_title, content: current_chunk.strip() }) return chunks这段代码核心思想是当遇到一个新的章节标题时把上一个chunk保存下来当chunk长度超标时保留最后几十个token作为下一个chunk的开头这样“年假满10年”和“年假满20年”这类被切到不同块的内容至少会有一小段交叉不至于彻底断裂。切分结果里我给每个chunk带了title字段后面检索时可以直接展示“来源章节”这对用户确认答案非常有用。4.2 第二步Embedding入库与检索验证切完之后就是向量化入库。这里我强烈建议单独写一个初始化脚本而不是在问答主程序里每次重新嵌入因为Embedding几百个chunk虽然快但每次启动都跑一遍就太浪费了。from sentence_transformers import SentenceTransformer import chromadb # 加载Embedding模型CPU即可第一次运行会下载权重 model SentenceTransformer(bge-m3) # 创建Chroma客户端数据持久化到本地 ./rag_db 目录 client chromadb.PersistentClient(path./rag_db) collection client.get_or_create_collection( nameemployee_handbook, metadata{hnsw:space: cosine} # 用余弦距离衡量语义相近程度 ) # 假设chunks是上一节切分得到的list ids [fchunk_{i} for i in range(len(chunks))] documents [c[content] for c in chunks] metadatas [{title: c[title]} for c in chunks] # 成批转为向量并写入 embeddings model.encode(documents, normalize_embeddingsTrue).tolist() collection.add( idsids, documentsdocuments, metadatasmetadatas, embeddingsembeddings )入库之后不要急着接大模型先做一步纯检索验证。这一步很关键如果检索阶段拿回来的片段就是错的后面接什么模型都救不回来。def search(query, top_k5): q_emb model.encode([query], normalize_embeddingsTrue).tolist() results collection.query( query_embeddingsq_emb, n_resultstop_k, include[documents, metadatas, distances] ) return results # 验证一下“试用期离职”这个问题 results search(试用期员工提出离职需要提前几天) for i, doc in enumerate(results[documents][0]): title results[metadatas][0][i][title] print(fTop{i1} [来源:{title}] {doc[:100]}...)我实际跑这段时发现前两条确实命中了休假制度相关章节但第三条已经开始跑偏回到了考勤章节。这说明单靠向量检索的前3个结果里有噪声正常现象后面我重排序章节会专门处理。但至少这一步能让你直观感受到“检索质量”是什么也方便后续做参数调优。4.3 第三步把检索结果组装成Prompt并接入对话模型检索验证通过后接生成模型就只剩最后一步了组装提示词调用接口。我习惯把所有上下文片段编号然后要求模型在回答时标注编号这样用户能对照原始文档也方便排查问题。def build_prompt(query, search_results): context_lines [] for i, doc in enumerate(search_results[documents][0]): title search_results[metadatas][0][i][title] context_lines.append(f[{i1}]来源{title}\n{doc}) prompt f你是一名企业内部知识库问答助手请只根据以下资料片段回答用户问题。 如果资料中没有明确依据请回答“资料中未找到相关内容”不要自行推测。 回答时先给结论再引用片段编号说明依据。 资料片段 {chr(10).join(context_lines)} 用户问题{query} return prompt对话模型的接入现在大部分服务端都提供兼容格式的接口调用方式大同小异。我用一个通用的OpenAI SDK风格示例from openai import OpenAI client OpenAI( api_key你的密钥, base_url你的模型服务地址 ) def ask(query): results search(query, top_k5) prompt build_prompt(query, results) resp client.chat.completions.create( model你的模型名称, messages[ {role: system, content: 你是严谨的知识库问答助手。}, {role: user, content: prompt} ], temperature0.0, # 关键RAG场景追求确定性不要发散 streamFalse ) answer resp.choices[0].message.content return answer, results # 试一下 answer, results ask(试用期员工提出离职需要提前几天) print(answer)跑完之后你会看到模型根据检索到的片段给出了带编号依据的回答。这一步完成后一个最朴素的RAG系统就已经能用了。接下来真正拉开差距的是上线之后那些“看着能用但经常出错”的细节问题。5. 运行两天后遇到的三类检索质量问题和我的修复方案5.1 “答案被拦腰切断”——chunk边界设计不当系统运行到第二天有人来问“全年带薪年假天数怎么算”。模型回答里只写了“工作满1年不满10年享受5天年假”后半段“满10年不满20年享受10天”完全没出现。用户一看就反馈“答案不完整”。我第一反应是调大生成模型的上下文没用。后来把检索返回的片段打出来一看才发现命中的前3个chunk里只有第一个chunk包含了“满1年不满10年”的部分而“满10年不满20年”位于同一个小节的下一段被切分函数分到了另一个chunk且那个chunk的相似度排名掉到了第6名以外。这个案例让我意识到切分不能只看token数还要考虑语义单元。同一类规则最好放在同一个chunk里。我的修复方案是两件事第一把“年假规则”这一类小节整体设为一个chunk而不是机械地按长度切第二把默认top_k从3提升到召回20条之后再做重排避免因为排名波动漏掉关键片段。修复后重新测试完整答案回来了。5.2 “看起来很有道理但完全不对”——需要重排序而不是单纯信任TopK另一个经典问题是“幻觉型错误”。有员工问“如何申请加班调休”模型居然按照“加班工资”的规则回答了一大段。单独看那一段结构和口吻都很正经但完全不是用户想要的。查日志后我发现检索阶段返回的片段里确实包含“加班工资”的段落而且和“加班调休”的向量相似度很高。问题在于向量检索擅长找“语义相近”但不擅长判断“意图相关”。在向量空间里“加班调休”和“加班工资”的距离可能比“加班调休”和“请假流程”还近所以单纯按照向量相似度排序正确答案经常排在错误答案后面。解决办法是在向量召回之后加一层重排序。我引入了cross-encoder类型的中文重排序模型比如bge-reranker系列。它的做法不是先转成向量再比距离而是把“用户问题”和“每个候选片段”一起拼起来输入模型逐对打分精度更高代价是速度慢所以只对召回后的候选集做。具体代码如下from transformers import AutoModelForSequenceClassification, AutoTokenizer rerank_model_name bge-reranker-v2-m3 tokenizer AutoTokenizer.from_pretrained(rerank_model_name) rerank_model AutoModelForSequenceClassification.from_pretrained(rerank_model_name) def rerank(query, candidates): pairs [[query, doc] for doc in candidates] inputs tokenizer(pairs, paddingTrue, truncationTrue, return_tensorspt) scores rerank_model(**inputs).logits.view(-1).tolist() # 返回按得分降序的索引 return sorted(range(len(scores)), keylambda i: scores[i], reverseTrue) # 假设从向量检索取回20个候选 results search(如何申请加班调休, top_k20) candidates list(results[documents][0]) ranked_idx rerank(如何申请加班调休, candidates) # 只取重排后前2个并过滤低分片段加上重排之后正确答案的排名明显上升“加班工资”那段被压下去了。这里还要配合一个阈值过滤相关性得分低于某个值的片段直接丢弃宁可不答也不要拿无关资料硬答。阈值需要在你的具体语料上调没有一个万能值我在这套手册上取的是0.4左右。5.3 “明明有文档却没检索到”——混合检索补漏第三个问题最隐蔽。有人问“班车T3线停靠哪些站点”系统回答“资料中未找到相关内容”但我用关键词在库里人工搜明明有这段写的是“通勤车三号线”。这就是我之前提到的向量检索对编号、缩写的盲区。“T3线”这种写法和“三号线”在向量空间里的距离并不近尤其是在既有中文字符又有字母数字混合的情况下。光靠向量召回就是找不到。修复方案是加混合检索。我在系统里维护了一套BM25关键词索引对每个chunk做关键词打分然后把BM25召回的结果和向量召回的结果合并去重后一起进入重排阶段。这样即使是完全靠字面匹配才能找到的内容也能被捞回来。核心实现不复杂。先对文档做分词然后基于词频建立倒排索引查询的时候同时算BM25得分。当然生产环境如果已经有了Elasticsearch直接用它的BM25能力会更省事。我在这套Demo里的做法是引入rank-bm25库配合jieba分词做中文适配代码大致这样import jieba from rank_bm25 import BM25Okapi # 初始化时构建BM25索引 tokenized_chunks [list(jieba.cut(c[content])) for c in chunks] bm25 BM25Okapi(tokenized_chunks) def hybrid_search(query, top_k_from_vector20, top_k_from_bm2520, final_top_k5): # 向量召回 vec_results search(query, top_ktop_k_from_vector) # BM25召回 query_tokens list(jieba.cut(query)) bm25_scores bm25.get_scores(query_tokens) bm25_top_indices sorted(range(len(bm25_scores)), keylambda i: bm25_scores[i], reverseTrue)[:top_k_from_bm25] # 合并去重后统一重排 candidate_set set() for doc in vec_results[documents][0]: candidate_set.add(doc) for idx in bm25_top_indices: candidate_set.add(chunks[idx][content]) candidates list(candidate_set) # 进入重排阶段取前N个这一版改完之后T3线的问题解决了顺带把“DSAP系统无法登录”这类缩写问题也解决了一大半。到这一步这个RAG系统才算真正具备了一点“能用”的底气。6. 这套系统上线前必须做好的三件事评估、兜底、成本6.1 用一个小脚本评估“有没有答对”很多团队把RAG跑通就上线然后靠用户骂骂咧咧来发现问题这个成本太不可控了。我现在的习惯是上线前先准备至少二三十条问答测试集覆盖典型问题、边界问题、否定表述、缩写问题这几类然后做一个半自动评估。评估分两层。第一层看检索命中率给定问题检索返回的前几个片段里有没有包含答案所在的原始段落如果检索都错了生成一定错问题出在索引和检索环节第二层看生成正确率模型给出的答案内容是否正确、是否忠于引用片段。第一层可以脚本化第二层目前最稳的还是人工抽检或者用一个独立的评估模型来打分避免用回答模型自己给自己评分。我给团队写过一个简单的评估脚本逻辑就是遍历测试集调用系统拿到答案和引用片段然后让另一个模型按1到5分评估答案和标准答案的一致程度。分数低于3的自动进入bad case清单每周人工过一遍。这套机制价值不在于精确测量而在于让质量波动可见并且给后续优化提供方向。6.2 兜底策略不知道胜过一本正经胡说RAG的底线不是“每个问题都答得很漂亮”而是“答错的成本可控”。我做过一个很重要的设定当检索结果的最高相关性得分低于阈值时系统不进入生成环节直接返回“资料中暂时未找到相关内容请补充关键词或咨询相关部门”。这比让模型硬答更安全尤其是面向企业内部员工时错误答案造成的信任损失远大于“不知道”带来的不便。同时在提示词里反复强调“未找到就直说”并把“不要自行推测”写成强制性要求。回答的展示页面上也会把命中的原文片段一并展示出来用户自己能核对答案出处。这个设计看起来朴素但对提升可信度非常有效很多用户反馈“能看到来源答错了也敢相信”。6.3 成本账用300页文档运行一个月大概花多少钱最后算一笔账让大家心里有数。假设员工手册清洗后约有3000到5000个chunk取决于切分粒度Embedding这一步如果是本地CPU跑基本就是一次性几十分钟的电费如果走API的Embedding接口按token计费几千个chunk总共也就几十万token费用可以忽略。向量库存储更是小头几千条向量也就几MB。真正的成本在问答阶段。每一轮问答需要把用户问题做一次Embedding再把检索到的几百上千token片段和生成结果一起消耗模型token。假设一次问答约消耗1500个token的总输入输出如果每天200次问答、一个月6000次用通用API模型一个月的花费大约在几十到几百元量级具体取决于你选的模型档位。如果改成全本地部署主要是硬件折旧和电费一次投入买一块消费级显卡就能撑住不少并发。听起来是不是不算贵但我要提醒RAG真正的隐性成本不是算力而是人工。文档版本管理、切片边界维护、bad case跟进、用户反馈迭代这些都需要有人持续投入。如果文档本身写得含糊不清RAG的上限也就到那里了。上线前最好明确由谁负责定期更新知识库内容这比研究任何技术参数都重要。最后分享一个小经验不要等系统上线后才开始积累bad case。从第一天起我就习惯把每天的query、答案、命中的原文片段全部记录到日志里每周固定半小时翻一遍错误案例。这件事坚持下来你会发现大部分问题都出在文档本身写得不够清楚或者切块不合理上而不是模型不够强。先把手头文档整理干净比换更贵的模型管用得多。RAG系统的价值上限约等于你文档整洁程度的上限。