最近这半年,几乎每隔几天就有人拿着公司内部文档来找我:员工手册、项目验收报告、新产品培训资料、客服话术库……问题都差不多——能不能让大模型直接基于这些资料回答问题,做成一个内部问答机器人?能,而且现在做起来的成本比大多数人想象中低很多。答案就是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 环境搭建最容易忽略的依赖版本问题环境搭建的坑往往不在逻辑,而在依赖版本。我给你们看一份我跑通全链路的最低依赖清单:python>=3.10 chromadb==0.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_tokens=500, overlap_tokens=80): # 先把标题行识别出来 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( name="employee_handbook", metadata={"hnsw:space": "cosine"} # 用余弦距离衡量语义相近程度 ) # 假设chunks是上一节切分得到的list ids = [f"chunk_{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_embeddings=True).tolist() collection.add( ids=ids, documents=documents, metadatas=metadatas, embeddings=embeddings )入库之后不要急着接大模型,先做一步纯检索验证。这一步很关键:如果检索阶段拿回来的片段就是错的,后面接什么模型都救不回来。def search(query, top_k=5): q_emb = model.encode([query], normalize_embeddings=True).tolist() results = collection.query( query_embeddings=q_emb, n_results=top_k, include=["documents", "metadatas", "distances"] ) return results # 验证一下“试用期离职”这个问题 results = search("试用期员工提出离职需要提前几天") for i, doc in enumerate(results["documents"][0]): title = results["metadatas"][0][i]["title"] print(f"Top{i+1} [来源:{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"[{i+1}](来源:{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_k=5) prompt = build_prompt(query, results) resp = client.chat.completions.create( model="你的模型名称", messages=[ {"role": "system", "content": "你是严谨的知识库问答助手。"}, {"role": "user", "content": prompt} ], temperature=0.0, # 关键:RAG场景追求确定性,不要发散 stream=False ) 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, padding=True, truncation=True, return_tensors="pt") scores = rerank_model(**inputs).logits.view(-1).tolist() # 返回按得分降序的索引 return sorted(range(len(scores)), key=lambda i: scores[i], reverse=True) # 假设从向量检索取回20个候选 results = search("如何申请加班调休", top_k=20) 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_vector=20, top_k_from_bm25=20, final_top_k=5): # 向量召回 vec_results = search(query, top_k=top_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)), key=lambda i: bm25_scores[i], reverse=True)[: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系统的价值上限,约等于你文档整洁程度的上限。
最近这半年,几乎每隔几天就有人拿着公司内部文档来找我:员工手册、项目验收报告、新产品培训资料、客服话术库……问题都差不多——能不能让大模型直接基于这些资料回答问题,做成一个内部问答机器人?能,而且现在做起来的成本比大多数人想象中低很多。答案就是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 环境搭建最容易忽略的依赖版本问题环境搭建的坑往往不在逻辑,而在依赖版本。我给你们看一份我跑通全链路的最低依赖清单:python>=3.10 chromadb==0.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_tokens=500, overlap_tokens=80): # 先把标题行识别出来 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( name="employee_handbook", metadata={"hnsw:space": "cosine"} # 用余弦距离衡量语义相近程度 ) # 假设chunks是上一节切分得到的list ids = [f"chunk_{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_embeddings=True).tolist() collection.add( ids=ids, documents=documents, metadatas=metadatas, embeddings=embeddings )入库之后不要急着接大模型,先做一步纯检索验证。这一步很关键:如果检索阶段拿回来的片段就是错的,后面接什么模型都救不回来。def search(query, top_k=5): q_emb = model.encode([query], normalize_embeddings=True).tolist() results = collection.query( query_embeddings=q_emb, n_results=top_k, include=["documents", "metadatas", "distances"] ) return results # 验证一下“试用期离职”这个问题 results = search("试用期员工提出离职需要提前几天") for i, doc in enumerate(results["documents"][0]): title = results["metadatas"][0][i]["title"] print(f"Top{i+1} [来源:{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"[{i+1}](来源:{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_k=5) prompt = build_prompt(query, results) resp = client.chat.completions.create( model="你的模型名称", messages=[ {"role": "system", "content": "你是严谨的知识库问答助手。"}, {"role": "user", "content": prompt} ], temperature=0.0, # 关键:RAG场景追求确定性,不要发散 stream=False ) 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, padding=True, truncation=True, return_tensors="pt") scores = rerank_model(**inputs).logits.view(-1).tolist() # 返回按得分降序的索引 return sorted(range(len(scores)), key=lambda i: scores[i], reverse=True) # 假设从向量检索取回20个候选 results = search("如何申请加班调休", top_k=20) 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_vector=20, top_k_from_bm25=20, final_top_k=5): # 向量召回 vec_results = search(query, top_k=top_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)), key=lambda i: bm25_scores[i], reverse=True)[: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系统的价值上限,约等于你文档整洁程度的上限。