大模型知识库实战
从 RAG 原理、代码实战到开源平台与企业落地
一本讲透"为什么这么做"与"有什么好处"的知识库工程之书
前言
这是一本讲“如何让大模型说话有依据”的书。我们会把大模型为什么需要知识库、知识库如何一步步搭建起来、又如何在生产环境中稳定运营,完整地讲清楚。
这本书写给谁
过去两年,“知识库”成了大模型领域的高频词。几乎每家企业都想把自己的文档“喂”给大模型,让员工一句话就能查到准确的制度答案,让客服引用产品手册而不是凭记忆作答。但与此同时,它也是一个容易被误解的概念:许多人以为搭个知识库就是“把文档传进某个平台”,等真的动手才发现,检索不准、答案无出处、更新麻烦,越往深处走问题越多。这本书就是为了把这项技术系统地讲清楚而写的。具体来说,它写给三类读者。
第一类是想了解原理的业务与产品人员。你可能不写代码,但需要做判断:这个需求适不适合用知识库解决?供应商的方案靠不靠谱?项目的风险在哪里?对你而言,本书的原理篇与落地篇是重点。全书用大量类比和示意图解释技术概念,尽量避开公式推导与实现细节,读完之后,你应当能理解这项技术的能力边界与代价,能在评审会上问出正确的问题,而不是被宏大的叙事带着走。
第二类是要动手开发的工程师。你是本书的主要目标读者。原理篇讲清每个环节背后的机制,搭建篇与进阶篇给出完整的实现路径,书中的代码都基于统一的版本基线并经过实际验证。更重要的是,这本书不满足于告诉你“怎么写”,几乎每个重要的技术决策都会解释“为什么这么写”以及“这么写有什么好处”。我们认为,只有理解了决策背后的理由,你才能在真实环境中做出正确的取舍——真实环境永远和书里的示例不一样。
第三类是要负责选型与落地的团队负责人。你关心的不只是技术本身,还有成本、风险与投入产出:自建还是用现成产品,开源还是商业,权限怎么管,效果怎么评估,出了问题谁负责。书中的对比表格与落地案例是为你准备的:第2章的三路线对比与产品生态梳理可以直接作为选型依据,落地篇的权限设计、评估体系与运营实践可以直接借鉴到你的项目规划里。
为什么 2026 年正是时候
学习一项技术讲究时机。太早,生态不成熟,今天的最佳实践明天就被推翻,学的东西很快过时;太晚,早期红利吃完,只能跟着别人亦步亦趋。我们判断,2026 年正是掌握知识库技术的窗口期,理由有三个。
第一,企业 RAG 应用进入了落地深水区。前两年,知识库项目大多停留在演示与试点阶段,暴露的问题没有被认真对待;而现在,越来越多的知识库进入了真实业务流程,遇到的问题变得具体而尖锐:文档解析一团糟、检索不准、答案没有出处、权限管不住。行业围绕这些问题已经积累了足够多的教训与解法,到了可以系统总结的阶段,这本书做的正是这样的总结。现在读它,相当于站在整个行业过去两年踩过的坑之上。
第二,开源平台成熟了。Dify、RAGFlow、FastGPT、MaxKB 等开源产品把文档解析、切分、检索、重排序、引用溯源这些环节打磨成了开箱即用的功能,权限管理与运营监控也在逐步完善。搭建一个可用知识库的成本,从“一个团队开发几个月”降到了“几个人配置几天”。对学习者来说,这意味着不必从零造轮子,可以站在成熟平台上理解全局;对企业来说,这意味着试错成本足够低,可以快速验证一个场景值不值得投入。
第三,开发框架完成了代际更替。RAG 早期,LangChain、LlamaIndex 这类框架还在快速演变,接口频繁变动,半年前写的代码今天可能就跑不起来了,不少学习者因此半途而废。经过一轮大版本的重构与沉淀,这些框架的核心接口已经稳定,社区的最佳实践也从混乱走向收敛。现在学到的东西不会在明年作废,这是一次保值期很长的学习投入。
全书结构与两条阅读路线
全书共四篇十三章。原理篇为第 1 至第 3 章,回答“是什么”:从大模型的知识困境讲起,到 RAG 的技术全景,再到把语义变成几何的 Embedding 机制。搭建篇为第 4 至第 7 章,回答“怎么做”:依次覆盖文档解析与知识治理、用低代码平台快速搭建知识库、用开发框架构建知识库应用,以及结构化数据与多模态知识的处理。进阶篇为第 8 至第 10 章,回答“怎么做得更好”:高级检索与重排序、Agentic RAG,以及评估体系与效果调优。落地篇为第 11 至第 13 章,回答“怎么长期用好”:权限、安全与合规,企业落地案例,以及生产环境的运营与持续迭代。
考虑到读者需求不同,我们设计了两条阅读路线。完整路线是从第 1 章顺序读到第 13 章,适合想系统掌握这项技术的工程师,各章之间有明确的承接关系,顺序阅读体验最顺畅。快速路线是依次阅读第 1、2、5、12、13 章:先理解为什么需要知识库与技术全景,然后动手用低代码平台搭出一个能跑的系统,再看企业如何真实落地,最后了解如何运营。这条路线适合时间有限、希望先看到效果再深入原理的读者。
如果不确定选哪条路线,建议先走快速路线。第 5 章的动手体验会给你直观的感性认识,之后再回头读原理篇,许多原本抽象的概念会豁然开朗。学技术,感性认识常常先于理性理解。
本书约定
为避免阅读时产生困惑,这里先说明几条写作约定。第一是版本基线:全书内容以 2026 年 8 月 18 日的工具与生态状态为准。开源产品迭代很快,具体的界面与接口细节可能在成书后发生变化,但书中强调的原理与方法是跨版本稳定的;当你发现书中描述与实际软件有出入时,请以对应产品的官方文档为准。
第二是代码可运行:书中所有代码片段都在基线环境中实际验证过,关键示例会给出完整的操作步骤与预期结果。我们建议你亲手把代码敲一遍并运行起来,很多细节只有在自己踩过一次坑之后,才会真正变成你的东西。
第三是强调“为什么”与“好处”:你会在书中频繁看到两种专栏,“为什么这么做”解释关键技术决策的动机,“有什么好处”说明这些决策带来的具体收益;另有“避坑提示”指出常见错误、“小贴士”补充实践经验。阅读时请不要跳过这些专栏,它们往往是每一章里信息密度最高的部分。
知识库技术仍在快速演进,这本书只能记录我们在这个阶段的理解。但有一件事我们相信不会改变:技术的价值在于解决真实问题。希望这本书能帮助你把大模型真正变成说话有依据的生产力,而不是一个会一本正经胡说八道的玩具。
大模型的知识困境
要解决问题,先认清问题。本章从一个几乎每家企业都会遇到的案例出发,剖析大模型在知识上的四个结构性困境,逐一说明为什么已有的替代路线都不够好,最后给出全书的答案:外挂知识库与检索增强生成。
1.1 从一个真实场景说起
周一上午,一位新入职的销售向公司智能助手提问:“差旅报销标准是什么?”这个问题一点也不刁钻,答案本该明明白白写在员工手册里:他这个职级对应的交通与住宿标准、需要的审批流程、超标了怎么处理。智能助手很快给出了回答:一套完整的报销标准,数字具体、流程清晰,连系统入口的名称都说得有模有样。唯一的问题是,这些内容几乎没有一条与公司真实的制度相符。新员工照着这套标准提交了报销单,被财务直接驳回,他这才知道,那份制度年初就已经修订过了。
这个案例最棘手的地方,不在于模型“不知道”。如果它老老实实回答“我不知道”,员工会去问人事或者查内网,什么事情都不会发生。问题在于,它不知道,却表现得非常知道:语言流畅、语气笃定、结构完整,答案里没有任何一处流露出犹豫。对企业来说,这比系统崩溃更麻烦。崩溃是显性的,所有人都知道系统坏了;而一本正经地胡说八道是隐性的,用户会把错误答案当成事实,让错误顺着答案悄悄流向下游的每一个决策。
这样的场景不是孤例。内部知识问答、客户服务、合规咨询,凡是问题涉及某个组织特有的事实,大模型都会或多或少地表现出这种倾向。要理解原因,不能停留在抱怨“模型不够聪明”上,而要看清楚大模型的知识是怎么来的,以及这种获取知识的方式边界在哪里。这正是本章的出发点。
还要强调一点:这个案例里的问题与模型能力强弱无关。换一个参数更多、更强的模型,也许编造出来的答案会更流畅、更详尽,但改变不了“它从未见过这份制度文档”的事实。问题的根源不在模型的能力,而在知识的供给方式。理解这一点,是理解整个知识库技术栈的第一步。
1.2 大模型的四大困境
上面这个案例看似偶然,实则必然。它的背后,是大模型在知识层面普遍面临的四个结构性困境。这四个困境彼此独立又相互叠加,共同决定了“直接问模型”无法承担企业知识问答的职责。
困境一:幻觉,流畅但编造的答案
先看机制。大模型的训练目标,本质上是“预测下一个词”。训练时,模型阅读海量文本,学习一种统计规律:给定前文,接下来哪个词出现的可能性最大。这种训练方式带来的是语言的流畅和常识上的连贯,但它并不包含“核对我说的内容是否为真”的机制。模型生成回答时,并不是去某个事实库里查询,而是基于概率把“看起来最像正确答案”的内容逐词输出。当问题缺乏足够的依据时,模型不会停下来反思,而是继续用它学到的模式把答案补全,补出来的内容往往形式上合情合理,内容上却纯属编造。业界给这个现象起了个专门的名字:幻觉。
幻觉的危害恰恰在于它的欺骗性。幻觉内容通常流畅而具体,数字、流程、名称一应俱全,非专业人士很难识破。日常闲聊里这至多是个笑话,但在严肃场景下性质就变了:风险不在于“答错了”,而在于“答错了却看起来很对”,用户会基于这份信任去做后续的决定。
还要认识到,幻觉不是某个版本模型的缺陷,而是当前技术架构下无法根除的特性。只要模型的生成机制还是基于概率的预测,它就可能在知识缺失时“脑补”,各种对齐与约束手段只能降低幻觉的频率,无法将它清零。这意味着,指望模型自己“意识到”自己在胡说是不现实的,必须从系统层面引入外部约束——这正是知识库要站的位置。
幻觉在医疗、法律、金融、合规等严肃场景中的风险尤为致命:医疗咨询系统编造用药剂量、法律助手引用不存在的条款、财务分析引用虚构的数据,带来的都不只是体验问题,而是实际损失与合规风险。评估任何知识库方案时,不要只看它答得有多好,先问两个问题:答案有没有可追溯的来源?系统在不知道的时候,能不能诚实地说“不知道”?
困境二:知识截止,训练数据的时间边界
大模型的知识,定格在它训练结束的那一刻。模型的参数就像训练数据的一张“照片”:训练完成后,无论世界发生什么,新政策出台、新产品发布、组织如何调整,参数里的内容都不会改变分毫。如果训练数据截至某个时间点,那么模型对这个时间点之后发生的事情一无所知。业界把这个边界称为知识截止。
这个问题比看上去更严重,因为企业知识恰恰是变化最快的那一类知识:报销制度每年修订,产品手册随版本更新,组织架构与负责人随时调整。即便这些内容曾经进入过训练数据,模型给出的也只是旧版本的答案。而对用户来说,很难分辨模型给的答案是不是最新的,于是每个答案都要再人工核实一遍,智能问答承诺的效率提升也就无从谈起了。
能不能靠重新训练或继续训练来解决?理论上可以,代价却极高:一次完整训练以周为单位,消耗的算力折合成费用相当可观,没有企业会为了一份文档的更新去做这件事。这说明,指望用模型参数本身承载不断变化的知识,这条路注定追不上现实。问题不在工程实现,而在路线本身。
困境三:私有知识不可达
前两个困境说的至少是“曾经存在过的知识”。第三个困境更根本:企业真正值钱的大部分知识,从来没有进入过模型的视野。内部制度、项目文档、技术积累、客户案例、会议纪要,这些文档躺在内网、文档系统和聊天记录里,不属于公开的训练语料。模型从未见过它们,自然也无从回答。问一个通用大模型关于你公司内部的事情,就像问一个从没来过你公司的优秀毕业生:“你们的报销流程是什么?”他再聪明,也只能靠猜。
这个困境揭示了一个重要事实:对企业知识问答而言,模型缺的不是“智能”,而是“素材”。它的理解能力、归纳能力、表达能力都是够用的,缺的是被理解和被归纳的具体内容。想清楚这一点,解决方向就清晰了:与其设法把知识塞进模型,不如把知识放在模型之外,需要的时候再递给它。
困境四:长上下文的成本与局限
有读者会想:既然模型缺素材,那把素材给它不就行了?如今许多大模型支持很长的上下文窗口,把企业的全部文档都塞进每次提问里,难道不行吗?这个想法对了一半——把材料递给模型确实是正确的方向——但“全塞进去”的做法会遇到两个现实障碍。
第一个障碍是成本。大模型按 token 计费,输入和输出都要算钱。如果每次提问都附带几十万字的企业文档,单次调用的费用会急剧上升,响应延迟也随之变长。对一个全员使用的高频问答系统来说,这样的账单是不可接受的:很可能系统的价值还没被证明,预算就先见底了。
第二个障碍更隐蔽:就算塞得进去,模型也未必找得到。研究发现,当上下文变得很长时,模型在其中定位关键信息的能力会明显衰减。这就像把一座图书馆的书全部摊开在你面前,让你找出其中某一句话,材料越多,越容易漏。这个现象常被形象地称为“大海捞针”问题:上下文长度超过一定程度后,从中提取特定信息的准确率会明显下降,位于中间部分的信息尤其容易被忽略。
因此,长上下文是一项有价值的能力,却不能替代检索。它适合“少量文档、深度理解”的场景,比如读完一份合同回答其中的细节问题;而不适合“从上万份文档里找到对的那一份”的场景。前者是容器,后者是搜索能力,两者互补,而非替代。
1.3 为什么其他路线不够
看清了四个困境,自然会问:难道没有其他技术手段可以解决吗?业界确实尝试过好几条路线,每一条都有它的价值,但对照企业知识库的需求,没有一条能单独作为主方案。我们逐一看。
把模型继续做大:通用智能与知识更新的节奏不匹配
最直观的想法是把模型做得更大、用更多数据训练,让它装下更多知识。这条路线过去几年确实推动了模型能力的持续进步,但面对“知识更新”这个具体需求,它有两个绕不开的问题。一是成本:模型越大,训练与推理的开销越高,而更新知识意味着重新训练,对每天都在变化的企业文档来说完全不现实;二是频率:企业文档按天变化,模型训练按月甚至按季度进行,两者的节奏根本对不上。把模型做大,提升的是通用智能,而不是承载具体知识的合适方式。
微调:教的是风格,不是事实
微调是另一个常被想到的路线:拿一个通用大模型,用企业数据继续训练,把它“教会”企业的知识。这个做法听起来顺理成章,实践中却有三个问题。
第一,微调擅长教模型“怎么说”,而不是“说什么”。风格、格式、语气、特定任务的输出规范,这些通过微调效果好;而事实性知识靠训练硬塞进去,就像靠死记硬背记答案,记得不牢、容易混淆,用户换一种问法就可能对不上。第二,更新代价高:知识一变就要重新整理数据、重新训练,周期长、费用高,和知识库“随时更新”的需求天然冲突。第三,存在灾难性遗忘的风险:灌入新知识可能干扰模型原有的能力,学了新的、忘了旧的,得不偿失。
纯长上下文:昂贵且不一定准
第四个困境的分析已经指出了这条路线的极限:成本高,长文本中的信息定位能力衰减明显。长上下文解决的是“看得见”,而不是“找得到”。而且每次调用都要把全部文档重新传一遍,知识无法沉淀复用,等于让每次提问都从零开始翻一遍全公司的资料。
为什么不靠更大的模型、微调或纯长上下文?因为这三条路线的本质,都是把知识放进模型参数里,或者放进每一次请求里。而企业知识有三个特点:量大、变化快、需要追责。放进参数,就放弃了更新速度,任何变动都要重新训练;放进请求,就放弃了成本控制,每次调用都是一笔大账单。唯一能同时满足“随时更新、精准找到、可追溯来源”的路线,是把知识放在模型之外,由一个独立的系统管理,模型需要时再来取。这就是知识库思路的出发点。
1.4 答案:外挂知识库与检索增强生成
既然知识装不进模型,那就把它放到模型外面。这个思路其实并不新鲜,现实中人人都熟悉的原型就是图书馆。
想象一下图书馆是怎么运作的。它不要求每个读者把所有书的内容背下来,它只做两件事:把书有序地收藏起来,再提供一套目录与检索系统,让读者能快速找到需要的那一册。更好的图书馆还配备参考咨询馆员:读者提出问题,馆员不凭记忆作答,而是先去书库里找出最相关的资料,递给读者,并说明出自哪本书的哪一页。阅读理解与表达是读者自己的事,馆员负责的是让每个结论都有据可查。
知识库方案,就是给大模型配一座这样的“图书馆加参考咨询馆员”。企业文档经过清洗、切分,存入一个可检索的知识库,这是建图书馆;用户提问时,系统先从知识库里检索出最相关的段落,把它们和问题拼接成一段提示词递给大模型,模型基于这些材料生成回答,这是参考咨询馆员的工作。模型的角色从“背诵知识”变成了“阅读理解”:它不需要记住报销标准,只需要读懂检索到的那条制度,然后据此作答。
这个方案有一个正式名称:检索增强生成,英文 Retrieval-Augmented Generation,缩写 RAG。名字的含义非常直白:用检索来增强生成。检索在前,生成在后,生成受检索结果的约束。从本章开始,这个术语将贯穿全书。
RAG 路线的收益可以概括为四点。其一,知识随时更新:文档变化只需更新知识库,不必重新训练模型,当天甚至当小时就能生效。其二,答案可追溯:回答可以标注出处,用户可以核对原文,信任由此建立。其三,成本可控:每次只把与问题最相关的少数段落放进提示词,token 消耗远低于把全部文档塞进去。其四,权限可管:知识库可以按人控制“谁能查到什么”,这是混进模型参数里的知识永远做不到的。
从分工的角度看,知识库系统承担“记忆”,大模型承担“思考与表达”。记忆要求准确与新鲜,适合用传统的信息系统来管理——存储、索引、检索、权限,都是成熟技术;思考与表达要求理解与生成,正是大模型的强项。让每一方做自己最擅长的事,整个系统才既可靠又经济。这种分工思想会贯穿全书,也是理解后续所有设计决策的钥匙。
回头看四大困境,RAG 恰好一一回应:幻觉靠“先查后答”与引用溯源来抑制;知识截止靠知识库的实时更新来化解;私有知识以文档形式进入问答流程;长上下文的成本与衰减则被“先检索、再阅读”绕开。这正是 RAG 能成为企业知识库事实标准的原因。
1.5 本章小结与全书路线图
本章从“模型编造报销标准”的案例出发,说明了大模型在知识上的四大困境:幻觉、知识截止、私有知识不可达、长上下文的成本与局限;又逐一分析了为什么把模型做大、微调、纯长上下文这三条路线都无法独立解决这些困境。结论是清晰的:知识应当由模型之外的独立系统来管理,模型负责理解与表达,连接二者的桥梁是检索。这就是 RAG 的基本思想。
全书后续章节将沿着这条思路展开。第 2 章鸟瞰 RAG 的完整技术全景:定义、工作流程、三代演进与产品生态;第 3 章深入核心技术之一 Embedding,看语义相似如何变成可计算的几何距离;搭建篇、进阶篇与落地篇则分别解决“怎么建起来”“怎么做得更好”“怎么长期用好”的问题。如果你只想快速建立全局观,读完本章可以直接进入第 2 章;如果你已经迫不及待想理解底层机制,第 3 章会给你答案。
RAG 技术全景
上一章给出了结论:大模型需要外挂知识库,连接的桥梁是检索。本章鸟瞰这项技术的全貌:它从哪里来、如何运转、六年间经历了怎样的演进、三条知识注入路线如何取舍,以及市面上有哪些工具可用。读完这一章,你手里应该有一张完整的地图。
2.1 RAG 的定义与由来
RAG 的思想并非凭空出现。2020 年,Meta(当时的 Facebook)的研究团队发表论文,正式提出了检索增强生成的构想。这项研究的出发点正是上一章讨论的问题:参数化语言模型把知识存储在权重里,在开放领域的常识任务上表现不错,但面对长尾的、随时间变化的、需要精确引用的知识时就力不从心。研究者提出,把一个信息检索模块与生成模型结合起来:检索模块负责从外部知识源中找到相关文档,生成模块负责基于问题与文档生成答案。这项工作先在开放域问答任务上验证了“先查后答”架构的价值,也为后来企业知识库的浪潮奠定了理论基础。
用一句话概括 RAG 的核心定义:在生成回答之前,先从外部知识源中检索与问题相关的信息,并将这些信息作为大模型生成答案的上下文。关键词是“外部”——知识不存在模型参数里,而存在一个可以独立维护、独立更新的知识库里。这种“知识与模型分离”的设计,是 RAG 一切好处的根源:因为知识独立于模型,所以可以自由更新、精细管理、追溯来源;因为模型只负责阅读理解与表达,所以它的通用能力可以被充分复用,换模型也不必重建知识。
回头看,这篇论文最重要的贡献是提出了两种记忆的区分:参数化记忆与非参数化记忆。存在模型权重里的知识是参数化记忆,训练时固定,难以更新;存在外部索引里的知识是非参数化记忆,可以随时增删改查,与模型解耦。RAG 的本质,就是把两种记忆连接起来,让模型用参数化记忆做理解与表达,用非参数化记忆提供事实与证据。今天的知识库系统无论工程形态多么复杂,都是这个基本结构的延伸。
2.2 RAG 如何工作:先查后答
整个流程并不复杂,且在一次请求内就能完成。我们先看下面的流程图,再把它拆成五个环节逐一讲解。
flowchart LR A[用户提问] --> B[从知识库检索相关内容] B --> C[问题与内容拼接为提示词] C --> D[大模型生成回答] D --> E[带引用的回答返回用户]
环节一与环节二:从提问到检索
一切始于用户的提问。问题往往是口语化而简短的,系统先对它做必要的理解与处理,然后去知识库里寻找相关内容。最常见的检索方式是向量语义检索:先把问题转换成一个向量,再在向量空间里找出与它距离最近的文档片段。它的长处在于理解语义,哪怕措辞不同,只要意思接近就能找到。除此之外,还有基于关键词的传统检索,以及把两者结合起来的混合检索。检索的结果通常是一小组按相关度排序的文本片段,而不是整篇文档。
这一步决定了整个流程的质量上限。业界有句话:检索决定上限,生成决定下限。查不到,后面模型再强也答不对;查得准,即便用普通模型也能给出像样的回答。这也是为什么后面的章节会花大量篇幅讲检索优化。
举个直观的例子。员工问“陪产假能休几天”,知识库里可能没有任何一句话写着“陪产假”三个字,相关内容也许表述为“符合政策的男职工,配偶生育期间给予护理假”。纯关键词检索会直接漏掉,语义检索却能把这两种说法关联起来。这正是向量语义检索成为知识库标配的原因:它按意思查,而不是按字面查。当然,语义检索也不是万能的,涉及编号、型号、专有名词的查询,关键词检索往往更准,所以混合检索越来越流行。
环节三:拼接提示词
拿到相关片段后,系统把问题与这些片段按模板拼接成一段完整的提示词。模板通常要明确三件事:模型的角色、参考材料、回答规则。一条典型的规则是“只根据以下材料回答,如果材料中没有答案,请说不知道”。这一步看似只是简单的字符串拼接,实际上是在给模型立规矩:哪些是可以引用的证据、哪些是必须遵守的要求、用什么语气输出、以什么格式呈现,都写在这段提示词里。提示词设计因此是 RAG 工程中的一项基本功。
看一个简化的拼接示例。模板分为三段:第一段告诉模型“你是企业知识助手,必须依据下面的参考资料回答”;第二段放入检索到的片段,每个片段附上编号;第三段放上用户的原始问题,并要求“回答中注明引用了哪几号材料”。模型收到这样的提示词,任务就从开放式问答变成了有依据的阅读理解,发挥空间被约束在材料之内,幻觉的空间自然被压缩。
环节四与环节五:生成与带引用的回答
大模型读取提示词,基于参考材料生成回答。因为材料就在眼前,模型不需要从参数里“回忆”,幻觉的空间被大幅压缩。设计良好的系统还会进一步要求模型在答案中标注每个关键结论的来源,前端再把来源渲染成可点击的引用,用户一点就能跳到原文。引用溯源是企业用户信任知识库回答的重要原因:答案对不对,打开原文看一眼就知道。
引用具体是怎么实现的?常见做法是:拼接提示词时给每个检索片段编号,要求模型引用结论时注明材料编号;系统再把编号映射回具体的文档与段落,生成跳转链接。这套机制让“可追溯”从一句口号变成了一个可验证的功能:答案中的任何结论都能定位到某份文档的某一段,审核者不需要信任模型,只需要核对原文。
值得强调的是,整个流程在一次请求中完成,通常在几秒之内。对用户而言,他只是问了一个问题;对系统而言,幕后完成了一连串检索、拼接与生成的动作。这种“透明”的体验也是 RAG 系统设计的目标之一:技术越复杂,交互越应该简单。
还有一部分工作不直接面向用户,却决定了检索的效果:知识入库。系统上线之前,企业文档要经过一连串加工——解析把 Word、PDF 乃至扫描件中的文字提取出来,清洗去掉页眉页脚与乱码等无关内容,切分把长文档拆成大小合适的片段,向量化再把每个片段转成可供检索的向量。这一系列离线工作的质量,直接决定了在线检索准不准:切分切错了,后面再怎么优化也查不准。第 3 章的 Embedding 与第 4 章的文档治理会详细展开这部分内容。
2.3 RAG 的三代演进
上面的流程是 RAG 最基础的形态。真实生产中,这种简单形态远远不够:检索可能不准,问题可能含糊,一次检索可能不够用。过去六年,RAG 技术经历了三代演进,每一代都在解决上一代留下的问题。
flowchart LR A[Naive RAG
直接检索并拼接] --> B[Advanced RAG
查询改写与重排序优化] B --> C[Agentic RAG
智能体自主决定检索]
第一代:Naive RAG
第一代的做法非常直接:把用户问题向量化,从知识库里取出相关度最高的 top-k 个片段,拼接进提示词,交给模型。这套“检索、拼接、生成”的三步走,简单、易实现,是早期知识库建设的标准做法,业界称之为 Naive RAG。
但它的问题很快暴露出来。一是召回不准:语义检索并不总是可靠,检索回来的片段可能字面相关而语义无关,也可能答案分散在多个片段中,单个片段都不完整。二是上下文噪音:top-k 个片段里哪怕有几个是对的,混进来的无关片段也会干扰模型判断,有时越多越糟。三是无法应对复杂问题:如果一个问题需要综合多份文档的信息,或者需要先拆解再回答,一次检索根本不够。
举个直观的例子。员工问“一线城市住宿报销上限是多少”,如果知识库里恰好有个片段标题就叫“差旅住宿标准”,Naive RAG 大概率能检索到并正确回答。但如果问题换成“我下周去上海出差,酒店最多能报多少”,问题与片段的字面重合度很低,检索排名可能靠后,反而召回了诸如“上海分公司简介”这样无关的片段。Naive RAG 的检索质量高度依赖问题与文档的表面相似度,这正是它的软肋。
Naive RAG 的好处是简单、门槛低。不需要复杂的工程投入,就能快速搭出一个可用的原型,验证知识库这个方向是否可行。今天几乎所有低代码平台的默认选项仍是这套流程,因为对许多简单问答场景来说,它已经够用。
第二代:Advanced RAG
为了解决检索不准,第二代 RAG 在检索的前后增加了一系列优化环节。检索前,查询改写把用户口语化的问题扩展、改写甚至拆解成更清晰的子问题;还有一种叫假设文档嵌入的技巧,让模型先“猜”答案可能长什么样,再拿这个猜测去检索,往往比拿原问题检索效果更好。检索后,重排序用一个专门的精排模型对初筛出的片段重新打分排序,把真正相关的片段顶到前面,把噪音剔除出去。
这一代方案还包括许多配套优化:切分策略决定文档怎么拆,直接影响检索粒度;混合检索结合语义与关键词两种方式的长处;多跳检索支持需要分步推理的问题。总体而言,Advanced RAG 不再把检索当成一个孤立动作,而是当成一条可以精细调优的流水线,流水线的每个环节都有独立的优化空间。
继续上面的例子。在 Advanced RAG 里,查询改写模块会先把“我下周去上海出差,酒店最多能报多少”改写成“差旅住宿报销标准 一线城市”再去检索,命中率大幅提升;初筛返回十个片段后,重排序模型逐一精细打分,把真正的“差旅住宿标准”顶到第一位,把“上海分公司简介”这样的噪音排出去。两道工序之后,模型拿到的材料干净而准确,回答质量自然上去了。
Advanced RAG 的收益是检索精度显著提升,直接抬高了回答的质量上限。对企业场景而言,这一代技术大幅减少了“查不到、答非所问”的情况,是当前生产环境知识库的主流选择。本书进阶篇介绍的大部分优化手段,都属于这一代。
第三代:Agentic RAG
第三代的变化更根本:检索不再是一个固定流程,而成为 Agent(智能体)自主做出的决策。Agent 会先理解用户意图,然后自主决定一连串问题:这个问题需不需要检索?去哪个知识库检索?要不要检索多次?问题是否需要拆解?检索结果要不要交叉验证?要不要向用户追问澄清?第一次检索结果不理想,Agent 会换个角度再查;问题横跨多个知识库,Agent 会分别检索再综合;问题与知识库无关,Agent 直接回答,不浪费一次检索。
设想一个更复杂的问题:“对比一下去上海和去成都出差的住宿标准,哪个更高?”这个问题一次检索无法回答:Agent 先把它拆成两个子问题,分别检索上海标准与成都标准,再核对两个片段是否出自同一版制度,最后综合出对比结论。如果发现其中一条检索结果来自已废止的旧制度,它还会重新检索。这种“边思考边检索”的工作方式,就是 Agentic RAG 的日常。
在这种模式下,检索从“固定步骤”变成了“智能体手中的工具”,知识库则成为 Agent 的信息源之一。Agent 还可以把知识库检索与其他工具结合起来,比如查询业务数据库、调用内部接口。Agentic RAG 代表的正是知识库技术与智能体技术融合的方向。
Agentic RAG 的收益在于灵活与鲁棒。它能处理复杂、含糊、多步骤的问题,能通过多轮检索与自我反思纠正错误,系统的上限被显著抬高。对应的代价是流程变长、变复杂,延迟、成本与工程投入都高于前两代,需要量力而行。
| 代际 | 核心思想 | 解决的问题 | 代价 |
|---|---|---|---|
| Naive RAG | 直接检索 top-k 片段并拼接进提示词 | 从无到有,让模型先查后答 | 低;但召回不准、上下文有噪音 |
| Advanced RAG | 在检索前后增加查询改写、重排序等环节,形成流水线 | 检索不准、上下文噪音、复杂切分 | 中;组件更多,调优成本上升 |
| Agentic RAG | 由智能体自主决定是否检索、检索什么、检索几次 | 复杂问题、多跳推理、自我纠错 | 高;流程长,延迟与成本更高 |
需要强调的是,三代技术是叠加而非替代的关系:新一代并没有淘汰旧一代,而是建立在旧一代之上。Agentic RAG 依然需要高质量的基础检索,Agent 的每一次决策,最终可能都要落到 Advanced RAG 的检索与重排序流程上。选型时应从场景的复杂度出发,而不是盲目追求最新的代际。
2.4 三条知识注入路线:RAG、微调、长上下文
第 1 章已经说明,把模型做大、微调、长上下文各有其局限。这里把 RAG、微调、长上下文三条路线放在一起做一次系统对比。先要说明,三者并非完全互斥,成熟的系统常常组合使用,但主次有别,主路线的选择决定了系统的基本形态。
| 对比维度 | RAG | 微调 | 长上下文 |
|---|---|---|---|
| 知识更新 | 更新知识库即可生效,无需重训模型 | 需要重新训练,周期长、费用高 | 无需更新,但每次都要重新传入文档 |
| 成本 | 检索与存储成本低,单次调用成本可控 | 训练成本高,持续维护成本高 | 按 token 计费,高频调用账单可观 |
| 可追溯性 | 可标注出处,便于核对与审计 | 知识混入参数,无法追溯来源 | 依赖提示词设计,弱于 RAG |
| 适用场景 | 私有知识问答,知识频繁变化 | 风格、格式、行业表达规范 | 少量文档的深度理解 |
对表中的维度稍作解释。知识更新维度考察的是“知识变了,要花多大力气跟上”;成本维度既包括一次性的建设投入,也包括持续运营的开销;可追溯性决定答案能否被验证,在合规敏感的行业里这是一票否决项;适用场景则说明每条路线最舒服的区间。读者可以把这张表当成选型清单,对照自己业务的特征逐条核对。
为什么企业知识库默认选 RAG?因为企业知识的典型特征是量大、变化快、需要追责。微调解决的是“怎么说”,对“说什么”力不从心,且每次更新都要重训;长上下文解决的是“看得见”,而不是“找得到”,成本随调用频率线性上涨。RAG 把知识放在模型之外,更新即时生效、答案可追溯、成本可核算,恰好命中企业的三个核心诉求。除非有明确的风格定制需求,知识库项目都应从 RAG 起步。
这并不意味着另外两条路线没有用武之地。常见的组合方式是:以 RAG 为主体承载知识,用微调让模型适应企业特有的表达习惯与输出格式,在需要深度阅读单个文档的场景里启用长上下文。三者各司其职,系统才完整。但在绝大多数情况下,RAG 都是第一步,也是投入最大的那一步。
2.5 知识库产品生态的三层
理解了原理,回到现实:搭一个知识库,手头有哪些工具可用?目前的产品生态大致分为三层:开发框架、低代码平台、云厂商服务。三层的定位不同,适合不同能力与不同诉求的团队。
开发框架:LangChain 与 LlamaIndex
最底层是开发框架,代表是 LangChain 与 LlamaIndex。它们把文档加载、切分、向量化、检索、提示词拼接这些通用操作封装成可复用的组件,工程师用代码组装出自己的 RAG 应用。这条路的优势是灵活:每个环节都可以定制,能与现有业务系统深度集成;劣势是门槛高,权限管理、前端界面、运营监控这些能力都要自己补齐。它适合有开发能力、有复杂定制需求的团队。
两个框架的定位略有差异。LangChain 更偏重通用应用编排,组件生态丰富,适合构建包括 RAG 在内的各类大模型应用;LlamaIndex 更偏重数据连接层,在文档接入、索引构建与检索环节的组件更精细,做以知识库为核心的项目时更顺手。实际项目中,两个框架混用的团队也不少,这完全可行。
低代码平台:Dify、RAGFlow、FastGPT、MaxKB
中间层是低代码平台,国内用户熟悉的有 Dify、RAGFlow、FastGPT、MaxKB。它们把知识库搭建的全流程做成了可视化界面:上传文档、配置切分策略、选择模型、发布应用,全部在网页上完成;它们大多是开源的,支持私有化部署。这条路的优势是快,从想法到可用系统的路径很短,知识管理、权限、引用展示都是现成的;劣势是深度定制的灵活性受平台能力约束。它适合大多数企业团队,尤其是希望快速验证、快速迭代的团队。
几个平台之间也有细微差别:有的在文档解析上更强,有的在工作流编排上更灵活,有的在企业内部问答的开箱体验上更好。选型时建议带着自己真实的文档与真实的问题去实测,重点考察复杂格式文档的解析效果,以及本行业术语下的检索准确率。第 5 章会选其中一个平台,完整走一遍搭建流程。
云厂商服务
最上层是云厂商提供的一站式知识库服务。它的特点是深度绑定云生态:模型、向量数据库、对象存储、权限体系都是现成的,按量付费,免运维。适合已经深度使用某朵云、或者不希望自己维护基础设施的团队。相应的取舍是:数据与技术选型在一定程度上与云绑定,私有化部署的支持程度也因厂商而异。
选择云服务时,除了对比功能,还要特别留意两点:一是数据合规,弄清楚数据存放在哪里、谁能访问、是否满足行业监管要求;二是迁移成本,未来若要更换厂商或转回私有化部署,数据与流程的导出是否顺畅。绑定越浅,架构越有回旋余地。
给一条简单的选型原则:想先验证场景是否可行,从低代码平台起步,几天就能搭出可用的系统;验证通过、需要与业务系统深度集成时,再引入开发框架自研,或在平台之上做扩展;团队没有运维能力、数据允许上云,云厂商服务最省心。三层生态并不互斥,许多企业最终的形态是平台与代码的组合。
2.6 本章小结
本章先回顾了 RAG 的由来:2020 年由 Meta 研究团队在论文中正式提出,核心思想是先查后答,知识存放在模型之外。接着拆解了基本流程的五个环节:提问、检索、拼接、生成、带引用回答;其中检索决定质量上限,引用决定用户信任。然后梳理了三代演进:Naive RAG 解决“有没有”,Advanced RAG 解决“准不准”,Agentic RAG 解决“活不活”。最后对比了三条知识注入路线,并梳理了开发框架、低代码平台、云厂商服务三层产品生态。
有了这张地图,就可以深入了。下一章我们拆解 RAG 最基础的一个问题:计算机如何理解两句话意思相近?答案是 Embedding。
Embedding:把语义变成几何
知识库之所以能听懂人话,靠的是一个看似不起眼的步骤:把文本变成一串可以存储、可以比较的数字。本章从一次失败的搜索讲起,用地图经纬度建立语义空间的直觉,讲清楚余弦相似度如何衡量文本的接近程度、嵌入模型如何被训练出来,最后给出一张主流模型选型表和关键参数解读。
3.1 从关键词匹配的失败说起
假设你所在的公司有一套人力资源知识库。一位新员工遇到麻烦,在搜索框里输入了一句话:如何解除劳动合同。系统用的是传统关键词检索,把这句话拆成解除、劳动、合同这样的词,再去文档库里做字面匹配。而真正能回答他的那篇文档,标题叫员工离职流程说明,正文里写满了离职申请、交接清单这样的字眼,唯独没有一个词和查询相交。结果可想而知:搜索返回零条结果,或者一堆不相干的制度条文。
问题在于,从人的角度看,这两句话说的是完完全全同一件事,只是换了个说法。关键词检索判断的是有没有说同样的词,而不是是不是在说同一件事。类似的情况随处可见:社保和五险一金、电脑和微型计算机、API 和应用程序接口,字面交集为零,含义却完全相同。任何依赖字面匹配的系统,都注定会系统性地漏掉这些换了马甲的内容,而且漏掉的往往正是用户最急需的那一篇。
我们想要的是能理解含义的检索:看到解除劳动合同,就知道它和离职流程在讲同一件事。要实现这一点,必须先回答一个根本问题——含义这种看不见摸不着的东西,怎么才能变成计算机能存储、能比较的形式?本章给出的答案是:把含义变成几何。把每段文本表示成高维空间里的一个点,让意思接近变成位置接近。这个把文本映射成一组数字坐标的过程,就叫作Embedding,中文常译作嵌入或向量化。
关键词匹配的本质是字面比对,它只能判断两段文本有没有出现相同的词,无法判断它们是否在表达相同的意思。自然语言的词汇极其丰富,同一件事可以有几十种说法,同义词、缩写、中英混用、口语与书面语的差异无处不在。检索系统如果跨不过这道措辞鸿沟,就会在用户最需要答案的时刻集体失明。引入语义表示,就是要把含义映射成数学上可比较的对象,让意思相近变成数值上相近,把检索从字符串匹配变成在空间中找最近点的几何问题。这是整个 RAG 技术栈的出发点,后面所有组件都建立在这个假设之上。
3.2 什么是向量:语义空间的坐标
先从一个每个人都熟悉的东西说起:地图。在地图上,每座城市都用两个数字来定位——经度和纬度。北京大约位于东经 116.4 度、北纬 39.9 度,天津大约在东经 117.2 度、北纬 39.1 度。两组数字相差很小,两座城市在地理上就挨得近;数字差得远,两座城市就相隔千里。坐标的意义在于:它把一个城市在平面上的位置,变成了两个可以计算、可以比较的数字。我们需要的,是一张能给文本定位的地图,这就是语义空间。
只不过这张地图不是二维的,而是成百上千维的。一段文本的向量表示就是一长串数字,形如 0.21、-0.87、0.05 一直到 1.32,长度可能是 1024,也可能是 3072。每一维可以粗略地理解为某种语义特征,比如话题的正式程度、情感倾向、领域归属,尽管绝大多数维度并不对应任何人类能命名的概念。我们不需要读懂每一维的含义,真正有用的是向量之间的整体几何关系:谁离谁近,谁离谁远。
有了坐标,判断语义的规则就变得异常简单:语义相近,等价于距离相近。如何解除劳动合同和离职流程说明,在语义空间里是两个相距很近的点;如何解除劳动合同和昨晚的足球比赛比分,则相距遥远。于是语义检索的做法水到渠成:把用户的问题也变成一个点,在库里找出离它最近的若干个点,这些点对应的文本就是检索结果。开头那个失败案例,就这样被几何化解掉了。
当然,这张地图不是人工绘制的,而是模型从海量文本中学出来的。对使用者而言,拿到坐标的过程非常简单:调用一次嵌入模型,它就输出一个固定长度的向量。这个向量可以缓存、可以复用——只要文本不变、模型不变,坐标就不变。这也是第 5 章索引侧设计的重要依据:向量化是一次性开销,算一次,用很久。
这里必须强调一条纪律:坐标离不开地图。北京的经纬度在地球坐标系里才有意义,拿到火星坐标系里就是一串无效数字。同理,不同嵌入模型生成的向量处在不同的语义空间里,两个来自不同模型的向量,无论看起来多么像,都不能放在一起比较距离。这一点在本章末尾和后面的工程章节还会反复出现。
flowchart LR A[如何解除劳动合同]-->C[Embedding 模型] B[离职流程说明]-->C C-->D[两组数字向量] D-->E[语义空间中距离很近
判定为语义相似]
3.3 相似度度量:如何衡量两个向量有多接近
文本已经变成了坐标,接下来的问题是:两个向量的接近程度,用什么算式来衡量?常用的度量有三种:余弦相似度、点积和欧氏距离。文本检索中最主流的是余弦相似度,我们重点讲它,另外两种一句话带过。
余弦相似度的直觉非常几何:把每个向量想象成从原点出发的一条射线,两条射线的夹角越小,方向就越一致。余弦相似度就是这个夹角的余弦值:等于 1 表示方向完全相同,等于 0 表示互相垂直、毫无关联,等于 -1 表示方向完全相反。实际检索中不会要求达到 1,两个文本的余弦值能到 0.8 以上通常就算相当接近了,具体阈值因模型和数据而异。
打个比方。两个人从同一个原点出发,一个人向东北方向走了 100 米,另一个人向东北方向走了 500 米。他们走的路程差别很大,但方向完全一致——余弦相似度只看方向,不看走了多远,所以会判定这两个人完全同路。至于点积,它同时考虑方向和向量的长度;欧氏距离则看两个终点之间的直线距离,更接近日常说的远近。这两种度量在个别场景也有用武之地,但在文本检索里不是主流,知道它们存在即可。
为什么文本检索默认选用余弦相似度?因为它对向量的长度不敏感。一段长文本的向量,模长往往比一句短问题的向量更大,因为承载的信息量更多;但这里的长度差异反映的只是文本的体量,而不是语义内容。如果用对长度敏感的度量,一篇讨论同一主题的长文档反而可能比一段简短描述显得更远,这显然不合理。余弦相似度只比较方向,相当于把所有向量先归一化到单位长度再比较,于是短问题和长文档被拉到同一条起跑线上。检索场景几乎永远是短查询找长文档,这个性质就显得尤为关键。
3.4 Embedding 模型是如何训练的
接下来的问题是:模型凭什么学会把意思相近的文本放到一起?嵌入模型的骨架通常是一个 transformer,输入一段文本,输出一个固定长度的向量,结构本身并不神秘,奥秘全在训练方法上。主流做法是对比学习,思想一句话就能说清:把相似的句子对拉近,把不相似的句子对推远。
具体来说,先要准备大量的句子对。一个问题,和能够回答它的那篇文档,构成一个正样本对;同一个问题,和一篇毫不相关的文档,构成一个负样本对。训练时,模型不断调整自己的参数,目标只有两个:让正样本对的向量距离越来越小,让负样本对的向量距离越来越大。当成千上万个句子对都这样处理过之后,语义空间逐渐成形:谈论同一主题的内容聚拢在一起,互不相干的内容彼此远离。
这个过程很像一位图书管理员在整理书架。管理员并不需要读懂每本书的内容,他只需要观察借阅记录:两本书如果经常被同一批读者先后借走,就把它们摆得近一些;两本书的借阅人群毫无交集,就摆得远一些。看得足够多之后,书架自然形成了按主题亲疏排布的格局。对比学习做的就是这种基于共现关系的整理,只不过材料换成了海量文本对,书架换成了高维语义空间。
值得补充两点。其一,负样本对通常不需要人工标注,工程上直接把同一训练批次里的其他样本当作负例使用,数据成本因此大大降低。其二,正因为见过足够多的正反例子,模型获得了泛化能力:对于训练中从未出现过的措辞,只要意思相近,向量依然会靠得足够近。这正是今天的开源嵌入模型能够彻底取代人工维护同义词词典的原因——规则是写不完的,而数据可以把规则学出来。
再往深一层说,训练数据的构成决定了模型的上限。用什么样的句子对训练,模型就学会什么样的检索关系:用网页搜索日志训练的模型,擅长问题找答案这种问答式匹配;用平行语料训练的模型,更擅长跨语言对齐。所以当你发现某个模型在特定场景表现平平,往往不是模型差,而是它的训练数据与你的场景不同源。这也是选型必须用自己的数据实测的原因之一。
3.5 主流模型选型
原理讲完,落到选型。目前可用的嵌入模型大致分两个阵营:以 OpenAI、Cohere 为代表的商业 API,开箱即用、无需维护推理服务,但要考虑数据出域和按量计费;以 BGE、GTE、Qwen3-Embedding、jina 为代表的开源模型,可以私有化部署、数据不出域,但要自己准备显卡和运维。下表汇总了截至成书时的主流选择。
| 模型 | 维度 | 最大输入 | 特点 | 许可证或价格 |
|---|---|---|---|---|
OpenAI text-embedding-3-small[9] | 1536 | 8192 token | MTEB 62.3,高性价比的入门之选 | 约 $0.02 / 百万 token |
OpenAI text-embedding-3-large[9] | 3072 | 8192 token | MTEB 64.6,支持 dimensions 参数截断维度 | 约 $0.13 / 百万 token |
| BGE-M3[20] | 1024 | 8192 token | 支持 100+ 语言,稠密、稀疏、多向量三合一 | 开源 |
gte-Qwen2-7B-instruct[21] | 3584 | 32K 上下文 | 发布时 MTEB 70.24,C-MTEB 72.05 | 开源 |
jina-embeddings-v3[22] | 32~1024 可截断 | 8192 token | Matryoshka 套娃表示,维度可灵活选择 | CC BY-NC 4.0,商用需授权 |
| Qwen3-Embedding[24] | 1024 / 2560 / 4096 | — | 0.6B / 4B / 8B 三档参数,发布时多语言 MTEB 70.58 | Apache 2.0 |
| Cohere Embed 4[23] | — | 128K 上下文 | 多模态,支持 100+ 语言 | 商业 API |
怎么读这张表?第一看语言与场景:内容以中文为主,就优先关注在 C-MTEB 等中文基准上有公开成绩的模型;第二看成本结构:API 按 token 计费,数据量大且查询频繁时要算长期账,开源模型一次性投入显卡,边际成本趋近于零;第三看工程特性:是否支持维度截断、最大输入够不够长、是否覆盖你的语种。表中成绩只是发布时的官方口径,基准分数是综合能力的评价,而你的业务可能只用到其中一两种能力,必要时应该用自己的数据构建小型评测集实测,再下结论。
逐个点评几句。OpenAI 的两个模型胜在省心,small 版本价格极低,适合快速验证和中小规模库,large 版本精度更高且支持维度截断,适合对效果有要求又不想自建推理的团队。BGE-M3 的亮点是一个模型同时输出稠密、稀疏、多向量三种表示,为混合检索留足了空间。gte-Qwen2-7B-instruct 和 Qwen3-Embedding 代表了大参数嵌入模型的方向,中文成绩突出,且 32K 的上下文让长文本处理从容许多。jina-embeddings-v3 的套娃维度设计灵活,但 CC BY-NC 4.0 许可证意味着商用必须先获得授权,选型时务必让法务过目。Cohere Embed 4 则把多模态和超长上下文作为卖点,适合文档中图文混排、单篇极长的场景。
用 API 的话,不妨算一笔成本账。假设知识库有一百万个分块,每个分块约 500 token,首次全量向量化共五亿 token:按 text-embedding-3-small 的价格大约十美元,按 large 大约六十五美元,不算贵。真正的长期开销在查询侧——用户的每一次提问都要向量化一次,查询并发高时,费用会随使用量持续累积。开源模型的成本曲线正好相反:前期显卡投入高,边际成本趋近于零。哪边更划算,取决于你的数据量和查询量各自的增长曲线。
中文场景为什么优先选中文优化模型?MTEB 这类综合基准以英文为主,各模型训练语料中中文的比例、对中文表达习惯的适配程度差别很大。有的模型英文榜单成绩亮眼,一到中文任务就明显下滑;而在 C-MTEB 等中文基准上表现突出的模型,往往对中文词汇、句法和篇章做过针对性优化。知识库的内容和用户的提问都以中文为主时,应优先选择有公开中文基准成绩、社区口碑好的模型;如果只能参考综合榜单,也务必先用自己业务的真实查询做一轮小样本验证,再决定上线哪一个。
表中的 text-embedding-3-large 与 jina-embeddings-v3 都支持 Matryoshka 维度截断:使用时通过 dimensions 参数或相应接口指定一个更小的维度,例如把 3072 维截断到 512 维,存储占用直接降为原来的六分之一,检索速度随之提升,而精度损失几乎可以忽略。对数据量大、内存紧张或延迟敏感的场景,这是一项近乎免费的优化;它还让架构多了一条退路——先用低维度快速上线,等效果遇到瓶颈再考虑升维重建。
3.6 关键参数解读
选型表里有三个参数反复出现,值得单独展开:维度、最大输入和套娃表示。它们直接决定了存储成本、工程复杂度和效果上限。
维度:表达能力与成本的权衡
维度是最直观的参数:512、1024、3072、4096。维度越高,理论上能记录的语义细节越多,效果天花板越高,但代价也随之上升。算一笔账:一百万条 1024 维向量,每个数字按 float32 占 4 字节,整库约 4GB;维度减半,存储、检索时的计算量、网络传输开销全都跟着减半。所以维度本质上是一个表达能力与成本的权衡旋钮。多数知识库场景下 1024 维已经够用,只有在追求极致精度、且资源充裕时,才值得上更高维度。
最大输入:警惕静默截断
最大输入决定了模型一次能读进多少文本。主流模型多为 8192 token,粗略对应数千个汉字;gte-Qwen2-7B-instruct 支持 32K 上下文,Cohere Embed 4 更是达到 128K。这里的坑在于:超出上限的文本会被模型截断,而且大多数模型截断时不会给出任何提示——长文档的结尾内容就这样悄无声息地从向量里消失了。因此长文档在向量化之前必须先合理切分,这正是第 5 章要详细讨论的分块环节。另一个容易忽略的推论是:最大输入也约束了分块尺寸的上限,块不能比模型能吃的还大。
MRL:套娃表示
套娃表示学习(Matryoshka Representation Learning,简称 MRL)是近两年流行起来的训练技巧。传统训练只保证完整维度向量的效果,把它拦腰截断,精度会断崖式下跌;MRL 在训练时同时约束向量前 N 维也具备良好的判别力,就像俄罗斯套娃,大娃娃肚子里装着完整的小娃娃。于是使用时可以只保留前 512、256 甚至 32 维:jina-embeddings-v3 支持 32 到 1024 之间的任意截断,OpenAI 的 text-embedding-3 系列则通过 dimensions 参数指定。好处在前面的 benefit-box 里已经算过账:存储与速度大幅改善,精度几乎不动。
请牢记一条纪律:嵌入向量不能跨模型使用。不同模型生成的向量处在不同的语义空间,混用或跨模型比较毫无意义。升级或更换模型时,必须把库里的全部内容重新向量化,并且保证索引侧与查询侧始终使用同一个模型、同一个版本。建议在入库时把模型名称、版本号和维度一并写进元数据,日后重建索引时才有据可查。
3.7 本章小结
本章完成了 RAG 的第一次关键跳跃:把不可计算的含义,变成了可计算的几何。关键词匹配之所以失败,是因为它只盯字面;嵌入模型把文本映射进高维语义空间,让语义相近变成距离相近;余弦相似度提供了对长度不敏感的度量方式;对比学习——拉近正例、推远负例——解释了模型能力的来源;选型表与维度、最大输入、MRL 三个参数,则为工程决策提供了依据。
但新的问题随之而来:当数据量到达百万级,每次查询都逐条比对显然不可行。如何从海量向量里快速找出最近邻?这些向量又该交给什么工具来存储和管理?下一章,我们进入向量数据库与索引算法。
向量数据库与索引算法
向量已经生成了,接下来的问题是:把它们存在哪里,又如何在毫秒之间从百万条候选里找出最近邻。本章先算一笔成本账,说明为什么必须建索引;再讲近似最近邻检索如何用可控的精度损失换取数量级的提速;然后用三个生活类比拆解 HNSW、IVF、PQ 三大索引算法;最后给出一张主流向量库对比表和选型建议。
4.1 规模问题:一百万条向量的成本账
上一章的结尾,我们把文本变成了向量。如果数据只有几百条,事情很简单:把所有向量放进内存,来一个查询就循环一遍,逐一计算余弦相似度,返回最接近的几条。十几行代码就能搞定,第 6 章的第一个原型正是这么做的。但生产环境里的知识库通常是另一个量级:一家中型企业的文档库动辄数万篇文档,切块之后就是几十万到上百万条向量。
来算一笔账。假设每条向量 1024 维,每个数字按 float32 占 4 字节,一条向量就是 4KB,一百万条约 4GB。一次检索意味着什么?把这 4GB 数据完整读一遍,对每条向量做一次 1024 次乘加。就算现代 CPU 的内存带宽和向量指令足够快,单次查询也要几十到上百毫秒——这还是理想情况。真实的在线服务每秒要处理几十上百个请求,而且一次 RAG 问答内部可能触发多次检索,比如多路查询扩展、混合检索,成本还要翻倍。几十毫秒乘以并发数,服务器立刻被拖垮。
换句话说,线性扫描就像一家没有目录的书店:无论顾客要找哪本书,店员都得从第一个书架开始一本本翻过去。店里只有一百本书时,这种办法简单可靠;店里有一百万本书时,就必须先想办法把书预先整理好——这正是索引存在的理由。
再看延迟预算。一次可接受的 RAG 问答,通常期望在几秒内给出完整答案,其中大模型生成已经占去大头,留给检索环节的预算往往只有几十到一百毫秒。线性扫描单次就要吃掉整个预算,更不必说多路检索与并发。所以索引不是锦上添花的优化项,而是系统能否成立的前提。
还有一个隐藏问题:这 4GB 向量数据必须放进内存才能保证访问速度,若再加上原文与元数据,单机内存需求还要翻番。数据量到亿级时,单机彻底放不下,分布式分片成为必然——这正是 Milvus 这类分布式向量数据库存在的原因之一。
为什么要专门建索引,而不是直接暴力比对?因为线性扫描的成本随数据量线性增长,它虽然百分之百精确,却无法满足在线检索毫秒级响应的要求。索引的本质是预计算:提前花时间把数据组织成某种附加结构,让每次查询只需要查看其中很小的一部分。这实际上是用预处理时间和额外存储空间去交换查询时间,同时也用一点点精度损失去交换数量级的提速——下一节近似最近邻里的近似二字,就是为此付出的代价。
4.2 精确检索与近似最近邻 ANN
信息检索里有个概念叫精确检索:把查询和每一条候选逐一比较,保证找出真正的最近邻,召回率百分之百。它像一次精确查户口——挨家挨户敲门登记,一个不漏,绝对准确,但慢、贵,而且每次人口变动都要重新查一遍。数据量小的时候它很香,数据量一大就成了不可承受之重。
与之相对的是近似最近邻(Approximate Nearest Neighbor,简称 ANN)。它更像凭经验指路:你向本地人打听一家店在哪,对方不会带你挨家挨户找,而是说往东北那片走,到了附近再打听。这个答案不保证百分之百精确,但足够快,而且绝大多数时候八九不离十——顺着方向走,大概率能找到目标,最多在终点附近多绕两步。
ANN 算法用召回率来量化这种取舍:真正的最近邻前 10 条里,ANN 返回的结果命中了 9 条,召回率就是 0.9。在 RAG 场景里,这笔交易非常划算——漏掉一两条相关片段并不致命,后面还有冗余候选和重排序兜底;但响应慢到让用户干等几秒,是致命的。所以 ANN 成了向量检索的默认形态,下一节的三大算法,都是在回答同一个问题:如何跑得更快,同时少丢一点。
召回率怎么测?方法很直白:以精确检索的结果为标准答案,让 ANN 索引跑同一批查询,看真正的 top K 里有多少落进了 ANN 返回的 top K。工程上常见的目标是 0.9 到 0.99 之间——越接近 1 越逼近精确检索,代价是越慢。评估时还要看统计量的稳定性:单条查询的召回波动很大,几千条真实查询上的平均值才有参考价值。
4.3 三大索引算法
HNSW:先坐快线,再换慢线
HNSW 的全称是分层可导航小世界图,是目前应用最广的向量索引。它把所有向量组织成一张多层的图:最上层节点最少,边连得远;越往下节点越多,边连得越短;最底层包含全部向量。层与层之间通过共享的节点相连,像一座立体的交通枢纽。
检索过程就像坐高铁换乘。假设你要从一个偏远小城去另一个偏远小城,合理的路线是:先坐慢线到附近的大站,再换乘快线横穿到目的地附近的大站,最后再换慢线抵达。HNSW 的搜索就是这个思路的镜像:先从最上层的快线网络出发,每一步贪心地跳到离查询最近的节点;当这一层再也走不出更近的一步,就下沉到下一层继续细找,直到最底层收敛到目标附近。快线负责大跨度逼近,慢线负责最后一公里。
这种设计让 HNSW 的搜索路径极短,通常只需几百次距离计算,就能从百万级数据里拿到高质量结果,召回率也稳定。代价是内存:除了向量本身,还要存储图的边结构,内存占用明显高于其他索引;建图过程也比较耗时,增量插入需要额外处理。它适合百万到千万级、内存相对充裕的场景,是大多数向量库的默认选项。
建图时通常暴露两个参数:一个控制每个节点连接多少邻居,决定图的连通程度与内存开销;另一个控制建图过程中搜索的认真程度,决定图的质量与构建耗时。在数据持续更新的场景,还要留意新增数据的插入成本——部分实现选择定期重建而非实时插入,用短暂的索引滞后换取实现简单,这也是一种工程取舍。
IVF:按行政区划分快递片区
IVF 全称倒排文件索引,思路是先分区、再搜索。离线阶段把全部向量做一次聚类,比如分成 1024 个桶,每个桶记录一个中心点。查询时不再扫描全库,而是先把查询向量和这 1024 个中心点比较,选出最近的几个桶——这个参数叫 nprobe——然后只在这几个桶内部做精确比较。
这就像快递公司按行政区划分片区:快递员不会跑遍全城找收件人,而是先判断包裹属于哪个区,直接去那个区的站点分拣。片区划分得合理,工作量就从全城缩小到了一个区。nprobe 是调节旋钮:只查 1 个桶最快,但容易漏掉边界附近的目标;查 32 个桶,召回率明显上升,速度也随之下降。实践中通常根据延迟预算反复试出合适的值。
flowchart LR A[查询向量]-->B[与所有桶中心点比较] B-->C[选出最近的几个桶] C-->D[只在这些桶内逐条比较向量] D-->E[返回最相似的前 K 条结果]
PQ:把高清照片压成缩略图
PQ 全称乘积量化,解决的是另一个问题:向量太占空间。它的做法是把每条向量切成若干段,比如 1024 维切成 64 段、每段 16 维;对每一段单独做聚类,得到比如 256 个子中心点。这样每一段只需记录一个 1 字节的编号,整条向量就从 4KB 压缩到 64 字节,压缩比高达 64 倍。
类比是把高清照片压成缩略图:缩略图丢失了大量细节,但足以让你大致分辨出这是谁、那是哪,而存储开销只有原图的零头。PQ 检索时会预先算好查询子向量到各子中心点的距离表,之后对任意候选向量只需查表求和,就能快速得到近似距离,速度极快。代价是精度必然有损失,所以 PQ 很少单独上阵,通常与 IVF 或 HNSW 组合:先用压缩向量粗排,再用原始向量精排。
实际产品中,三者常以组合形态出现:IVF 加 PQ 是亿级数据的经典搭配;HNSW 配合 PQ 或标量量化,可以大幅压低内存;还有图索引加量化再加磁盘存储的混合形态,用磁盘换内存。理解了这三个积木,你就能读懂任何索引名称背后的含义。
| 索引 | 原理一句话 | 检索速度 | 内存占用 | 召回特点 | 适用规模 |
|---|---|---|---|---|---|
| HNSW | 多层图结构,先坐快线大站再换慢线小站 | 很快 | 高,需存图结构 | 召回率高且稳定 | 百万至千万级 |
| IVF | 先聚类分桶,查询时只搜最近的几个桶 | 快 | 中 | 随 nprobe 可调,弹性大 | 百万至亿级 |
| PQ | 向量分段量化压缩,查表算近似距离 | 快 | 很低,每条仅数十字节 | 有损失,多与他人组合 | 亿级以上,配合 IVF 等 |
索引参数没有放之四海而皆准的最优值:HNSW 的 efSearch、IVF 的 nprobe、PQ 的分段数,都是越精确就越慢、越省就越有损失的旋钮。正确姿势是准备一份来自真实业务的查询评测集,逐一测出召回率与延迟的对应关系,再结合自己的服务目标找平衡点。直接照搬别人博客里的参数,往往因为数据分布不同而水土不服。
4.4 主流向量库对比
索引算法是引擎,但工程上需要的是整车。一个成熟的向量数据库,除了索引,还要解决数据持久化、增删改查、元数据过滤、分布式扩展、权限与运维等一系列问题。下表列出了截至成书时的几个主流选择,它们的定位差异相当大。
| 产品与版本 | 定位与核心特性 | 适用场景 |
|---|---|---|
| FAISS 1.14.3[12] | Meta 开源的向量检索库而非独立服务,支持 Flat、IVF、PQ、HNSW 等索引 | 嵌入自有应用,需要完全掌控检索细节 |
| Chroma 1.5.9[10] | Rust 重写核心的轻量向量库,Python API 简单,开箱即用 | 原型验证与中小规模应用 |
| Milvus 3.0.0[11] | 分布式云原生向量数据库,2026 年 7 月发布的 3.0 版本支持 BM25 全文检索、TEXT 长文本字段与 Function Chain 重排 | 大规模生产环境 |
| Qdrant 1.19.0[13] | Rust 编写,性能出色,支持 TurboQuant 量化压缩,过滤能力强 | 过滤条件复杂、内存敏感的场景 |
| Elasticsearch 9.x | 传统搜索引擎叠加向量能力,ES|QL 支持混合检索 | 已有 ES 技术栈的团队 |
几条选型经验。做原型或小应用,Chroma 的简单 API 能在几分钟内跑通全流程,把精力留给效果本身;数据量和并发都上来了,需要分布式与混合检索,Milvus 3.0 值得优先考虑,它的 BM25 全文检索与 Function Chain 重排,恰好对应下一章要讲的混合检索与重排序环节;团队已有 Elasticsearch 栈,在 ES 9.x 上叠加向量能力是迁移成本最低的路线,全文与向量在一套系统里完成;想把检索能力嵌进现有服务、对每个参数都要可控,FAISS 仍是首选的库。Qdrant 则在过滤条件复杂、内存敏感的场景里表现突出,TurboQuant 量化能显著压缩内存占用。
按规模分阶段选型,收益是直接的。起步阶段用 Chroma、FAISS 这类轻量方案,几行代码就能跑通,快速验证想法是否成立,完全不碰复杂运维;等数据量上来再迁移到 Milvus 这类分布式方案,由于检索逻辑大多收敛在接口层,索引侧与查询侧的代码改动有限。起步轻、演进平滑,既避免了小项目用不起大集群的浪费,也避免了业务长大后小方案顶不住的尴尬。
向量库选型是 RAG 项目里过度设计的重灾区之一。不少团队在文档只有几千篇时就架起分布式集群,结果运维成本远超收益,而真正的效果瓶颈往往根本不在数据库上。判断标准可以很简单:百万条向量以内、单机就能放下,Chroma 或 FAISS 完全够用。先把流程跑通,用评测找到真实瓶颈,再谈存储升级,顺序不要反。
最后提醒一句:无论选择哪款数据库,都把嵌入模型的名称、版本与维度写进元数据或系统配置。这些信息在模型升级、索引重建的场景里会派上用场,也方便团队成员交接时快速理解现状。
4.5 元数据过滤与混合存储
真实业务里的检索,很少是纯粹的相似度查找。用户往往带着条件来:只搜本部门的文档、只看今年发布的制度、只查我有权限访问的内容。这就需要元数据过滤:每条向量入库时都附带来源文件、所属部门、发布时间、权限级别等属性,查询时先按条件缩小候选范围,再在范围内做向量相似度检索。主流向量库都支持这种过滤加检索的组合查询,过滤条件的表达能力也是选型时的重要维度,Qdrant 在这方面的口碑尤其突出。
举个例子。全员检索报销标准,可能命中几十个部门的制度,而用户只关心自己部门的。有了元数据过滤,查询可以写成:部门等于某值,且发布时间在最近一年内,且语义上匹配报销标准。前两个条件走结构化过滤,最后一个走向量相似度,三者各司其职。缺少这种能力,向量库在真实业务里基本只能算玩具。
第二个容易被忽视的问题是:向量库里到底存了什么。一条记录通常包含三样东西——向量本身,用于相似度检索;分块的原文,用于命中后直接返回给大模型;元数据,用于过滤与前端展示。也有架构把原文放进对象存储或文档数据库,向量库只存 ID,好处是省空间,代价是每次命中后要多查一次。更常见的做法是三样全存,检索返回即拿到文本,下一章的上下文组装环节可以直接使用,链路更短。
最后留一个钩子。你可能已经注意到,无论是元数据过滤,还是 Milvus 3.0 的 BM25 支持、Elasticsearch 的 ES|QL 混合检索,都指向同一个方向:向量检索不是孤立的,它要和关键词检索、结构化条件协同工作。具体如何协同,留到下一章的查询侧流水线里展开。
4.6 本章小结
本章的脉络很清晰:百万级规模下逐条比对的成本不可接受,所以要建索引;精确检索准但慢,ANN 用可控的召回损失换数量级提速;HNSW 像高铁换乘,先快线逼近再慢线细找;IVF 像按行政区划分快递片区,先定位再桶内搜索;PQ 像把高清照片压成缩略图,用极小的空间换近似距离。三者分别解决路径、范围、空间三类问题,工程中常常组合使用。向量数据库则把索引包装成完整的存储与服务,选型看规模、看技术栈、看过滤与混合检索的需求。
引擎和整车都齐了,下一章我们把它们装进完整的流水线:文档如何一步步入库,一个问题又如何一步步变成带引用的答案。
RAG 流水线解剖
前两章讲的嵌入模型、索引算法、向量数据库,都是零件。本章把它们装配成一台完整的机器:一个 RAG 系统由索引侧与查询侧两条流水线组成,一条离线建库,一条在线应答。我们逐环节走一遍,讲清每一步的作用与常见的坑,最后给出一张质量瓶颈地图,作为后续实战篇的导航。
5.1 两条流水线总览
无论多么复杂的 RAG 系统,剥掉产品外壳,内部都是两条流水线。第一条是索引侧,运行在离线阶段:把文档收集起来,解析、清洗、分块、向量化,最后写入向量库。它决定了知识库里能搜到什么,是一次性建设加周期性维护的工作。第二条是查询侧,运行在在线阶段:从用户提问输入开始,经过理解、检索、重排、组装、生成,到带引用的答案输出。它决定了系统怎么搜、怎么答,每个用户问题都要完整走一遍。
两条线的性格完全不同。索引侧是批处理,可以慢慢打磨,建好之后长期运行;它犯的错是先天的,内容没被正确索引,后面查询侧再怎么调参也救不回来。查询侧是实时服务,每一步都有毫秒级预算,必须在效果与延迟之间精打细算。绝大多数 RAG 效果问题,都能归因到这两条线中的某一环;学会把系统拆成两条线分别观察,是调试 RAG 的第一步。
两条线的成本结构也不同。索引侧是一次性计算开销:每篇文档只需要向量化一次,结果长期有效;查询侧是按次发生的开销:每个用户问题都会触发一整条链路,用户量上涨,开销随之线性上涨。这种差异决定了两条线的优化方向——索引侧值得花时间打磨质量,查询侧则要为每一毫秒精打细算。
连接两条线的交接点是向量数据库:索引侧的产出,正是查询侧的检索对象。这也意味着,交接点上的问题——元数据写错、模型版本不一致——会同时影响两条线,是排查故障时的第一嫌疑人。
flowchart TB subgraph indexSide [索引侧 · 离线建库] A[文档加载]-->B[格式解析] B-->C[内容清洗] C-->D[分块] D-->E[向量化] E-->F[写入向量库] end subgraph querySide [查询侧 · 在线服务] G[用户提问]-->H[问题理解与改写] H-->I[检索召回] I-->J[重排序] J-->K[上下文组装] K-->L[大模型生成] L-->M[带引用的答案] end F -. 提供候选分块 .-> I
下面两节按顺序走一遍每个环节。每一步只关注两件事:它解决什么问题,以及它最容易在哪里翻车。
5.2 索引侧:从文档到可检索的知识
文档加载:把数据接进来
索引侧的起点是把文档源接进来:本地文件服务器、Wiki 系统、云文档平台,甚至数据库里的业务数据。这一步看似只是搬运文件,却有两个高频坑。其一是权限遗漏:部分文档需要特定身份才能读取,采集账号权限不足,结果知识库先天缺一块;其二是增量更新:文档库是活的,每天都有新增、修改、删除,只做一次性全量采集,知识库很快就会过时。成熟的做法是建立文档源清单与定期同步机制,记录每篇文档的来源、版本与最后更新时间。
格式解析:把文件变成可读文本
加载回来的是 PDF、Word、HTML、Markdown、PPT 等各种格式,而嵌入模型只认纯文本,所以需要格式解析:从每种格式里提取正文、标题、列表和表格。这一步看起来是脏活累活,却决定了知识库的上限——内容在这一步被解析错了,下游模型再好也无能为力。坑集中在 PDF 上:扫描版本质是图片,不做 OCR 一个字也提不出来;双栏排版容易读乱顺序;表格最棘手,行列错位、表头与表体分离都很常见,一张错位的财务报表可能把错误的数字一路带进大模型的答案里。
解析之前,先抽样检查文档库的格式构成:扫描件占多少、复杂表格占多少、有没有大量公式与代码块。扫描件要安排 OCR 流程,表格尽量选择能保留结构的解析方案,并抽查行列对齐情况。解析质量的抽检应当成为入库前的固定动作,而不是等用户反馈答案不对之后再回头排查——到那时,定位成本会高得多。
内容清洗:去掉噪声
解析出来的文本还带着大量噪声:页眉页脚、页码、水印、网站导航栏、广告文案,以及 PDF 换行造成的破碎段落。这些内容混进向量库会污染检索——想象一个分块,页眉的公司制度汇编几个字占了小半,用户搜任何制度都可能把它召回,而它其实什么都回答不了。清洗工作包括:去除重复出现的页眉页脚与页码,合并被换行打散的段落,过滤过短或无意义的片段。经验法则是:凡是你不希望出现在大模型答案里的东西,就不要让它先进库。
分块:最影响效果的环节
清洗之后,要把长文本切成一个个分块。为什么要切?一是嵌入模型的最大输入有限,几万字的长文档塞不进去;二是检索粒度要合适:块太大,一个块里混着多个主题,检索命中时会带回大量无关噪声;块太小,完整语义被切断,单个块回答不了问题。常见策略有两类:一类是固定长度加重叠窗口,比如每 500 字切一块,相邻块重叠 50 到 100 字,防止切分点上的内容被拦腰斩断;另一类是按结构切分,以段落、标题、列表为天然边界,让每个块保持相对完整的语义单元。实践中通常两者结合:先按结构切开,再把过长或过短的段合并、重切。
块多大合适没有标准答案,但有经验参照:常见的块大小多落在几百字的区间——小到主题聚焦,大到能容纳一段完整的论述。重叠窗口也不宜过大,够覆盖句子边界上的上下文即可,过大的重叠会让同一内容被重复索引、重复召回,浪费空间还引入噪声。最可靠的做法仍然是准备评测集,把几种块大小与重叠组合逐一试验,让数据说话。
为什么说分块是最影响 RAG 效果的环节?三个理由。第一,它决定检索粒度:用户最终是按块拿到答案的,块的大小直接决定上下文是否聚焦,太大夹带噪声,太小丢失背景。第二,它决定语义完整性:一条完整的规则被切成两半,每一半的向量都无法代表完整含义,检索自然漂移。第三,它的错误是先天且不可逆的:解析、清洗的问题属于脏数据,尚可返工;而分块不当会让好数据也搜不到——后面无论怎么调检索参数、换更强的模型,都无法找回从未被正确索引的内容。所以业内有一条共识:效果不好,先看分块。
向量化:文本变向量
有了分块列表,就调用嵌入模型把每个块变成向量。工程要点有三条。第一,批量调用,不要一条一条地发,无论走 API 还是本地推理,批量都能显著提升吞吐。第二,模型一致性,索引侧与查询侧必须使用同一个模型、同一个版本,否则向量处在不同的语义空间,检索结果全是垃圾——这条纪律在第 3 章已经强调过,在这里它是硬性约束。第三,记录模型信息,把模型名称、版本号、维度写进元数据,将来升级模型时,才知道哪些数据需要重建。
写入向量库:存得下、管得住
最后一步,把向量连同原文、元数据写入向量库。工程细节包括:批量写入以减少网络往返;为每个分块设计唯一 ID,通常用来源文档 ID 加分块序号的组合,方便增量更新——文档更新时按 ID 删除旧块再插入新块,或直接覆盖;把来源、时间、部门、权限等一切可能用于过滤的属性都写进元数据。至此,索引侧走完,文档正式变成可检索的知识。
这里还有一个容易被忽视的工程问题:幂等。同步任务可能中途失败后重跑,如果写入不做幂等处理,同一个分块会被写两遍,检索结果里出现重复条目。唯一 ID 加上覆盖式写入,让任务重跑变得安全——无论执行多少次,库里的状态都一样。这个设计原则在后面的索引重建场景同样适用。
5.3 查询侧:从问题到带引用的答案
问题理解与改写
用户的问题往往不是拿来就能搜的:口语化、含糊、带着指代。多轮对话里尤其明显,用户的第二句经常是什么那第二条呢、打折之后多少钱,孤立地看完全不知道在问什么。问题理解环节的任务,是把人话翻译成明确的检索意图:把指代还原成上文里的实体,把省略的上下文补全,必要时把一个问题改写成多个子查询,覆盖不同的措辞。这一步容易被忽视,但在多轮场景里,它几乎决定了检索是否跑偏。
另一个常用手法是查询扩展:把一个问题扩写成几个措辞不同的版本,或者从复合问题里拆出若干子问题,分别检索再合并结果。例如,年假的天数条件和未休补偿怎么算,拆成两个查询分别检索,往往比一个长句检索得更全。代价是多几次检索开销,收益是漏召回明显减少,在多数场景里这笔交易是划算的。
检索:稠密、稀疏与混合
拿到明确的查询,就去向量库找候选。路线有三条。稠密检索是前两章的主角,把查询变成向量找最近邻,强项是语义理解,解除劳动合同能搜到离职流程;稀疏检索是 BM25 这类传统关键词方法,强项是精确命中专有名词、编号、型号——这类内容向量检索反而常常失手;混合检索两条路都跑,再用加权或排名融合的方式合并结果。Milvus 3.0 内置 BM25 全文检索、Elasticsearch 9.x 用 ES|QL 支持混合检索,都是冲着这个需求来的。实践中,混合检索的稳定性通常好于任何单一路径。
两路结果的融合方式常见两种:一种是加权求和,把两路分数归一化后按权重相加,权重需要针对数据调优;另一种是排名融合,只看名次不看分数,把两路结果的名次倒数相加后重新排序,对分数尺度不敏感,更稳健,也常被用作默认选择。
重排序:先粗筛,再精排
检索返回前 50 或前 100 个候选,但不能把它们全塞给大模型——数量太多,噪声和 token 成本都会爆炸。正确做法是加一层重排序器:把查询和每个候选分块拼成一对,整体输入一个交叉编码器模型,逐对打出相关性分数,再按分数排序,取前 5 到 10 条。它和嵌入模型的差别在于:嵌入模型把两段文本各自独立编码成向量,快但粗糙;重排序器把查询和文档放在一起读,能捕捉词与词之间的细粒度互动,准但慢,两者差着几个数量级。
为什么要加重排序这一步?因为初筛与精排天然矛盾。初筛面对百万级候选,必须在毫秒内返回,只能用轻量的双塔向量算近似相似度,难免混进看似相关、实则跑偏的结果;重排序器准得多,但给一对文本打分的成本远高于一次向量检索,不可能对全库逐条打分。于是有了分工:初筛要快,负责从全库里宽泛地捞出几十上百个候选;精排要准,负责在这个小候选集里优中选优。这种先粗后细的漏斗设计,是检索系统在速度与准确之间取得平衡的核心手段,也是 RAG 中性价比最高的效果优化之一。
上下文组装
精选出的几个分块,接下来要拼装进大模型的输入。看似只是字符串拼接,其实有几个细节。token 预算有限,分块数量与长度要控制,宁缺毋滥;分块要有顺序,大模型对长文本的开头与结尾关注度更高,关键内容通常放在前面或最后;每个分块附上编号与来源,例如编号 1 来自员工手册第三章,为引用标注打好基础。模板里还应写明材料的地位:以下材料是回答的唯一依据。
生成
带着组装好的上下文,交给大模型生成答案。提示词里通常有明确约束:只根据提供的材料作答,材料没有覆盖的内容要明确说不知道,不要编造。这些约束直接决定了幻觉控制的效果——RAG 的价值就在于让模型有据可依地回答,一旦模型脱离材料自由发挥,知识库就成了摆设。采样参数也宜保守:问答场景下把温度调低,让输出更稳定、更贴合材料。
工程上,生成环节还有两个值得注意的细节。一是流式输出:答案边生成边推给前端,用户的感知等待时间大幅缩短;二是兜底策略:当模型判断材料不足以回答时,应引导用户换一种问法或转人工,而不是硬答。这两个细节不影响正确性,却直接影响用户对系统的第一印象。
引用标注
最后一步,给答案标上出处:哪句话来自哪个分块,哪个分块来自哪篇文档,用编号一一注明,用户点开就能看见原文。好处至少有三条:用户可以自行核验,信任度随之上升;幻觉空间被压缩,模型知道答案会被追溯,回答自然更克制;效果出问题时,能快速归因到具体分块,知道该去修哪里。引用标注不是锦上添花,而是 RAG 系统可验证性的关键一环。
上下文组装时,务必给每个分块带上编号与来源。这个小动作是引用标注和效果归因的基础:只有知道答案出自哪个块,答案出错时才知道该修哪个块。不少团队在系统上线后才想起补这一步,结果历史数据无法追溯,只能重建索引,代价惨重。
5.4 质量瓶颈地图
流水线走下来你会发现:每个环节都可能出问题,而症状却高度相似——答案不准。为了让排查更快,我们把各环节的典型问题、影响与优化手段汇总成下表。它可以当作体检清单:系统效果不佳时,对照它逐环节检查,通常能很快定位病灶。表中的优化手段,都会在后续实战篇展开成可运行的代码。
| 环节 | 典型问题 | 对最终答案的影响 | 对应优化手段 | 详见 |
|---|---|---|---|---|
| 格式解析 | 扫描件未做 OCR,表格行列错位 | 相关内容根本没入库,问到它必错 | 按格式选择解析方案,OCR 与表格结构抽检 | 实战篇 |
| 内容清洗 | 页眉页脚、噪声文本混入 | 无关内容被高频召回,稀释答案 | 清洗规则与入库前抽检 | 实战篇 |
| 分块 | 块过大混杂主题,过小切断语义 | 搜到但答非所问,或根本搜不到 | 调整块大小与重叠窗口,按结构切分 | 本章 5.2 与实战篇 |
| 向量化 | 模型不匹配中文场景,索引与查询模型不一致 | 召回率整体偏低,检索像碰运气 | 换中文优化模型并全库重建 | 第 3 章 3.5 节 |
| 检索 | 纯向量检索漏掉专有名词与编号 | 关键词精确命中的内容缺失 | 引入 BM25 的混合检索 | 本章 5.3 与实战篇 |
| 重排序 | 缺少重排,或初筛候选数不足 | 跑偏内容挤进上下文,带偏答案 | 加重排序器,扩大初筛候选量 | 本章 5.3 |
| 上下文组装 | 塞得太多超出预算,顺序不当 | 关键材料被淹没,模型抓不住重点 | 控制分块数量与摆放顺序 | 本章 5.3 |
| 生成 | 模型脱离材料自由发挥 | 幻觉与编造,答案看似流畅实则错误 | 提示词约束与低温度采样 | 实战篇 |
| 引用标注 | 答案没有出处 | 无法核验,问题无法归因 | 建立分块到答案的溯源链路 | 本章 5.3 |
再强调两点。第一,瓶颈的分布并不均匀:从社区实践与我们的项目经验看,分块与解析是出问题最多的两个环节,频率远高于模型选型——新手却常常把时间花在相反的方向上。第二,优化要一次只动一个变量,并且配合评测集来观察变化;一次改三处,效果无论变好变坏,都不知道该归功于谁。
如果连清单都懒得过,还有一个更快的分诊办法:看症状。搜不到——内容明明在文档里,却从未被检索出来——多半查索引侧,重点看解析、分块与模型一致性;搜到但答错——相关分块出现在候选里,最终答案却跑偏——多半在查询侧,重点看重排序、组装顺序与生成约束。先定线,再定环节,排查效率会高得多。
5.5 本章小结与实战篇导读
本章把 RAG 系统拆成了两条流水线。索引侧六步——文档加载、格式解析、内容清洗、分块、向量化、写入向量库,决定能搜到什么;查询侧六步——问题理解与改写、检索、重排序、上下文组装、生成、引用标注,决定怎么搜、怎么答。其中,分块是最影响效果的环节,重排序是速度与准确之间取得平衡的核心手段,混合检索与引用标注是两个容易被低估、回报却很高的动作。质量瓶颈地图则给出了按环节排查问题的清单。
原理篇到此结束,地图已经画好。从下一章开始进入实战:我们会先用 100 行 Python 搭出一个能跑的最小 RAG 系统,亲手感受这条完整流水线;然后逐步替换更结实的零件——更好的解析、更聪明的分块、混合检索、重排序,以及一套能持续衡量效果的评测体系。下一章,开始写代码。
最小 RAG 系统:100 行 Python
这一章我们抛开所有框架,只用 openai SDK、numpy 和 FAISS,写出一个完整可运行的 RAG 系统。大约 100 行 Python 代码之后,检索增强生成的四个环节——分块、向量化、检索、生成——将不再是示意图上的概念,而是你亲手调试过的代码。
6.1 为什么先手写一个轮子
上一章我们在图纸上把 RAG 流水线解剖了一遍:文档被切分成块,块被转成向量,向量进入索引,用户提问检索出相关块,最后由大模型基于上下文生成回答。流程看起来不复杂,但真动手时你会发现,LangChain、LlamaIndex 这类框架把这条流程包装成了几十个类和数不清的参数。对初学者来说,这种包装反而是一层新的障碍:你不知道每个抽象背后在做什么,出了问题甚至不知道该从哪里查起,更谈不上优化。
所以在正式使用框架之前,我们先做一件看似“反效率”的事:不用任何框架,用最基础的库手写一个完整的 RAG 系统。本章的目标非常明确——用 openai SDK、numpy 和 FAISS,在大约 100 行 Python 代码内,跑通从语料到回答的全过程。每一行代码都能读懂,每一步都能改动,这是建立“流水线手感”的最快路径。
框架是黑盒,手写一遍才能看懂任何框架的报错与优化点。当你看到框架报出“索引维度不匹配”时,手写过的人会立刻反应过来:是嵌入模型输出维度和索引的 dim 对不上;当检索效果变差时,他知道该把块大小、重叠、嵌入模型、提示词这些嫌疑对象逐个排队检查,因为这些部件都是自己亲手装上去的。只调用过框架 API 的人,遇到同样的问题只能漫无目的地搜索答案。
最小实现约 100 行,零框架依赖,但其中的概念——分块、向量化、索引、生成——全部是 RAG 领域的标准件,可以一对一迁移到任何框架。学完这一章,你再看任何框架的文档,都能把它的模块映射到这 100 行代码上,学习成本会大幅下降;将来做性能优化时,你也清楚每一处改动的代价和收益。
6.2 环境准备
先准备环境。需要 Python 3.10 及以上版本,然后安装三个库:openai 是调用模型服务的官方 SDK[8],numpy 负责向量的数值计算,faiss-cpu 是 Meta 开源的向量检索库。本章的语料规模很小,CPU 版本绰绰有余,不必安装 GPU 版本。
# 要求 Python 3.10+
pip install openai numpy faiss-cpu
# 设置 API 密钥(Linux / macOS / WSL)
export OPENAI_API_KEY="sk-your-key"
# 可选:指向任意“OpenAI 兼容”服务的端点
# 例如国内大模型提供的兼容接口,设置后 SDK 的请求会自动发往该地址
export OPENAI_BASE_URL="https://your-compatible-endpoint/v1"关键是两个环境变量。OPENAI_API_KEY 存放你的 API 密钥,SDK 会自动读取它,代码里不必也不应该出现密钥明文。如果你使用的是国内大模型提供的“OpenAI 兼容端点”——现在许多国产服务都提供与 OpenAI 协议完全一致的接口——只需再设置 OPENAI_BASE_URL 指向该服务的地址,SDK 会自动把请求转发过去,业务代码一行都不用改。需要注意的是,换用兼容服务后,要把代码里的两个模型名换成该服务实际支持的模型,例如嵌入模型可能叫 text-embedding-v3 之类的名字。
如果你使用 Windows PowerShell,对应的写法是 $env:OPENAI_API_KEY = "sk-your-key",设置 OPENAI_BASE_URL 的方式完全相同。本书的 bash 代码块一律采用 Linux/macOS 风格的 export 语法,Windows 读者请自行换算一下。
API 密钥只放进环境变量,永远不要硬编码进源码。代码一旦提交到 git 并推送,密钥就等于公开泄露,事后清理非常麻烦。如果不慎泄露,别犹豫,立刻去控制台作废旧密钥并重新生成。
6.3 示例语料:一份内置的“员工手册”
为了把注意力集中在流水线本身,本章不解析任何文件,直接在代码里内置一份小型“公司员工手册”:五个段落,分别覆盖差旅报销、年假、远程办公、保密要求和加班调休。内容纯属虚构,但条款具体、制度完整,很适合演示问答。在真实项目里,这一步的“语料”来自文档解析,那是第 7 章的事。
# 示例语料:公司员工手册,每一项是一个自然段落
# 真实项目中,这里会被“文档解析”(第 7 章)取代
HANDBOOK = [
"差旅报销标准:交通票据(经济舱/二等座)按实际发生金额报销,单次行程上限 2000 元。住宿标准按城市分级:一线城市每晚不超过 600 元,其他城市每晚不超过 400 元。餐补统一为每天 100 元。报销需附发票与出差审批单,在行程结束后 30 天内提交财务部,逾期不予受理。",
"年假制度:连续工作满 1 年不满 10 年的员工,享有 5 天带薪年假;满 10 年不满 20 年的,享有 10 天;满 20 年的,享有 15 天。年假应在自然年度内休完,特殊情况经部门负责人批准,最多可结转 5 天至次年。未休年假不折现。",
"远程办公规定:员工每周最多可申请 2 天远程办公,需提前至少 1 个工作日在 OA 系统提交申请,并经直属主管批准。远程办公期间须保证核心工作时间 10:00 至 16:00 在线,并保持即时通讯工具畅通。试用期员工及涉密岗位员工不适用远程办公。",
"保密要求:员工入职时须签署保密协议。保密信息包括但不限于客户名单、技术设计文档、定价策略与未公开的财务数据。涉密文档必须存放于公司加密盘,严禁通过外部个人邮箱或个人网盘传输。离职后保密义务持续 2 年。",
"加班与调休:加班须提前申请并经主管批准。工作日加班按 1:1 兑换调休,休息日加班按 1:1.5 兑换,法定节假日加班按 1:3 兑换。调休有效期为 3 个月,自产生之日起计算,逾期自动作废。",
]有两点值得说明。第一,每一项都是语义完整的段落,长度在一两百字之间,这是“天然块”的理想形态,后面的分块逻辑因此可以非常简单。第二,我们故意只写了五个主题,这样在 6.9 节就能提一个“健身房补贴”之类语料外的问题,检验系统会不会拒答。对 RAG 系统来说,知道自己“不知道”的能力,和答对的能力同样重要。
6.4 第一步:分块
分块是流水线的第一道工序。为什么要分块?一方面,嵌入模型对单次输入的长度有限制,整本手册塞不进去;另一方面,块太大让检索失去精度——你问年假,却召回整本手册,等于没检索。最简策略是:以段落为天然边界,短段落直接作为一块;段落超过长度上限时按长度硬切,并让相邻块之间保留一小段重叠文本。
def chunk_text(paragraphs, max_len=200, overlap=40):
"""最简分块器:
- 短段落直接作为一块;
- 长段落按长度滑窗切分,相邻块之间保留重叠。
"""
chunks = []
for para in paragraphs:
para = para.strip()
if not para:
continue
if len(para) <= max_len: # 短段落:直接成块
chunks.append(para)
continue
start = 0 # 长段落:滑窗切分
while start < len(para):
end = min(start + max_len, len(para))
chunks.append(para[start:end])
if end == len(para):
break
start = end - overlap # 回退 overlap,衔接语义
return chunks两个参数值得记住:max_len=200 控制块的上限,overlap=40 让每个块“继承”上一块的末尾 40 个字。这样即使某个条款恰好被切在边界上,也能在下一个块里被完整召回。这种“纯长度切分”当然粗糙——它可能把一句话拦腰切断——但对最小系统已经够用。第 7 章我们会把它换成尊重语义边界的递归分块器。
6.5 第二步:向量化
有了块,接下来把每个块变成向量。这里调用 OpenAI 提供的 embeddings 接口:把所有块的文本列表一次性传给 input 参数,SDK 返回 resp,其中 resp.data[i].embedding 就是第 i 条文本对应的向量,顺序与输入严格一一对应。模型选用 text-embedding-3-small,输出 1536 维向量,对本章这种玩具规模的语料,它的价格和效果都很均衡[9]。
from openai import OpenAI
client = OpenAI() # 自动读取环境变量 OPENAI_API_KEY 与 OPENAI_BASE_URL
EMBED_MODEL = "text-embedding-3-small" # 输出向量为 1536 维
def get_embeddings(texts):
"""批量获取文本向量。
input 传入列表即可批量调用,比逐条调用快得多。
resp.data[i].embedding 与 texts[i] 按顺序一一对应。
"""
resp = client.embeddings.create(model=EMBED_MODEL, input=texts)
return [item.embedding for item in resp.data]批量调用是新手容易忽略的一点。一次请求传入几十条文本,相比逐条调用能省下几十次网络往返,速度差距是数量级的。官方对单次请求的条数有上限,语料较大时在外层循环分批即可。还有一个务实的建议:embeddings 接口按 token 计费,调试期间不要对同一份语料反复重建索引,这也是第 8 章需要“持久化”的原因之一。
不要把整份语料拼成一个超长字符串去调用。单条输入有自己的长度上限,超了会报错或被截断;而且超长文本的向量是所有内容的“大杂烩平均”,指向不了任何具体条款。先分块、再向量化,这个顺序不能颠倒。
6.6 第三步:用 FAISS 建索引与检索
向量有了,还需要一个索引结构来支持“找出最相似的 top-k”。这里选用 FAISS 里最简单的结构 IndexFlatIP:暴力内积检索,不做任何近似。语料只有十几个块,暴力检索毫秒级就能完成,而且结果严格精确;IVF、HNSW 这类近似索引要到百万级语料才值得引入,那是第 8 章的话题。
import numpy as np
import faiss
def build_index(vectors):
"""建立内积索引。
向量先做 L2 归一化,之后内积就等于余弦相似度。
"""
dim = len(vectors[0])
vectors = np.array(vectors, dtype="float32") # FAISS 要求 float32
faiss.normalize_L2(vectors) # 原地归一化
index = faiss.IndexFlatIP(dim) # IP = Inner Product 内积
index.add(vectors)
return index
def search(index, query_vec, k=3):
"""检索最相似的 top-k 个块,返回相似度分数与块编号"""
q = np.array([query_vec], dtype="float32")
faiss.normalize_L2(q) # 查询向量同样要归一化
distances, indices = index.search(q, k) # 此时 distances 就是余弦值
return distances[0], indices[0]为什么 L2 归一化之后,内积就能直接当相似度用?余弦相似度的定义是两个向量的内积除以它们模长的乘积,它衡量的是两个向量方向上的接近程度,对长度不敏感。调用 faiss.normalize_L2() 之后,每个向量的模长都变成 1,公式的分母退化为 1,余弦相似度就等于内积本身。所以“先归一化、再用 IndexFlatIP”等价于“按余弦相似度排序”,我们一行余弦计算都不用写,还白拿了 FAISS 高度优化的内积检索实现。
三个细节提醒。第一,FAISS 只接受 float32 类型,所以在 np.array 时就要统一指定 dtype,否则 add 会直接报错。第二,归一化是原地修改,向量原始数值会被覆盖,如果后面还想用原始向量,要先备份。第三,查询向量同样要做归一化,否则算出来的分数不再是余弦值,分数之间也没有可比性。检索返回的 distances 此时就是余弦值,越接近 1 越相似;indices 是块编号,凭它就能取回原文。
6.7 第四步:生成
最后一道工序:把检索到的块和用户问题一起塞进大模型的提示词。这里的核心设计不在代码,而在系统提示词:明确告诉模型“只依据参考上下文回答,找不到就说找不到”。这是 RAG 抑制幻觉的最重要一道防线——没有这句话,模型会非常乐意地用它的常识编造出一个看似合理的答案。
CHAT_MODEL = "gpt-4o-mini"
SYSTEM_PROMPT = """你是企业内部的员工服务助手,请严格依据下面的“参考上下文”回答问题。
规则:
1. 上下文中能找到答案时,简洁作答并引用相关条款;
2. 上下文中找不到答案时,直接回复“员工手册中未找到相关规定”;
3. 不要使用上下文之外的任何知识,不要编造。"""
def generate(question, context):
"""组装提示词并调用对话补全接口"""
resp = client.chat.completions.create(
model=CHAT_MODEL,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user",
"content": f"参考上下文:\n{context}\n\n员工提问:{question}"},
],
temperature=0, # 事实问答场景,把稳定性拉满
)
return resp.choices[0].message.content接口这里用的是 client.chat.completions.create()。值得一提的是:openai SDK 3.x 引入了 client.responses.create(),这是官方新的首选 API,支持更丰富的内置能力;但官方同时承诺 chat.completions 会被无限期支持。考虑到各类兼容服务的适配情况以及读者的熟悉程度,本书示例统一使用 chat.completions,你了解有这回事即可。temperature 设为 0,事实问答要的是稳定、可复现的输出,不需要模型自由发挥。
把“拒答”写成系统提示词里的显式规则,甚至直接规定拒答的话术,能让系统行为可预测、可测试。进一步还可以要求模型在回答时标注引用了哪一段上下文,既方便用户核对,也方便你调试检索质量。
6.8 完整代码清单:minimal_rag.py
现在把四个环节拼装成一个文件。下面就是本章的完整清单 minimal_rag.py,约 100 行,包含串起整条流水线的 ask() 函数,以及两个示例问题——一个语料内可以回答,一个语料外应当拒答。建议你亲手敲一遍并运行它。
"""minimal_rag.py —— 最小 RAG 系统
依赖安装:pip install openai numpy faiss-cpu(Python 3.10+)
"""
import numpy as np
import faiss
from openai import OpenAI
client = OpenAI() # 自动读取 OPENAI_API_KEY / OPENAI_BASE_URL
EMBED_MODEL = "text-embedding-3-small" # 嵌入模型,输出 1536 维
CHAT_MODEL = "gpt-4o-mini" # 生成模型,可换成任意兼容模型
# ---------- 示例语料:公司员工手册 ----------
HANDBOOK = [
"差旅报销标准:交通票据(经济舱/二等座)按实际发生金额报销,单次行程上限 2000 元。住宿标准按城市分级:一线城市每晚不超过 600 元,其他城市每晚不超过 400 元。餐补统一为每天 100 元。报销需附发票与出差审批单,在行程结束后 30 天内提交财务部,逾期不予受理。",
"年假制度:连续工作满 1 年不满 10 年的员工,享有 5 天带薪年假;满 10 年不满 20 年的,享有 10 天;满 20 年的,享有 15 天。年假应在自然年度内休完,特殊情况经部门负责人批准,最多可结转 5 天至次年。未休年假不折现。",
"远程办公规定:员工每周最多可申请 2 天远程办公,需提前至少 1 个工作日在 OA 系统提交申请,并经直属主管批准。远程办公期间须保证核心工作时间 10:00 至 16:00 在线,并保持即时通讯工具畅通。试用期员工及涉密岗位员工不适用远程办公。",
"保密要求:员工入职时须签署保密协议。保密信息包括但不限于客户名单、技术设计文档、定价策略与未公开的财务数据。涉密文档必须存放于公司加密盘,严禁通过外部个人邮箱或个人网盘传输。离职后保密义务持续 2 年。",
"加班与调休:加班须提前申请并经主管批准。工作日加班按 1:1 兑换调休,休息日加班按 1:1.5 兑换,法定节假日加班按 1:3 兑换。调休有效期为 3 个月,自产生之日起计算,逾期自动作废。",
]
# ---------- 第一步:分块 ----------
def chunk_text(paragraphs, max_len=200, overlap=40):
chunks = []
for para in paragraphs:
para = para.strip()
if not para:
continue
if len(para) <= max_len: # 短段落直接成块
chunks.append(para)
continue
start = 0 # 长段落滑窗切分
while start < len(para):
end = min(start + max_len, len(para))
chunks.append(para[start:end])
if end == len(para):
break
start = end - overlap # 回退重叠,衔接语义
return chunks
# ---------- 第二步:向量化 ----------
def get_embeddings(texts):
resp = client.embeddings.create(model=EMBED_MODEL, input=texts)
return [item.embedding for item in resp.data]
# ---------- 第三步:建索引与检索 ----------
def build_index(vectors):
vectors = np.array(vectors, dtype="float32")
faiss.normalize_L2(vectors) # 归一化后,内积即余弦相似度
index = faiss.IndexFlatIP(len(vectors[0]))
index.add(vectors)
return index
def search(index, query_vec, k=3):
q = np.array([query_vec], dtype="float32")
faiss.normalize_L2(q) # 查询向量也要归一化
distances, indices = index.search(q, k)
return distances[0], indices[0]
# ---------- 第四步:生成 ----------
SYSTEM_PROMPT = """你是企业内部的员工服务助手,请严格依据“参考上下文”回答。
上下文中能找到答案时,简洁作答并引用相关条款;
找不到答案时,直接回复“员工手册中未找到相关规定”,不要编造。"""
def generate(question, context):
resp = client.chat.completions.create(
model=CHAT_MODEL,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user",
"content": f"参考上下文:\n{context}\n\n员工提问:{question}"},
],
temperature=0,
)
return resp.choices[0].message.content
# ---------- 组装:一个 ask() 串起整条流水线 ----------
def ask(question, index, chunks, k=3):
scores, ids = search(index, get_embeddings([question])[0], k=k)
context = "\n\n".join(chunks[i] for i in ids if i >= 0)
return generate(question, context)
if __name__ == "__main__":
chunks = chunk_text(HANDBOOK) # 1. 分块
index = build_index(get_embeddings(chunks)) # 2. 向量化并建索引
for q in ["出差住一线城市的酒店,报销标准是什么?",
"公司的健身房会员补贴政策是什么?"]:
print("=" * 20, "提问:", q)
print(ask(q, index, chunks))顺着数据流读这份代码最清晰:HANDBOOK 经 chunk_text 切成块,get_embeddings 把块变成向量,build_index 建好索引;ask 接收问题后,先把问题向量化,再检索出 top-k 个块编号,取出原文拼成上下文,交给 generate 生成回答。对外只暴露一个 ask 函数,想换语料、换模型、改 top-k,都是局部改动,不碰主流程。这就是“最小系统”的价值:结构一目了然,扩展点清清楚楚。
6.9 运行效果与最小系统的五个局限
6.9.1 运行起来是什么样
把代码保存为 minimal_rag.py 并运行 python minimal_rag.py,你会看到大致如下的输出。第一个问题“出差住一线城市的酒店,报销标准是什么?”,模型的回答大意是:出差到一线城市,住宿按实际发生金额报销,每晚不超过 600 元,报销时需附发票与出差审批单,并在行程结束后 30 天内提交财务部。答案完全来自语料条款,没有自由发挥;把问题换成年假或远程办公,同样能稳定命中对应段落。
第二个问题“公司的健身房会员补贴政策是什么?”才是重头戏:语料里根本没有健身房补贴,模型会按照系统提示词的约定,返回“员工手册中未找到相关规定”。这是最小系统最有价值的时刻——它知道自己“不知道”。一个会编造答案的知识库,比没有知识库更危险,因为用户无法分辨哪句是真、哪句是假。
6.9.2 最小系统的五个局限
当然,这 100 行只是系统的骨架,距离生产可用还很远。我们诚实列出五个局限,它们各自对应后续章节的工作,也正好构成本书后面的路线图。
| 局限 | 具体表现 | 后续解决方案 |
|---|---|---|
| 无持久化 | 索引全部在内存里,程序退出即消失;每次运行都要对全部块重新调用 embeddings 接口,又慢又花钱 | 第 8 章的向量数据库:索引落盘、增量更新 |
| 无文档解析 | 语料是手写的 Python 列表,真实的 PDF、Word、网页都处理不了 | 第 7 章的文档解析工具选型 |
| 分块粗糙 | 纯长度硬切,可能把句子拦腰切断,谈不上语义边界 | 第 7 章的递归分块器与元数据 |
| 无重排 | 向量检索的 top-k 直接进提示词,结果可能含噪声,也可能漏掉真正相关的内容 | 后续章节的混合检索与重排技术 |
| 无评估 | 回答不了“检索质量到底多好”这个问题,调参全凭感觉 | 第 16 章的评估体系 |
下一章我们先解决表中的“无文档解析”和“分块粗糙”两项:如何把 PDF、Word 等文件干净地变成文本,如何把长文本切出带语义边界、带元数据的块。再往后,我们会把这些块和向量放进真正的向量数据库,让知识库可以保存、可以更新、可以长大。
文档解析与分块实战
上一章的语料之所以能直接写进代码,是因为我们刻意绕开了真实项目里最麻烦的部分——文档解析。这一章正面解决它:如何把 PDF、Word 等文件变成干净可用的文本,以及如何把长文本切成适合检索的块。这两件事共同决定了整个知识库的质量上限。
7.1 为什么解析是第一道坎
上一章的最小系统里,语料是一份手写的 Python 列表,干净又规整。但真实项目的知识是以 PDF 合同、Word 制度文档、PPT 培训材料甚至扫描件的形式存在的。这些文件必须先变成纯文本,才能进入分块与向量化环节,这一步就是文档解析。它是整条 RAG 流水线的第一道坎,也是最容易被低估的一道坎。
解析之所以难,根源在于 PDF 这类格式是“呈现格式”而不是“结构格式”:文件里只记录了“哪个字符画在哪页哪个坐标”,完全不记录哪部分是标题、哪部分是正文、阅读顺序是什么。于是双栏排版的论文,提取出来常常是左右两栏交错的乱序文本;页眉、页脚、页码会混进正文,假装自己是合法内容;表格被拍扁成错位的文字碎片,数字和表头对不上号;至于扫描件 PDF,每一页本质上是一张图片,不做 OCR 连一个字符都提不出来。这就是“垃圾进,垃圾出”:解析环节混进来的任何脏东西,都会顺着流水线一路向下——分块切错文本,向量化编码错语义,检索自然只能召回垃圾。
所以本章解决两件事:第一,如何按文档格式选对工具,尽可能干净地把文本提取出来;第二,如何把提取出的长文本切成适合检索的块。前者决定“有没有米下锅”,后者决定“这锅饭怎么煮”。两件事都做扎实,后面的向量化与生成才有意义。
判断解析质量是否过关,标准也很朴素:随机抽几页提取结果,用肉眼对照原文读一遍。阅读顺序对不对、表格有没有错位、页眉页脚有没有混进来,几分钟就能看出个大概。这个检查动作要养成习惯,后面每换一批语料、每换一次工具,都值得重做一遍。
7.2 常用解析工具盘点
Python 生态里的解析工具相当丰富,选型不必追新,按语料格式对号入座即可。下表列出四类最常用的工具,以及各自的一句话建议。
| 工具 | 特点 | 适用场景 | 一句话建议 |
|---|---|---|---|
pypdf | 纯 Python 实现,轻量快速,无系统依赖 | 文字版(原生)PDF,版式简单干净 | PDF 解析的起点,能搞定它就省大力气 |
python-docx | 读取 docx 的段落、标题层级与表格 | Word 制度文档、合同、报告 | 能顺带拿到标题结构,天然适合记录元数据 |
unstructured | 一个接口处理 PDF、Word、HTML、PPT、邮件等,输出按类型划分的元素 | 多种格式混合的语料库 | 一套 API 通吃所有格式,元素分类便于精细处理 |
| MinerU / Docling | 版面理解能力强,可识别标题、表格、公式与阅读顺序 | 复杂版式的论文、教材、财报、标书 | 复杂版面效果最好,RAGFlow 等平台已集成,可直接使用 |
选型建议很简单:先盘点你的语料格式。全是文字版 PDF,就从 pypdf 开始,效果能接受就别折腾;Word 为主,python-docx 是不二之选;格式混杂,用 unstructured 做统一入口最省心;如果存在大量双栏、表格、公式等复杂版面,直接上 MinerU、Docling 这类版面理解工具,许多 RAG 平台已经内置了它们,不必自己从零集成。三个基础库的安装一条命令即可:
pip install pypdf python-docx unstructured先看 pypdf。它的 API 非常直白:PdfReader 打开文件,遍历 reader.pages,每一页调用 extract_text() 就能拿到该页文本。下面的例子把整份 PDF 逐页提取后拼成完整文本。
from pypdf import PdfReader
reader = PdfReader("employee_handbook.pdf")
pages = []
for page in reader.pages:
# extract_text() 返回该页全部文本(注意:页眉页脚也会一并混入)
pages.append(page.extract_text() or "")
full_text = "\n".join(pages)
print(f"共提取 {len(reader.pages)} 页,{len(full_text)} 字")再看 python-docx。Word 文件比 PDF“好相处”得多,因为 docx 本身就是结构化文档:段落就是段落,标题就是标题。遍历 doc.paragraphs 能拿到所有段落,而 para.style.name 会告诉你这一段是“Heading 1”还是“Normal”,这个信息稍后记录元数据时非常有用。
from docx import Document
doc = Document("employee_handbook.docx")
for para in doc.paragraphs:
text = para.text.strip()
if not text:
continue
# style.name 形如 "Heading 1" / "Normal",可用于判断标题层级
print(f"[{para.style.name}] {text}")两点诚实的提醒。第一,extract_text() 不是万能的:缺空格、双栏交错、生僻字乱码都可能出现,一旦发现提取出的文本阅读顺序明显不对,果断换更强的工具,不要硬撑。第二,解析结果一定要抽查——随机抽几页肉眼核对。解析质量问题在这一步最容易发现、修复成本也最低;等到索引建好、问答上线才发现,成本要翻几十倍。
解析时不妨把中间文本结果落盘保存,例如每份文件对应一个 txt 或 jsonl。后续调试分块与向量化时不必反复解析源文件,人工抽查、版本对比也方便得多。
7.3 四种常用分块策略
拿到长文本之后,下一步就是分块。第 6 章我们用了最粗糙的“按长度硬切”,这一节系统梳理业界常用的四种策略。理解各自的原理与边界,你才能在面对自己的语料时,说清楚为什么选这种而不选那种。
7.3.1 固定长度分块
最简单的策略:设定一个长度阈值,按固定字数切分,通常在相邻块之间再加一段重叠来缓解语义切断。优点显而易见:实现只要十几行代码,块的数量完全可预测,方便估算存储与成本。缺点同样明显:完全无视语言边界,一句话可能被拦腰切断,一个条款的主语在上块、谓语在下块。它适合没有格式可言的纯文本数据,或者作为快速验证的基线方案,不建议直接用于正式知识库。
7.3.2 递归字符分块
这是对固定长度的直接改进:先尝试用粗粒度分隔符(段落换行)切分,把切出的小片段合并到接近目标长度;如果某个片段仍然太长,就换更细的分隔符(单换行、句号、问号、感叹号,直到空格)递归处理。这样得到的块尽量是完整段落,不行就退到完整句子,再不行才退到词级别。LangChain 的 RecursiveCharacterTextSplitter 就是这个思路,它也是本书推荐的通用默认策略,下一节我们亲手实现一个。
7.3.3 语义分块
前两种策略看的都是“文本表面”,语义分块看的是“内容含义”:先把长文本按句子切开,调用嵌入模型算出每个句子的向量,再依次比较相邻句子的余弦相似度——相似度持续较高说明话题连贯,不切;在某处骤降,说明话题发生了切换,就在那里切开。这样每个块的主题相对统一,理论上检索精度最高。代价也很直接:每个句子都要调用一次向量化接口,费用与延迟显著上升,而且“骤降”的阈值需要针对语料调参。它适合高价值、主题混杂的长文本,不适合一上来就用。
7.3.4 QA 对分块
如果语料本身就是 FAQ——客服知识库、产品问答集——那么最好的分块方式是不切:每一组“问题 + 答案”天然就是一块。检索时,用户的提问与块中的问题高度相似,极易命中;命中之后答案完整无缺,不存在被切断的风险。实践中可以把问题和答案拼接后整体向量化,也可以只对问题向量化、把答案作为返回内容,具体取决于你的问法与答案的写法差异有多大。
| 策略 | 原理 | 适用文档 | 优点 | 缺点 |
|---|---|---|---|---|
| 固定长度 | 按固定字数直接切分,可加重叠 | 无格式纯文本、快速基线 | 实现简单,块数可预测 | 无视语义边界,常切断句子 |
| 递归字符 | 按“段落→句子→词”层级回退切分,超长则递归 | 绝大多数结构化文档 | 尽量保留自然边界,通用效果好 | 分隔符列表需按语料微调 |
| 语义分块 | 比较相邻句向量相似度,在骤降处切开 | 主题混杂的高价值长文本 | 块内主题统一,检索精度高 | 每句都要向量化,成本高,阈值难调 |
| QA 对分块 | 一问一答作为一块 | FAQ、客服知识库 | 提问易命中,答案完整 | 只适用于问答结构的语料 |
一句话总结选型:通用文档从递归字符分块起步,FAQ 直接用 QA 对,语义分块留作有了评估能力之后的精调手段,固定长度只用来做基线对照。
7.4 手写递归分块器
这一节我们不依赖任何框架,实现一个通用的递归分块器。核心思路四句话:找到能把文本切开的最粗分隔符;把切出的小片段合并成接近 chunk_size 的块;单个片段仍然太长时,换更细的分隔符递归处理;让后一块的开头继承前一块的结尾,形成重叠。代码稍长,但每一步都有注释,建议对照思路逐段阅读。
def split_text(text, chunk_size=400, overlap=60,
separators=["\n\n", "\n", "。", "?", "!", " "]):
"""递归分块器。
1. 找到能把文本切开的最粗分隔符;
2. 把小片段合并成接近 chunk_size 的块;
3. 单个片段仍超长时,用更细的分隔符递归;
4. 后一块开头复制前一块结尾的重叠字符,避免语义被切断。
"""
text = text.strip()
if not text:
return []
if len(text) <= chunk_size: # 文本本身不长,直接作为一块
return [text]
# 找到第一个在文本中出现的分隔符(按从粗到细的顺序),
# 同时记下剩余更细的分隔符,供递归使用
sep, remaining = "", []
for i, s in enumerate(separators):
if s in text:
sep, remaining = s, separators[i + 1:]
break
if not sep: # 完全没有分隔符,只能按长度硬切
step = max(1, chunk_size - overlap)
return [text[i:i + chunk_size] for i in range(0, len(text), step)]
# 切分后把分隔符补回每个片段末尾,避免丢失“。”等标点
pieces = text.split(sep)
pieces = [p + sep for p in pieces[:-1]] + [pieces[-1]]
chunks, buf = [], ""
for piece in pieces:
if len(buf) + len(piece) <= chunk_size:
buf += piece # 当前块还装得下,继续合并
continue
if buf.strip():
chunks.append(buf.strip()) # 当前块装满,落袋为安
if len(piece) > chunk_size:
# 单个片段仍然超长:交给更细的分隔符递归处理
chunks.extend(split_text(piece, chunk_size, overlap, remaining))
buf = ""
else:
# 新块开头继承上一块的结尾,形成重叠
tail = chunks[-1][-overlap:] if overlap > 0 and chunks else ""
buf = tail + piece
if buf.strip():
chunks.append(buf.strip())
return [c for c in chunks if c]几个值得品味的细节。第一,separators 是有序的,从段落换行到空格由粗到细,循环只挑第一个“在文本中出现”的分隔符,保证块边界尽量落在有意义的位置。第二,切分后把分隔符补回每个片段末尾,否则所有句号都会凭空消失,句子边界信息就丢了。第三,当文本完全不含任何分隔符时(比如一整串无空格长文),退化为按长度硬切,步长取 chunk_size 减去 overlap,保证极端情况下依然有重叠。
用法和第 6 章一样直白:读入长文本,调用 split_text,检查输出的块数与每块长度是否符合预期。
raw = open("employee_handbook.txt", encoding="utf-8").read()
chunks = split_text(raw, chunk_size=400, overlap=60)
print(f"共切出 {len(chunks)} 块")
for i, c in enumerate(chunks[:3]):
print(f"块 {i}:{len(c)} 字,开头 30 字:{c[:30]}")为什么需要重叠(overlap)?块边界是检索最脆弱的地方:某条报销标准可能一半落在上一块、一半落在下一块,用户的问题恰好命中边界时,只能检索到半条信息,模型自然只能给出半个答案。让每个块在开头多带上一块结尾的重叠字符,就能保证“边界信息”至少在某一个块里完整出现,大幅减少这种“切坏运气”。经验上,重叠取 chunk_size 的 10%–20% 比较合适:太少起不到兜底作用,太多则浪费存储与 token 开销,还可能让同一条款被重复检索、挤占 top-k 名额。
这个分块器已经能应付大多数文本文档。它当然不完美,比如重叠按字符数复制,可能从半句话开始;更精细的处理可以在此基础上继续打磨,例如让重叠从最近的句子边界对齐。但骨架就是这一个骨架,理解它之后,你看任何框架的分块器源码都不会陌生。
7.5 块大小怎么选
chunk_size 是对检索质量影响最直观的超参数,也是最常被问到的问题。我们先看两个极端,再给可操作的起点。
块太大,一个块里塞进三五个主题。用户的问题只与其中两三句有关,却把整块都检索了回来:一方面,无关内容成为上下文里的噪音,会分散生成模型的注意力,甚至带偏答案;另一方面,块的向量是多个主题的“平均”,与任何单一问题的相似度都不够高,可能根本挤不进 top-k。此外,大块还会线性推高每次问答的 token 成本。
块太小,语义不完整。“年假天数”和“年假结转规则”被拆进两个块,top-k 可能只召回其中一个,模型只能回答半个问题;同时块的数量爆炸,索引体积、检索耗时、重排成本都会随之上升,管理难度也变大。
中文场景下的实践给出一个起步区间:200–800 字。FAQ 式的短内容可以偏小,长篇论述与制度文档可以偏大;重叠按 10%–20% 配置。这个区间不能保证最优,但能保证错得不太离谱,给后续调参留出一个合理的搜索空间。
正确的姿势是:先用经验区间内的一组参数(例如 400 字加 60 字重叠)把整条流水线跑通,让系统先可用;再用评估集对 chunk_size 与 overlap 的组合做网格搜索,让每次调整都有指标背书。这种“先跑通、再调优”的做法,比一开始就猜测最优值高效得多,而且结论可复现、可交接。
不要迷信“512 最佳”之类的魔法数字。最优块大小同时取决于文档类型、提问风格、嵌入模型,甚至下游生成模型的上下文窗口——top-k 个块的总长度必须能塞进生成模型的窗口并留有余量。唯一可信的裁判是你自己的评估集,怎么建、怎么用,见第 16 章。
7.6 元数据的价值
分块器输出的块,不应该只是一段光秃秃的文本。给每个块附带一份元数据,至少记录三项:source(来源文件)、page(页码)、heading(所属的最近标题)。这是一个几乎免费、却极其值钱的习惯。
元数据至少有三个用途。其一是过滤:知识库长大后,你常常需要“只在某个目录或某年的文档里检索”,凭 source 与日期元数据就能在向量检索前先缩小范围,既提速又减少噪音。其二是引用展示:在回答末尾附上“来源:员工手册.pdf,第 3 页”,用户才敢相信答案、才能去核对,这是企业级知识库的标准配置。其三是辅助检索:把 heading 拼在块文本的开头再向量化,能让向量“知道”这段内容属于哪个章节,对标题结构清晰的语料往往能显著提升命中率。
下面演示如何在解析时就把元数据记录下来。为了示例可直接运行,这里用结构规整的 docx:遇到标题段落就更新“当前标题”,遇到正文段落就把当前标题与来源文件附在它身上;随后对每段文本调用 split_text,元数据随块一起流转。
from docx import Document
def parse_docx_with_meta(path):
"""读取 docx,同时记录每个段落的元数据:
heading = 最近的上级标题,source = 来源文件
"""
doc = Document(path)
heading = ""
blocks = []
for para in doc.paragraphs:
text = para.text.strip()
if not text:
continue
if para.style.name.startswith("Heading"):
heading = text # 更新当前标题;标题本身不进正文块
continue
blocks.append({"text": text, "heading": heading, "source": path})
return blocks
# 对每段文本分块,元数据随块流转
chunks_with_meta = []
for block in parse_docx_with_meta("employee_handbook.docx"):
for piece in split_text(block["text"], chunk_size=400, overlap=60):
chunks_with_meta.append({
"text": piece,
"source": block["source"],
"heading": block["heading"],
})最终进入索引的每个块,形状大致如下:text 用于向量化与生成,其余字段用于过滤与引用。页码 page 的获取依赖解析工具的能力,pypdf 按页提取时天然知道页码,docx 则通常以标题代替。
{
"text": "连续工作满 1 年不满 10 年的员工,享有 5 天带薪年假……",
"source": "docs/employee_handbook.docx",
"page": 3,
"heading": "年假制度"
}元数据与向量一定要配套存储,这一点在下一章会得到呼应:Chroma 与 Milvus 都原生支持“随向量存元数据、按元数据做过滤”。如果现在图省事把元数据丢掉,将来想补回来,就得把整个索引推倒重建。
7.7 本章小结
总结一下本章的要点。文档解析是 RAG 流水线的第一道坎:PDF 是呈现格式而非结构格式,双栏、页眉页脚、表格、扫描件都可能污染提取结果,垃圾进必然垃圾出。工具选型看格式:简单文字版 PDF 从 pypdf 起步,Word 用 python-docx,混合格式交给 unstructured,复杂版面交给 MinerU 与 Docling。分块策略有四种:固定长度、递归字符、语义分块、QA 对,通用文档从递归字符起步,我们手写的 split_text 就是它的完整实现。块大小从 200–800 字的经验区间出发,重叠取 10%–20%,最终答案由评估说了算。元数据是免费宝藏:source、page、heading 都要记,过滤、引用、辅助检索全靠它。
至此,我们手里有了一批带元数据的块。下一章把它们放进真正的向量数据库——从 FAISS 的纯库形态,到 Chroma 的开箱即用,再到 Milvus 的分布式生产形态,看看生产环境该如何选型。
向量数据库实战:FAISS、Chroma 与 Milvus
上一章我们把文档切成了分块,并用嵌入模型得到了每个分块的向量。这些向量放在哪里、怎么在毫秒级时间里找出与问题最相似的几条,就是本章要解决的问题。我们挑选 FAISS、Chroma、Milvus 三个代表产品,完整走一遍建索引、写入、查询与持久化的流程,最后给出一张可以直接带走的选型决策表。
8.1 为什么挑这三个
上一章我们得到了两样东西:一份文档分块的文本列表,以及对应的 1536 维向量。最朴素的做法是把向量存进一个 NumPy 数组,查询时循环计算一遍相似度再排序——数据只有几万条时这么做完全可行,但到了百万、千万级,逐条暴力计算就不可接受了。向量数据库解决的正是这个问题:把向量组织成专门的索引结构,给定一个查询向量,快速找出相似度最高的前 k 条。
市面上的向量数据库产品有几十个,乍看眼花缭乱,但按部署形态可以归成三类。第一类是嵌入式库:没有独立的服务进程,直接作为依赖库在你的程序里调用,代表是 FAISS;第二类是轻量本地数据库:单进程运行在本机,自带磁盘持久化和元数据管理,开箱即用,代表是 Chroma;第三类是分布式生产数据库:集群部署,支撑亿级以上数据与复杂的过滤条件,代表是 Milvus。本章会用同一批向量,把这三类产品各跑一遍,让你直观感受它们的差异。
为什么挑这三个,而不是别的?原因很简单:它们恰好落在“规模与复杂度”光谱的三个关键位置上,覆盖了你从原型验证到生产上线几乎必然经历的三种形态。学完这一章,你得到的不只是三个产品的用法,而是一套选型思路——数据量多大、有没有人专职运维、项目处于什么阶段,决定了该用哪种形态。更重要的是,三者的使用模式高度一致,都是“准备向量、建索引、写入、top-k 查询”这一套流程,这个模式在 Qdrant、Weaviate、pgvector 等其他产品上同样成立,学会一个就能迁移到其余。
为了让注意力集中在数据库本身,本章不调用真实的嵌入模型,而是用 NumPy 模拟 1536 维的随机向量。在你的真实项目里,只需把向量来源替换为嵌入模型的输出,其余代码完全一致。三个产品都需要先安装依赖:FAISS 用 pip install faiss-cpu,Chroma 用 pip install chromadb,Milvus 的 Python SDK 用 pip install pymilvus。
8.2 FAISS 实战
FAISS(Facebook AI Similarity Search)是 Meta 开源的向量相似度搜索库。严格来说它不是“数据库”而是“库”:没有服务进程、没有网络接口、没有权限管理,就是一套跑在你自己进程里的高性能索引结构与搜索算法。它的底层用 C++ 实现,Python 接口只是一层薄封装,因此搜索性能极高,许多商业向量数据库的底层都借鉴或直接使用了它。项目由 Meta 团队持续维护,版本一直在稳定迭代[12]。
8.2.1 Flat 索引:先跑起来
思路:最简单的索引是 Flat,即不做任何加速结构,查询向量与库中所有向量逐一比较。它的结果是绝对精确的,适合当基线,也适合中小规模数据。FAISS 用内积或 L2 距离衡量相似度,而知识库场景我们通常想要余弦相似度。技巧是:先把所有向量做 L2 归一化,归一化之后的内积在数值上就等于余弦相似度,于是直接用内积索引 IndexFlatIP 即可。
import faiss
import numpy as np
dim = 1536 # 向量维度,与嵌入模型输出保持一致
# 模拟 10000 条分块向量(真实项目中来自嵌入模型)
np.random.seed(42)
corpus = np.random.random((10000, dim)).astype("float32")
# L2 归一化:归一化后,内积就等于余弦相似度
faiss.normalize_L2(corpus)
# 建内积 Flat 索引,并写入全部向量
index = faiss.IndexFlatIP(dim)
index.add(corpus)
print("索引中向量总数:", index.ntotal)
# 查询:找出与 q 最相似的前 5 条
q = np.random.random((1, dim)).astype("float32")
faiss.normalize_L2(q) # 查询向量同样要归一化
distances, indices = index.search(q, 5)
print("命中位置:", indices)
print("相似度得分:", distances)
这段代码有几个要点。第一,FAISS 只接受 float32 类型的数组,其他类型会直接报错,所以 astype("float32") 不能省。第二,normalize_L2 是原地修改,归一化后每个向量长度变为 1,内积得分落在 -1 到 1 之间,越大越相似。第三,search 返回两个数组:distances 是相似度得分,indices 是命中向量在语料中的“位置下标”。注意这个下标不是业务 id,你必须自己维护“下标到原始文本”的映射——这是嵌入式库换来的自由,也是要付出的成本。
8.2.2 IVFFlat:用倒排文件换速度
思路:Flat 索引在千万级数据上逐条比较太慢。FAISS 常用的加速手段是 倒排文件索引(Inverted File,IVF):先把全部向量聚成 nlist 个桶,查询时先定位离查询向量最近的几个桶,只在这几个桶内部做精确比较。搜索范围从“全库”缩小到“少数几个桶”,计算量大幅下降,代价是可能漏掉落在相邻桶里的真正最近邻,得到的不再是精确解而是高质量近似解。
nlist = 100 # 聚类桶的数量
# 粗量化器:负责给每个向量分配桶,本身用 Flat 内积
quantizer = faiss.IndexFlatIP(dim)
ivf = faiss.IndexIVFFlat(quantizer, dim, nlist)
ivf.train(corpus) # IVF 索引必须先训练,学习每个桶的聚类中心
ivf.add(corpus) # 训练完成后才能写入向量
distances, indices = ivf.search(q, 5)
IVFFlat 与 Flat 有两个关键差异。其一,必须先 train 再 add:FAISS 需要先扫一遍数据学出每个桶的聚类中心,才能决定每条向量该进哪个桶,顺序写反会直接报错。其二,nlist 要随数据规模调整:桶太少起不到加速作用,桶太多则每个桶内样本太稀、召回下降,经验上让每个桶平均落在几百到几千条之间比较合适。查询时还有一个 nprobe 参数,控制每次实际搜索多少个桶:默认只搜 1 个桶,生产中通常要调大,nprobe 越大召回越高但越慢。这种“先粗筛、再精排”的思路在向量检索里非常普遍,值得记住。
8.2.3 持久化:把索引存到磁盘
思路:FAISS 的索引是纯内存结构,进程一退出就没了,必须显式保存到文件,下次启动时再加载回来。FAISS 提供了成对的写入与读取函数,一行搞定。
# 把索引结构写入磁盘文件
faiss.write_index(index, "kb.index")
# 下次启动时读回,无需重新训练、重新写入
index = faiss.read_index("kb.index")
distances, indices = index.search(q, 5)
write_index 会把索引结构与全部向量序列化为一个二进制文件,read_index 原样恢复,读回后可以直接查询。但要再次强调:文件里只有向量和索引结构,原始文本、业务 id、元数据一概不在其中。这些信息需要你另行保存,比如放进 SQLite 或一个 JSON 文件,并保证顺序与向量严格对齐。
FAISS 只管向量,不管文本与元数据。真实项目里一定要自己维护“向量位置到原始文本”的映射表,并且新增向量时保证两边顺序严格一致。一旦映射错位,查询会返回张冠李戴的内容,而且这类错误不会抛异常、日志里毫无痕迹,排查起来非常痛苦。建议在映射表里同时记录向量的写入序号,定期做一致性校验。
8.3 Chroma 实战
如果说 FAISS 是一台“发动机”,那 Chroma 就是一辆“整车”。它是一个开源的轻量向量数据库:单进程运行、数据自动落盘、原始文本与元数据随向量一起存储、原生支持过滤查询。原型阶段和中小型应用想要的功能它基本都内置了,能省下大量胶水代码。Chroma 在 2025 年发布了 1.x 稳定版本,API 已经收敛定型[10],本章的代码都基于 1.x。
8.3.1 创建集合并写入数据
思路:Chroma 用集合(collection)组织数据,类似关系数据库里的表。用 PersistentClient 指定数据目录,所有写入自动持久化到磁盘,程序重启数据还在;get_or_create_collection 在集合不存在时创建、已存在时直接复用,脚本反复执行也不会报错。集合的 metadata 里用 hnsw:space 指定距离度量为余弦。
import chromadb
# 数据持久化在 ./chroma_db 目录,程序重启后依然存在
client = chromadb.PersistentClient(path="./chroma_db")
# 创建(或复用已有的)集合,指定余弦距离
coll = client.get_or_create_collection(
"kb",
metadata={"hnsw:space": "cosine"},
)
# 写入:ids、原文、向量、元数据四个列表一一对应
coll.add(
ids=["hb-001", "hb-002", "rb-001"],
documents=[
"员工累计工作满 1 年不满 10 年的,年休假 5 天。",
"报销单需在每月 5 日前提交系统,逾期按次月处理。",
"出差打车凭行程记录可报销。",
],
embeddings=[vec1, vec2, vec3], # 嵌入模型算好的 1536 维向量
metadatas=[
{"source": "员工手册.pdf", "chapter": "休假"},
{"source": "员工手册.pdf", "chapter": "报销"},
{"source": "报销指南.pdf", "chapter": "报销"},
],
)
要点:四个列表 ids、documents、embeddings、metadatas 按下标一一对应,长度必须一致。ids 是每条记录的唯一标识,后续更新、删除都靠它;documents 存原始文本,查询时可以直接带回来,不必像 FAISS 那样另查映射表;metadatas 是字典列表,用来描述每条记录的属性,比如来源文件、所属章节、更新时间,后面做过滤查询全靠它。
8.3.2 查询与元数据过滤
思路:query 接收查询向量,返回相似度最高的前 n_results 条。返回值是一个 dict,含 ids、documents、metadatas、distances 四个键。更有用的是 where 参数:先按元数据过滤、再做向量搜索,把“属性条件”与“语义相似”组合起来。
# q_vec 是用同一嵌入模型算出的查询向量
res = coll.query(query_embeddings=[q_vec], n_results=3)
# 返回值是 dict:ids / documents / metadatas / distances
print(res["ids"]) # [['hb-001', 'rb-001', 'hb-002']]
print(res["documents"]) # [['对应原文', ...]]
print(res["metadatas"]) # [[{'source': '员工手册.pdf', ...}, ...]]
print(res["distances"]) # [[余弦距离,越小越相似]]
# 带元数据过滤:只在《员工手册》的内容里检索
res = coll.query(
query_embeddings=[q_vec],
n_results=3,
where={"source": "员工手册.pdf"},
)
这里的 distances 是余弦距离(1 减去余弦相似度),数值越小代表越相似,注意它与 FAISS 内积得分“越大越相似”的方向正好相反,跨产品迁移时要留心。where 还支持 $eq、$ne、$in、$and、$or 等操作符,可以组合出多条件过滤。这在知识库场景非常实用:比如只检索最新版本制度的内容,或者只检索当前用户有权限查看的文档。
Chroma 1.x 还有一个值得注意的 API 变化:client.list_collections() 直接返回 Collection 对象组成的列表,拿到对象就能直接访问集合信息,不必再按名字二次查询。
# Chroma 1.x:list_collections 直接返回 Collection 对象列表
for c in client.list_collections():
print(c.name)
为什么显式传入 embeddings,而不是让 Chroma 默认帮我们做嵌入?Chroma 的 add 与 query 都支持只传文本、由它内部调用默认模型计算向量,看起来更省事,但本书不推荐。理由有三:一是模型可控,显式传向量意味着你清楚每条向量出自哪个模型、哪个版本,日后升级或更换模型时有据可查;二是可复现,同一批向量可以在离线评测、批量回灌、跨环境迁移中反复使用,结果完全一致;三是一致性,本章三个产品共用同一批向量,FAISS 与 Milvus 本来就要自己传向量,Chroma 保持同样的写法,三条链路的代码结构完全对齐。默认嵌入是“能跑”,显式嵌入才是“能维护”。
8.4 Milvus 实战
当数据量到了亿级甚至十亿级、需要多机部署、需要多租户与细粒度管理时,就该分布式向量数据库登场了。Milvus 是这个领域最成熟的开源产品之一,3.x 版本持续演进[11],我们用官方 Python SDK pymilvus 来操作。与前两者相比,Milvus 的概念明显更多:要先定义表结构(schema)、创建集合、建索引,再把数据写入、加载进内存,最后才能查询——更像在使用一个“正经数据库”,运维与调优的空间也更大。
8.4.1 定义 schema 与创建集合
思路:Milvus 的集合类似关系数据库的表,写入前必须先声明字段。我们定义三个字段:id 为 VARCHAR 主键,text 为 VARCHAR 存原始文本,emb 为 1536 维浮点向量。集合创建后,在 emb 字段上建 HNSW 索引,距离度量选余弦。
from pymilvus import (
connections,
Collection,
CollectionSchema,
FieldSchema,
DataType,
)
# 连接 Milvus 服务(默认端口 19530)
connections.connect(uri="http://localhost:19530")
# 定义 schema:主键 id、原文 text、向量 emb 三个字段
fields = [
FieldSchema(name="id", dtype=DataType.VARCHAR,
is_primary=True, max_length=64),
FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=2048),
FieldSchema(name="emb", dtype=DataType.FLOAT_VECTOR, dim=1536),
]
schema = CollectionSchema(fields=fields, description="知识库分块表")
# 按 schema 创建集合
coll = Collection(name="kb", schema=schema)
# 在 emb 字段上建 HNSW 索引,使用余弦距离
coll.create_index(
"emb",
{
"index_type": "HNSW",
"metric_type": "COSINE",
"params": {"M": 16, "efConstruction": 200},
},
)
HNSW 是目前最主流的近似最近邻索引结构,它用分层图组织向量,查询时从顶层稀疏图逐层向下跳转,快速逼近目标邻域。建索引的两个关键参数:M 控制每个节点连接的邻居数,越大召回越好但内存占用越高,16 是常用默认值;efConstruction 控制建图时候选集大小,越大索引质量越好但建索引越慢,200 是较均衡的选择。metric_type 设为 COSINE,与前两个产品保持一致的语义。
8.4.2 写入、加载与查询
思路:索引建好后写入数据,然后把集合加载进内存,最后调用 search 做近邻搜索,并通过 output_fields 让结果把原文一并带回来。
# 写入数据:列表顺序与 schema 字段顺序一致(id、text、emb)
ids = ["hb-001", "hb-002", "rb-001"]
texts = [
"员工累计工作满 1 年不满 10 年的,年休假 5 天。",
"报销单需在每月 5 日前提交系统,逾期按次月处理。",
"出差打车凭行程记录可报销。",
]
coll.insert([ids, texts, embeddings]) # embeddings 为 1536 维向量列表
# 查询前必须先把集合加载进内存
coll.load()
# 近邻搜索:q_vec 为查询向量
res = coll.search(
data=[q_vec],
anns_field="emb",
param={"metric_type": "COSINE", "params": {"ef": 64}},
limit=3,
output_fields=["text"], # 结果中附带返回 text 字段
)
# 解析结果:每个查询向量对应一组命中
for hits in res:
for hit in hits:
print(hit.distance, hit.id, hit.entity.get("text"))
三个细节值得记住。第一,insert 接收“按列组织”的列表,列表顺序必须与 schema 字段顺序一致。第二,查询前必须先 load:Milvus 把存储与计算分离,数据默认落在磁盘上,显式加载进内存后才能搜索,这一步漏掉会直接报错。第三,output_fields 声明要随结果返回的标量字段,否则只能拿到主键和距离;搜索参数里的 ef 控制查询时的候选集大小,越大召回越好、延迟越高,可结合线上时延要求调节。
还有一项能力值得单独点名:Milvus 3.0 引入了 TEXT 字段类型与内置的 BM25 全文检索,这意味着“向量语义检索 + 关键词精确检索”的混合检索可以在同一个数据库内完成,不必再额外维护一套搜索引擎。限于篇幅本章只点到为止,第 11 章会专门展开混合检索与重排序。
8.5 三者选型决策表
三个产品跑完,我们把选型摆到台面上。下表从真实项目中最常遇到的六个维度做对比,可以作为你立项时的第一张检查清单。
| 维度 | FAISS | Chroma | Milvus |
|---|---|---|---|
| 部署形态 | 嵌入式库,跑在业务进程内 | 单进程本地数据库,也可起服务 | 分布式集群,另有轻量形态 |
| 适用规模 | 百万至千万级(受单机内存限制) | 数千至百万级 | 百万至百亿级 |
| 持久化 | write_index / read_index 手动保存 | 内置磁盘持久化,自动落盘 | 内置持久化,依赖对象存储 |
| 元数据过滤 | 不支持,需自行维护映射 | 原生 where 过滤 | 标量过滤完善,支持混合检索 |
| 运维成本 | 零运维,随应用启停 | 接近零运维 | 依赖 etcd、MinIO 等,成本较高 |
| 适合谁 | 算法工程师、离线评测、嵌入既有服务 | 原型验证、中小型应用、个人项目 | 生产系统、大规模在线服务、团队协作 |
可以看出,三者不是“谁优谁劣”的关系,而是同一条演进路径上的三个位置:项目早期用轻量工具快速验证,业务长大后再向生产级迁移,每一步的投入都与当下的规模匹配。
这种“路径式选型”的好处是什么?小项目、个人项目用 Chroma 十分钟就能上手:一条 pip 安装、三行代码建集合,数据自动落盘,完全没有运维负担,可以把全部精力放在检索效果本身。等业务长大、数据超出单机承载、需要多人共享与高可用时,再迁移到 Milvus。由于上层“算向量、写入、top-k 查询”的模式完全一致,业务代码只需替换检索层的一个适配函数,大部分逻辑可以原样保留。起步不过度设计,扩展时不被工具卡死,这就是正确的姿势。
Milvus 完整版依赖 etcd(元数据)、MinIO(对象存储)等组件,本地开发与演示环境不要直接部署完整集群,部署和运维成本都偏高。两个轻量选择:一是 milvus-lite,把 connections.connect 的 uri 换成本地文件路径即可,适合写 demo 和跑测试;二是 Docker standalone,一条命令拉起单节点版本,接近生产用法又足够轻。等到真正上生产,再规划分布式部署、监控与备份。
8.6 本章小结
本章用同一批 1536 维向量跑通了三个代表性向量数据库。FAISS 是嵌入式库,性能高、零依赖,但文本与元数据要自己管理;Chroma 是轻量本地数据库,自带持久化与元数据过滤,是原型与中小型应用的首选;Milvus 是分布式生产数据库,schema、索引、过滤、混合检索能力完整,承接大规模在线服务。把要点收拢如下:
- 想要余弦相似度:先归一化再做内积,查询向量与库内向量都要归一化;
- FAISS 的 IVF 索引必须先 train 再 add,nlist 与 nprobe 共同决定速度与召回的权衡;
- Chroma 显式传 embeddings,模型可控、结果可复现,where 过滤让“属性 + 语义”组合检索成为一行代码;
- Milvus 的标准流程是 schema、建索引、insert、load、search,3.0 起还支持库内 BM25 全文检索。
到这里,知识库的“检索”能力已经就位。下一章我们把它交给 Agent:用 LangChain 1.x 把检索封装成工具,让模型自己决定什么时候查、查什么。
注
[10] Chroma Releases:https://github.com/chroma-core/chroma/releases
[11] Milvus Releases:https://github.com/milvus-io/milvus/releases
[12] FAISS Releases:https://github.com/facebookresearch/faiss/releases
LangChain 1.x 实战
到上一章为止,知识库的检索能力已经就位,但它还只是一段“你问我查”的死代码。本章我们引入 LangChain 1.x:把知识库检索封装成工具,用 create_agent 组装一个 Agent,让模型自己决定何时检索、如何作答,并顺带讲清 1.x 的新范式、middleware 机制,以及老代码该如何迁移。
9.1 1.x 新范式
LangChain 在 2025 年 10 月发布了 1.0 正式版本[2],这是它历史上最重要的一次分水岭。在此之前,这个框架以“API 变化快、教程过期快”闻名,几乎每个小版本都在改接口;在此之后,核心 API 进入了明确的稳定承诺期,企业终于可以放心的把它用在生产环境里。1.x 的核心范式可以用一句话概括:用 create_agent 创建 Agent,用 middleware(中间件)扩展能力,底层执行引擎是 LangGraph 状态机。
对从 0.x 时代走过来的读者,需要明确几个变化。第一,LCEL 的 Runnable 接口(用管道把各组件串起来的写法)在 1.x 中依然存在、依然可以运行,但它不再是官方首选的构建方式,新代码不建议再围绕它展开。第二,create_retrieval_chain 等旧式链式组装被整体移入了独立包 langchain-classic[3],主包不再提供。第三,曾经塞满数百个第三方集成的 langchain-community 包已经归档,不再更新,新项目不应再依赖它。
为什么生态要这样拆分?LangChain 0.x 时代,核心抽象与几百个第三方集成混在同一个代码库里,带来两个顽疾:其一,任何一个第三方 SDK 的破坏性变更都可能波及核心包,发布质量难以保证,用户升级如履薄冰;其二,核心包想稳定稳定不了,想快速迭代又被庞杂的集成拖累。拆分之后,langchain 核心包只保留稳定抽象与 Agent 运行时,发布节奏放慢、兼容性承诺变硬;模型厂商集成(如 langchain-openai)与各类垂直能力独立成包,各自有独立的版本号与发布节奏,可以随时跟进上游变化。稳定核心与快速演进的集成彼此解耦,企业敢上生产、社区跟得上新模型,这正是 1.x 架构调整的根本动机。
拆分后的包格局可以用下表概括,理解这张表,你就不会再被网上新旧混杂的教程搞糊涂。
| 包名 | 定位 | 说明 |
|---|---|---|
| langchain | 核心包 | create_agent、middleware、工具抽象与 Agent 运行时 |
| langchain-openai | 官方模型集成包 | ChatOpenAI、OpenAIEmbeddings,独立版本、独立发布 |
| langchain-classic | 0.x 旧 API 兼容包 | 收纳 create_retrieval_chain 等旧链,仅供迁移过渡 |
| langchain-community | 第三方集成合集 | 已归档、不再更新,新项目不要使用 |
由此得到一个实用的判断标准:凡是教程里出现 from langchain.chains import ... 这类导入,基本可以断定是 0.x 时代的资料,对照本章内容转换思路即可。本书所有代码均基于 1.x 编写。
9.2 环境准备
安装只需要两个包:核心包与 OpenAI 集成包。
pip install langchain langchain-openai
截至本书写作时,langchain 的稳定版本为 1.3.x,langchain-openai 为 1.5.x[1]。两者都遵循语义化版本,小版本升级一般不会破坏 API,可以放心写在依赖清单里。langchain-openai 提供两个本章要用到的类:对话模型 ChatOpenAI 与向量模型 OpenAIEmbeddings。
安装完成后,用一条导入语句快速验证环境是否就绪:只要能顺利导入 create_agent 与 ChatOpenAI,说明核心包与集成包版本兼容。如果遇到导入错误,多半是只装了主包没装集成包,或者环境里混装了 0.x 与 1.x 的版本,把虚拟环境清空重装一遍通常就能解决。建议为每个项目使用独立的虚拟环境,避免依赖互相污染。
一个对国内开发者非常实用的特性是:ChatOpenAI 可以对接任何提供 OpenAI 兼容接口的服务,只需通过 base_url 参数指定平台地址,就能接入国产大模型,代码结构完全不变。
import os
from langchain_openai import ChatOpenAI
# 国产模型普遍提供 OpenAI 兼容接口,换 base_url 即可接入
llm = ChatOpenAI(
model="deepseek-chat",
base_url="https://api.deepseek.com",
api_key=os.getenv("DEEPSEEK_API_KEY"),
)
API 密钥建议放在环境变量或密钥管理服务中,不要硬编码进代码仓库。ChatOpenAI 默认从环境变量 OPENAI_API_KEY 读取密钥,使用 base_url 对接其他平台时,请显式传入 api_key,避免误读、误用。如果公司内有统一的模型网关,把 base_url 指向网关地址即可,顺便获得限流、审计与成本归集能力。
9.3 把知识库变成工具
思路:在 1.x 里,RAG 检索不再用链来拼装,而是封装成一个工具(tool)交给 Agent。我们复用第 8 章的 Chroma 知识库:写一个普通的 search_kb 函数,内部完成“查询向量化、Chroma 检索、拼接结果”,再用 langchain_core.tools 提供的 @tool 装饰器把它变成 Agent 可调用的工具。
import chromadb
from langchain_openai import OpenAIEmbeddings
from langchain_core.tools import tool
# 嵌入模型与集合复用第 8 章的成果
emb_model = OpenAIEmbeddings(model="text-embedding-3-small")
client = chromadb.PersistentClient(path="./chroma_db")
coll = client.get_or_create_collection("kb", metadata={"hnsw:space": "cosine"})
@tool
def search_kb(query: str) -> str:
"""从公司员工手册知识库检索相关内容。
当需要回答公司规章制度、年休假、报销等问题时,
先调用本工具检索,再依据检索结果作答。
"""
vec = emb_model.embed_query(query) # 把查询文本转成向量
res = coll.query(query_embeddings=[vec], n_results=3)
docs = res["documents"][0] # 取出第一个查询的文档列表
return "\n\n".join(docs) # 多个分块拼成一段文本返回
这段代码有三个细节值得注意。第一,@tool 装饰器把普通函数变成 Agent 可调用的工具,而函数的 docstring 就是工具描述——模型完全依靠这段描述来判断“什么时候该调用这个工具”,所以要写得清楚、具体,把适用场景说明白,不要只写“检索知识库”五个字。第二,参数要加类型注解(query: str),模型会依据函数签名生成调用参数。第三,返回值尽量是简单的字符串,方便模型直接阅读与引用。
为什么把 RAG 做成工具,而不是固定流程?传统 RAG 是“提问、检索、生成”三步死的流水线:不管用户问什么,都先检索一遍再生成,哪怕问题根本不需要知识库。把检索封装成工具交给 Agent 后,模型可以自主决定:这个问题是常识,直接回答,不检索;这个问题涉及公司制度,调用 search_kb;第一次检索结果不理想,换个关键词再查一次。这种“按需检索”比固定流程更灵活、更省 token,而且检索工具可以与其他工具(查数据库、做计算、发邮件)组合在同一个 Agent 里,能力随工具数量线性扩展。
9.4 create_agent 组装与调用
思路:工具就绪后,用 create_agent 把模型、工具、系统提示词三者组装成 Agent,然后调用 invoke 发起一轮对话。
from langchain_openai import ChatOpenAI
from langchain.agents import create_agent
agent = create_agent(
model=ChatOpenAI(model="gpt-4o-mini"),
tools=[search_kb],
system_prompt="你是公司内部助手,严格依据知识库检索结果作答;检索不到就如实说明。",
)
result = agent.invoke({
"messages": [
{"role": "user", "content": "工作满 3 年的员工有几天年假?"}
]
})
# 返回值是 dict,messages 键保存完整消息序列
print(result["messages"][-1].content)
create_agent 接收三个关键参数:model 是 Agent 的“大脑”,tools 是它的工具箱,system_prompt 用来设定行为准则与回答风格。系统提示词里那句“检索不到就如实说明”很重要,它能在知识库里确实没有答案时抑制模型编造,是 RAG 场景的基本护栏。
invoke 的返回值是一个 dict,其中最核心的是 messages 列表,它完整记录了这一轮运行的“思考过程”。一个典型的序列是这样的:第一条是用户输入;接着是模型的第一次回复 AIMessage,它的正文可能为空,但携带了对 search_kb 的工具调用与参数;然后是 ToolMessage,内容就是 search_kb 返回的检索文本;最后模型把检索结果整理成自然语言答案,也就是末尾那条 AIMessage。因此 result["messages"][-1].content 就是我们要的最终答案。调试时不必猜,把 messages 逐条打印出来,Agent 的每一步决策都一目了然。
这套“模型循环”由底层的 LangGraph 驱动:模型发出工具调用,框架执行工具,把结果回填给模型,直到模型认为可以给出最终答案为止。循环的次数、每一步的状态都是可观测、可干预的,这正是下一节 middleware 的切入点。
多轮对话的接入也很自然:invoke 的 messages 参数接收的是消息列表,把历史对话按顺序放进去,Agent 就能带着上下文继续工作。比如先问“工作满 3 年的员工有几天年假”,再追问“那满 12 年呢”,第二轮把必要的历史消息与新问题一起传入即可。若希望框架自动管理跨请求的会话状态,可以结合 LangGraph 的检查点机制,这属于进阶内容,本章不展开。
从工程角度看,messages 序列还是评估检索质量的现成材料:看模型调用 search_kb 时传的参数,能判断它有没有把问题正确拆解成检索关键词;看 ToolMessage 的内容,能判断知识库有没有命中正确的分块。答案不理想时先查这两处,很快就能分清问题出在检索层还是生成层,这比盲目调整提示词高效得多。
9.5 middleware 简介
middleware(中间件)是 LangChain 1.x 的核心新概念。可以把它理解成在 Agent 执行循环上安装的一个个“检查点”:在模型调用前后、工具调用前后等关键节点,中间件可以拦截、修改甚至中断流程。典型场景包括:限制模型调用次数防止死循环、对过长的历史消息做摘要防止上下文超限、敏感工具执行前要求人工审批、对输入输出做敏感信息过滤等。
下面以一个“限制模型调用次数”的中间件为例,看看典型写法。它的作用是:当 Agent 在一轮对话里反复调用模型超过阈值时强制终止,防止模型陷入“调用工具、不满意、再调用”的死循环。
from langchain.agents.middleware import ModelCallLimitMiddleware
agent = create_agent(
model=ChatOpenAI(model="gpt-4o-mini"),
tools=[search_kb],
middleware=[
# 限制单轮对话中的模型调用次数,超限即终止
ModelCallLimitMiddleware(thread_limit=6),
],
)
写法很直白:实例化中间件对象,放进 create_agent 的 middleware 参数列表,多个中间件按顺序生效。需要提醒的是,middleware 体系在 1.x 中仍在快速演进,内置中间件的类名、构造参数以及自定义中间件的钩子签名,请以你所用版本的官方文档为准,本节示例重在传达“检查点拦截”这个思想。
自定义中间件的一般做法是继承官方基类,覆写“模型调用前”“模型调用后”“工具调用前”等钩子方法。比如你可以写一个统计中间件,记录每次调用消耗的 token 数用于成本核算;或者写一个审批中间件,当模型试图调用高危工具时中断流程、转人工确认。这类机制让 Agent 的行为变得可控、可审计,对生产环境而言,其价值往往比“更聪明”更重要。
另一个高频场景是上下文管理。随着对话变长,历史消息会不断挤占上下文窗口,摘要类中间件的做法是:当消息总长度超过阈值时,自动把较早的消息压缩成一段摘要,既保留关键信息,又控制 token 开销。这类中间件与限流中间件的接入方式完全相同——实例化后放进 middleware 列表,多个中间件组合使用、互不冲突。再次强调,具体类名与参数请以你所用版本的官方文档为准。
9.6 老代码迁移
如果你的项目还跑在 0.x 代码上,比如大量使用了 create_retrieval_chain 之类的链式 API,升级到 1.x 时它们已经不在主包里了。官方的过渡方案是 langchain-classic 包[3]:安装它并把导入路径改过去,存量代码基本可以原样运行,为迁移争取时间。
pip install langchain-classic
新项目千万不要依赖 langchain-classic。它的定位是“迁移缓冲区”,只为存量代码提供兼容,不会新增任何功能,最终会停止维护。新代码应直接使用 create_agent 加 middleware 的 1.x 范式;老项目建议采用“绞杀式迁移”:先把外围的、低频的流程改写成 1.x,验证稳定后再逐步替换核心链路,避免一次性大改带来的风险。
迁移时有一张粗略的对照关系可以参考:旧的 RetrievalQA 与 create_retrieval_chain,对应到“@tool 包装的检索函数 + create_agent”;旧的“提示词模板 + 模型”链,对应到与模型直接对话或 LangGraph 节点;旧的 memory 组件,对应到 LangGraph 的状态与检查点机制。建议先挑一个最小的流程写 demo 验证效果与延迟,确认无虞后再批量迁移,迁移过程中把第 8 章的检索层原样保留,改动面会小很多。
迁移过程中还有一个实用技巧:给新旧两套实现准备同一组回归用例。挑十几个覆盖核心场景的典型问题,分别跑旧链与新 Agent,对比答案质量、工具调用次数与延迟。有了这组对照数据,迁移就不再是“凭感觉切换”,而是有据可依的工程决策;出现效果回归时,也能借助 messages 序列快速定位问题出在检索层还是生成层。
9.7 本章小结
本章完成了 LangChain 1.x 的核心链路:安装 langchain 与 langchain-openai 两个包,用 ChatOpenAI 对接模型,base_url 一换即可接入国产模型;把第 8 章的 Chroma 知识库封装成 search_kb 工具,docstring 写清用途;用 create_agent 把模型、工具、系统提示词组装成 Agent,从返回值的 messages 末尾取最终答案;用 middleware 在执行循环上加控制点;遇到 0.x 老代码,用 langchain-classic 做过渡但不长期依赖。
相比 0.x,1.x 的心智负担显著降低:入口只有一个 create_agent,扩展只靠 middleware,底层交给 LangGraph。下一章我们换一个视角,看看以“数据”为中心的 LlamaIndex 是怎么做知识库的,并给出两大框架的横向对比。
注
[1] langchain PyPI 项目主页:https://pypi.org/project/langchain/
[2] LangChain 1.0 Release Notes(LangChain 官方博客):https://blog.langchain.com/langchain-1-0/
[3] langchain-classic PyPI 项目主页:https://pypi.org/project/langchain-classic/
LlamaIndex 实战
上一章我们用 LangChain 把知识库变成了 Agent 的工具。本章换一个视角:LlamaIndex。它不急着谈 Agent,而是先把“数据”这件事做到极致——接入文档、切分节点、建立索引、组装查询引擎,一条完整的 RAG 数据管道几行代码就能跑起来。我们会完整走一遍这条管道,并在最后给出它与 LangChain 的横向对比。
10.1 定位差异
LlamaIndex(前身叫 GPT Index)与 LangChain 几乎同时期诞生,但两者选择了截然不同的路。LangChain 从“模型调用与编排”出发,把数据看作流程中的一种输入,强项在于把模型、工具、流程拼装成复杂应用;LlamaIndex 则以数据接入与索引为中心,强项在于把你的文档变成可被高效检索的知识库。用一句话区分:LangChain 关心“模型怎么用”,LlamaIndex 关心“数据怎么管”。
在 LlamaIndex 的世界观里,构建知识库是一条清晰的流水线:把各种来源的数据读成 Document,切分成节点(node),向量化后建成索引,最后包装成查询引擎对外提供问答。这条流水线上的每一环,框架都提供了开箱即用的默认实现。
这里值得先认识“节点”这个概念。LlamaIndex 不把整篇文档直接交给检索,而是先切分成一个个节点:每个节点是一段带上下文的文本,保留着与源文档的引用关系。检索以节点为单位进行,命中的节点连同其元数据一起参与后续生成。这个设计与第 7 章的“分块”一脉相承,区别在于 LlamaIndex 把切分策略、节点关系、元数据管理都做成了框架内建能力,而不需要你自己实现。
顺带一提,LlamaIndex 的索引并不只有向量索引一种。除了本章重点讲的 VectorStoreIndex,它还提供了摘要索引、知识图谱索引等形态,分别适配不同的查询模式。对入门读者来说,向量索引已经覆盖绝大多数知识库场景,不必急着把所有索引类型学全,等遇到向量检索解决不好的问题再按需了解即可。
为什么说 LlamaIndex 的数据工程能力更内建?文档加载、文本切分、向量化、索引的存储与加载、检索与结果后处理,这些环节在别的框架里往往要自己写或拼凑多个第三方库,而 LlamaIndex 把它们做成了统一抽象下的默认实现:一个 Document 类承接各种来源的文本,一个 VectorStoreIndex 串起切分、向量化与建索引,一个 query 方法完成检索加生成。对于“以文档问答为核心”的场景,这意味着极低的起步成本——你几乎不用理解底层细节,就能先得到一个可用的系统,再逐层深入调优。
10.2 环境与 Settings 配置
安装一个元包即可,它会带上核心库与常用的 OpenAI 集成。
pip install llama-index
截至本书写作时,llama-index 的稳定版本为 0.14.23[4]。安装完成后,核心能力在 llama-index-core 中,OpenAI 的对话模型与嵌入模型集成分别在 llama-index-llms-openai 与 llama-index-embeddings-openai 中,元包已替你装好。
LlamaIndex 用全局的 Settings 对象配置模型。建议在程序入口处显式设置,之后所有建索引与查询操作都会使用这套配置。
from llama_index.core import Settings
from llama_index.llms.openai import OpenAI
from llama_index.embeddings.openai import OpenAIEmbedding
# 全局配置:之后的建索引与查询都使用这套模型
Settings.llm = OpenAI(model="gpt-4o-mini")
Settings.embed_model = OpenAIEmbedding(model="text-embedding-3-small")
显式设置有两个好处:一是模型版本可控,不会随框架升级悄悄变化,向量维度与语义空间保持稳定;二是替换厂商时只改这两行,其余代码不动。这与第 9 章“模型集成独立成包”的思路异曲同工。
如果你用的是 OpenAI 兼容接口的国产模型,LlamaIndex 的 OpenAI 集成同样支持指定接口地址与密钥,思路与第 9 章的 base_url 一致;社区还有针对各家模型平台的独立集成包,具体支持列表以官方文档为准。无论选哪家,都建议把模型配置收敛到程序入口这一处,避免散落各处、难以追踪。
Settings 一定要在建索引之前设置好。嵌入模型决定了向量的维度与语义空间,索引建到一半再换模型,新旧向量无法比较,只能把整个索引推倒重建。若确实要换模型,请换一个 persist 目录或集合名,避免新旧向量混在一起。
10.3 五行代码建索引与问答
思路:核心流程只有四步——把文本包成 Document、用 VectorStoreIndex.from_documents 建索引、把索引转成查询引擎、调用 query 提问。下面用三条员工手册内容做演示。
from llama_index.core import VectorStoreIndex, Document
# 1. 把文本包装成 Document 对象
docs = [
Document(text="员工累计工作满 1 年不满 10 年的,年休假 5 天;满 10 年不满 20 年的,年休假 10 天。"),
Document(text="报销单需在每月 5 日前提交系统,逾期按次月处理。"),
Document(text="新员工入职后有 3 个月试用期,试用期工资按转正后的 80% 发放。"),
]
# 2. 建立向量索引:自动完成切分、向量化、建索引
index = VectorStoreIndex.from_documents(docs)
# 3. 把索引包装成查询引擎
qe = index.as_query_engine(similarity_top_k=3)
# 4. 提问
resp = qe.query("入职满 3 年的员工有几天年假?")
print(resp)
这五行代码背后,框架替你做了完整的 RAG 流程。from_documents 会先把 Document 切分成节点,调用 Settings.embed_model 计算每个节点的向量,再在内存中建成向量索引;as_query_engine 把索引包装成可查询的引擎,similarity_top_k=3 表示检索时取相似度最高的 3 个节点;query 执行时,先把问题向量化、检索 top-k 节点,再把节点内容作为上下文交给 Settings.llm 生成答案。
直接 print(resp) 就能看到自然语言答案,例如“员工累计工作满 1 年不满 10 年的年休假为 5 天,因此入职满 3 年的员工有 5 天年假”。resp 是一个 Response 对象,除了答案文本,还携带命中的节点与相似度得分,通过 resp.source_nodes 可以查看,这是调试检索质量的第一手材料:答案不对时,先看检索回来的节点对不对,再决定是调切分还是调检索。
从这个例子也能看出两个框架的写法差异:在 LangChain 里,我们要自己写检索函数、包成工具、交给 Agent 决定何时调用;在 LlamaIndex 里,“检索加生成”被封装进查询引擎,一次 query 调用即问即答。前者灵活,适合对话型助手;后者省事,适合问答型服务。另外,Document 还支持携带来源、时间等元数据,参与检索过滤与答案引用,完整参数可查阅官方文档。
真实项目里,你通常不会手写 Document 对象,而是用 LlamaIndex 提供的读取器把 PDF、网页、数据库等数据源批量加载进来,再交给 from_documents 处理。数据接入层是这个框架的强项之一,官方仓库里有大量现成的读取器可以直接使用。这也呼应了本章开头的判断:LlamaIndex 在数据侧的投入,远多于编排侧。
10.4 检索调优
similarity_top_k 是最直接的调优旋钮。取值太小可能漏掉关键信息,太大则会引入噪声、推高 token 成本。常见做法是先把 top_k 设大一些(比如 10),通过 source_nodes 观察命中质量,再配合后处理器把不相关的节点筛掉,而不是盲目调小 top_k。
node_postprocessors(节点后处理器)是 LlamaIndex 的特色设计:它在“检索之后、生成之前”运行,对命中节点做二次过滤或重排序。框架内置的 SimilarityPostprocessor 可以按相似度阈值过滤:
from llama_index.core.postprocessor import SimilarityPostprocessor
# 过滤掉相似度低于 0.7 的节点
postprocessor = SimilarityPostprocessor(similarity_cutoff=0.7)
qe = index.as_query_engine(
similarity_top_k=10,
node_postprocessors=[postprocessor],
)
这样组合的含义是:先放宽检索取回 10 个候选,再用阈值把明显不相关的剔除,兼顾召回与精度。阈值怎么定?经验做法是先离线跑一批真实问题,观察每个问题命中节点的相似度分布:相关节点与不相关节点的得分通常会分成两团,阈值取在两团之间即可。没有评测数据时,宁可把阈值设低一点、多保留候选,也不要为了“干净”把正确答案过滤掉——漏检比噪声更难补救。若想要更好的排序质量,还可以引入交叉编码器重排器,例如 SentenceTransformerRerank,用独立的重排模型对 top 候选精排。这类能力由独立的集成包提供,具体包名与用法请以 LlamaIndex 官方文档为准。重排是提升答案准确率的关键手段之一,完整的“混合检索 + 重排”方案将在第 11 章展开。
10.5 持久化与增量
内存里的索引随进程退出而消失,LlamaIndex 提供了简单的持久化接口:把索引存到目录,下次直接加载,无需重新计算向量。
# 首次构建后:把索引持久化到磁盘
index.storage_context.persist(persist_dir="./storage")
# 之后启动:从磁盘加载,无需重新向量化
from llama_index.core import StorageContext, load_index_from_storage
storage_context = StorageContext.from_defaults(persist_dir="./storage")
index = load_index_from_storage(storage_context)
qe = index.as_query_engine(similarity_top_k=3)
./storage 目录里保存了文档元数据、节点文本与索引结构的 JSON 文件,加载后即可直接查询。对于增量更新,LlamaIndex 同样友好:新文档到达时,用 index.insert(doc) 插入即可,框架会完成切分、向量化与索引更新;个别文档作废时,也可以按文档 id 删除对应节点。这种“持久化 + 增量插入”的模式,支撑几千到几万文档规模的知识库完全没有问题;数据量再上一个台阶,就需要把向量存进第 8 章介绍的专业向量数据库,LlamaIndex 也提供了对应的集成。
还有一个工程细节:增量插入虽然方便,但文档频繁增删之后,索引碎片会慢慢积累,检索质量与加载速度都可能受影响。稳妥的做法是定期全量重建——比如每周用最新文档重新执行一次 from_documents,写入新目录,验证无误后再切换过去。重建成本并不高,几千文档通常几分钟就能完成,换来的是索引状态的干净可控,这笔账很划算。
10.6 与 LangChain 的对比
两个框架跑下来,可以做一个务实的横向对比。下表从四个维度总结它们的差异。
| 维度 | LlamaIndex | LangChain 1.x |
|---|---|---|
| 擅长点 | 数据接入、索引构建、检索与调优 | Agent 编排、工具调用、流程控制 |
| 编排能力 | 查询引擎与聊天引擎为主,相对轻量 | create_agent 加 middleware,底层 LangGraph,强于复杂流程 |
| 生态 | 数据读取器、节点解析器、各类索引丰富 | 模型与工具集成丰富,社区活跃 |
| 适合谁 | 文档问答、企业知识库、重数据管道的团队 | 多工具 Agent、复杂业务流程、重编排的团队 |
这样对比的好处是给出一个可操作的选型经验:项目重数据管道——数据来源杂、要反复调切分策略、要打磨索引与检索质量——优先选 LlamaIndex,它把这些环节做成了内建能力;项目重 Agent 编排——要调多个工具、有复杂决策与人工审批流程——优先选 LangChain 1.x。更重要的是两者并不互斥:不少生产系统用 LlamaIndex 构建高质量检索层,再把它包装成 LangChain Agent 的一个工具,各取所长。框架是手段,检索质量与业务目标才是目的。
补充一点:两个框架都在快速演进,本节的对比基于 LlamaIndex 0.14 与 LangChain 1.x。实际选型时,除了看能力矩阵,也建议看一眼目标框架最近半年的发布节奏与问题响应速度——对生产项目来说,社区的活跃度与维护质量,往往比功能列表更能决定长期体验。
10.7 本章小结
本章完整走了 LlamaIndex 的数据管道:安装 llama-index 元包,用 Settings 全局配置对话与嵌入模型;五行代码完成 Document 建索引、查询引擎问答;用 similarity_top_k 与 node_postprocessors 调优检索;用 persist 与 load_index_from_storage 实现持久化与增量更新。LlamaIndex 的价值在于把“数据接入与索引”做成了内建能力,让文档问答类应用的起步成本降到最低。
至此,两大框架的实战都已覆盖。但无论用哪个框架,检索质量的上限最终取决于“检索策略”本身——下一章我们进入混合检索与重排序,把向量语义检索与关键词检索结合起来,再上一个台阶。
注
[4] llama-index PyPI 项目主页:https://pypi.org/project/llama-index/
混合检索与重排序
前面的章节里,我们把文档存进了向量数据库,知识库算是"能跑起来了"。可一旦拿真实用户的问题去测,就会发现向量检索并不像想象中那么靠谱:专有名词会漏、编号会漏、用户明明在问某个关键词却偏偏命不中。这一章,我们给检索链路加上两道保险:混合检索与重排序。先用 BM25 关键词检索给稠密向量补位,再用 RRF 把两路结果融合起来,最后用交叉编码器重排器对候选做精排,把真正相关的片段顶到最前面。
11.1 稠密检索的盲区
先一起回顾一下向量检索的工作方式。嵌入模型把文本变成固定长度的向量,检索的本质是在库里找与查询向量距离最近的片段。这个机制捕捉的是"语义上的相似",而不是"字面上的匹配"。多数时候这是优点:用户问"出差报销怎么弄",它能匹配到标题为《差旅费用报销流程指南》的文档,这是关键词检索做不到的。但也正因为只看语义、不看字面,向量检索有几个几乎无法回避的盲区。
最典型的盲区是专有名词与编号。假设企业语料里有一条片段记录着"GB-2024-07 报销单填写规范",用户查询恰好是"GB-2024-07 报销单"。对这类字母数字混合的编号,嵌入模型的理解能力往往很有限——在向量空间里,GB-2024-07 与 GB-2024-08 的距离可能差不多,甚至与某个不相干的编号也不远。结果就是,向量检索返回的前几名常常是"和报销沾边"的片段,却偏偏不是编号精确匹配的那一条。对财务、合规、客服这类"差一个字就差千里"的场景,这种失手是致命的。
第二个盲区是罕见词与新词。内部系统代号、行业黑话、刚发布的产品型号,这些词在嵌入模型的训练语料里出现得极少,向量表示自然很粗糙。用户搜"鲲鹏计划"这样的内部项目代号,向量检索可能返回一堆讲"计划""项目"的无关文档。模型没见过或者见得少的词,它天然摆不准位置。
第三个盲区是精确匹配需求。有时候用户就是要找"那一句":某个条款编号、某个错误码、某个 API 函数名。这类场景下"语义近似"毫无价值,必须字面命中才算数。向量检索平时引以为傲的"模糊",在这里反而成了硬伤。一句话总结:稠密向量解决的是"同一个意思的不同说法",解决不了"就是这个词"。
想验证自己的系统是否存在这些盲区,方法很简单:抽一批真实查询,看看包含查询关键词字面的片段有没有进入向量检索的前几名。你会发现,凡是带编号、型号、专有名词的查询,字面包含的片段经常进不了前列,或者勉强挂在尾部。这不是嵌入模型没选好的问题,而是稠密检索机制本身的共性局限,换模型只能缓解,无法根除。
稠密向量的核心能力是泛化——把不同说法的同一个意思匹配到一起;关键词检索的核心能力是精确——把字面出现的词项牢牢命中。二者没有优劣之分,只有分工不同。给检索链路加一条专做字面精确匹配的关键词路线,让两者各管一摊,这就是混合检索的出发点。这条关键词路线通常被称为稀疏检索,因为它把文本表示成一个绝大多数维度都是 0 的稀疏向量。
11.2 两条互补的检索路
既然知道了盲区在哪,解法就很自然:在稠密路线之外,再加一条专攻字面匹配的检索路线,取两者的长处。稀疏路线最经典的实现是 BM25,它直到今天仍是许多搜索引擎与知识库平台的默认检索配置。下面先分别看清两条路线的特点,再把它们放在一起对比。
BM25:词项统计的老将
BM25 是几十年前信息检索领域提出的算法,思路非常朴素:根据词项统计,判断哪些文档与查询的"重合度"高。粗略地说,一个词在文档里出现次数越多,贡献的分数越高;但这个词在整库语料中越常见(如"的""是""进行"),权重就越低——这就是 IDF(逆文档频率)思想。BM25 还引入了文档长度归一化,避免长文档靠"字数多"占便宜。整个算法不涉及任何语义理解,纯粹是统计,但正因如此,它快、稳、可解释。
BM25 的强项是精确匹配:只要查询词项在文档里字面出现,就有分,而且词项越罕见权重越高。像"GB-2024-07"这种查询正是 BM25 的主场——编号只在那一篇文档里出现过,IDF 极高,该文档的得分会一骑绝尘。它的弱项同样明显:完全不理解语义,"出差报销怎么弄"和"差旅费用报销流程"在它看来毫无关系,换个说法就轻易漏掉。
动手之前先说思路:我们准备三条短文档,用 BM25Okapi 建索引,再用一个带编号的查询打分,观察包含编号的文档是否脱颖而出。为了演示方便,这里用正则做简单切分。
import re
from rank_bm25 import BM25Okapi
docs = [
"GB-2024-07 报销单填写规范:差旅报销单的填写要求",
"差旅费用报销流程指南:出差报销与餐补申领办法",
"年假申请与审批流程说明",
]
# 简单切分演示:连续字母数字与连字符算一个词元,中文按单字切开
# 中文生产环境建议改用 jieba 做正式分词
def simple_tokenize(text):
return re.findall(r"[A-Za-z0-9\-]+|[\u4e00-\u9fff]", text)
# 构建 BM25 索引:输入是"分词列表的列表",每个文档一个词元列表
bm25 = BM25Okapi([simple_tokenize(doc) for doc in docs])
# 对语料中每篇文档计算查询得分
scores = bm25.get_scores(simple_tokenize("GB-2024-07 报销单"))
for doc, score in zip(docs, scores):
print(round(score, 2), doc)
# 含编号的第一篇文档得分会显著高于其余两篇上面代码有两个要点。第一,BM25Okapi 的输入是"分词列表的列表",每个文档对应一个词元列表;get_scores 针对查询返回语料中每篇文档的得分,得分越高越相关。第二,演示里用正则做简单切分只是为了快速跑通,中文被按单字切开了;中文生产环境务必换成 jieba 之类的分词器,否则"报销单"这样的词会被拆散,直接影响匹配质量。
稠密向量:语义泛化的主力
稠密路线的原理在前面章节已经详细讲过,这里只把它的长短处摆出来。它的强项是语义泛化:同义词、换一种说法、口语对书面语,甚至跨语言匹配,都能应付。它的弱项恰好是 BM25 强项的镜像:对字面精确匹配不敏感,对罕见词的表示粗糙,而且分数分布常常"整体偏高"或"整体偏低",区分度并不像看上去那么直观。
| 维度 | BM25(稀疏路) | 稠密向量(稠密路) |
|---|---|---|
| 匹配依据 | 查询词项在文档中的字面重合与统计权重 | 向量空间中的语义相似度 |
| 同义词、换种说法 | 弱,词不同就匹配不上 | 强,语义相近即可命中 |
| 专有名词、编号、代码 | 强,罕见词项 IDF 权重高 | 弱,向量表示粗糙、易混淆 |
| 长尾罕见词 | 只要字面出现就能命中 | 表示粗糙,容易失手 |
| 索引与依赖 | 只需构建倒排索引,无需模型推理 | 需要 embedding 模型与向量库 |
| 典型失误 | 查询与文档用词不同就漏召回 | 编号、专有名词查不准 |
对着这张对比表看,结论很清楚:两条路线的弱项恰好被对方的强项覆盖,这种互补不是巧合,而是由两者的机制决定的。所以混合检索不是二选一,而是两条都要,让 BM25 管字面、让稠密向量管语义。新的问题随之而来:两条路线各返回一份候选列表,怎么合成一份?
11.3 融合策略:加权分数 vs RRF
两路检索各自返回一份按分数排好序的候选列表,融合这一步决定了最终候选池的质量。常见的融合策略有两种:加权分数融合与 RRF 排名融合。前者看起来直观,实践中却坑不少;后者看起来"粗糙",工程上反而更稳。
加权分数融合:看起来直观,做起来麻烦
最直觉的做法是加权求和:final = α × dense_score + (1 − α) × bm25_score。公式很清爽,但实际用起来有个大坑:两路分数根本不在同一个量纲上。稠密检索的余弦相似度通常在 0 到 1 之间,还常常挤在 0.2 到 0.4 这样的窄区间里;BM25 的得分则是无上界的正数,随语料与查询不同,可能是 3,也可能是 30。直接相加,一路会完全压制另一路,权重 α 形同虚设。
于是加权融合必须先做归一化:min-max、z-score 都行。但归一化受当前查询与语料的分数分布影响,非常不稳定:今天调好的权重,明天语料更新或查询变化后可能完全失效。很多团队最后发现,调权重变成了玄学,效果忽好忽坏,还说不出原因。
RRF:只看名次,不看分数
RRF(Reciprocal Rank Fusion,倒数排名融合)换了个思路:彻底放弃分数,只看名次。规则一句话就能说清——对每个文档,它在每一路榜单上的名次都贡献一个 1/(k + 名次) 的分数,把多路贡献加起来,再按总分排序。公式写出来就是一行:score(d) = Σ 1 / (k + rank_i(d)),其中 rank_i(d) 是文档 d 在第 i 路榜单中的名次(从 1 开始),k 是平滑常数,通常取 60。如果某一路没有检索到 d,这一路对 d 就不贡献分数。
直观理解 RRF 的行为:被两路同时检索到、且名次都不错的文档,融合分最高;只被一路检索到的文档会打折扣;勉强挤进某路榜单尾部的文档几乎不贡献分数。分母里的 k = 60 起到了"稀释头部差距"的作用,避免某一路的第一名一家独大。RRF 在信息检索评测中是沿用多年的常规融合手段,Elasticsearch、OpenSearch 等主流引擎也内置了它,可靠性经过了充分验证。
名次是一种"无关分布"的信号:无论原始分数是余弦相似度还是 BM25 得分、无论分布集中还是分散,名次顺序都是可比的。这意味着 RRF 不需要归一化、不需要调权重,对异常分数也特别稳健——某一路即便给某个文档打出离谱的高分,也只能让它的名次靠前若干位,而不像分数相加那样横扫一切。对工程来说,这种"不用调、不会炸"的特性,往往比多几个点的理论精度更有价值。
实现思路很直白:外层遍历每一路榜单,内层按名次累加贡献,最后按总分降序输出。代码如下:
def rrf_fuse(rankings, k=60):
"""
RRF 排名融合
rankings: 多路排序结果,每个元素是一份 doc_id 列表(按该路得分从高到低)
k: 平滑常数,通常取 60
返回: 按融合分降序的 (doc_id, 融合分) 列表
"""
scores = {}
for ranking in rankings: # 遍历每一路检索结果
for rank, doc_id in enumerate(ranking, start=1):
# RRF 公式:score(d) = Σ 1 / (k + rank_i(d))
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
# 按融合分降序排列
return sorted(scores.items(), key=lambda item: item[1], reverse=True)
# 示例:两路检索的排序结果
dense_ranking = ["doc-3", "doc-1", "doc-7"]
sparse_ranking = ["doc-1", "doc-3", "doc-5"]
for doc_id, score in rrf_fuse([dense_ranking, sparse_ranking]):
print(doc_id, round(score, 5))
# doc-1 与 doc-3 被两路同时检索到,融合分排在最前实现只有十几行:外层循环遍历每一路榜单,内层按名次累加 1/(k + rank),最后按总分排序。注意 rankings 可以传入任意多路——两路、三路乃至更多,融合方式完全一样,这是 RRF 的额外红利。示例里 doc-1 与 doc-3 被两路同时检索到,融合分自然压过只出现在一路中的 doc-5 与 doc-7,这正是我们想要的融合效果。
11.4 重排序:把"准"留给第二阶段
RRF 融合之后,候选质量已经有了明显提升,但还剩一个问题:BM25 与稠密检索都属于"粗排",追求的是快与全,排在前面的候选常常"看着相关、其实跑偏"。比如两路都可能把《报销制度总则》排得很靠前,可用户真正想问的是"二线城市出差餐补标准"这个细节。要把最终前几名的精度再提一档,就需要引入更强但更慢的模型做重排序。先弄清两种编码器架构的本质区别。
bi-encoder 与 cross-encoder:一个快,一个准
我们使用的嵌入模型是 bi-encoder(双编码器):查询与文档各自独立编码成向量,再计算相似度。好处是文档可以提前编码入库,在线检索时只需编码一次查询,然后在索引里做快速近邻搜索;代价是查询与文档之间没有任何"交互"——模型看不到两段文本放在一起的样子,只能靠两个向量远远地"猜"它们是否匹配。
cross-encoder(交叉编码器)正好相反:把查询与文档拼成一对,一起喂进模型,注意力机制可以在两段文本之间做逐词的交互,判断能力远强于 bi-encoder。代价是无法预计算:每一对(查询,文档)都要完整跑一次模型前向。假设语料库有一百万篇文档,逐对跑一遍完全不可接受,所以 cross-encoder 永远不会用于全库检索,只适合对少量候选做"精加工"。
于是业界通用的方案是两段式漏斗:第一段用 bi-encoder 加 BM25 从海量语料中快速召回几十个候选,要求是"快"和"全",宁可多召不可漏召;第二段用 cross-encoder 对这几十个候选逐一精排,要求是"准"。粗排决定上限——相关文档没被召回,后面任何环节都无力回天;精排决定呈现——最终送进大模型的前几个片段,直接决定答案质量。让每个阶段做自己最擅长的事,这就是重排序设计的动机。
bge-reranker-v2-m3 与 CrossEncoder 实战
本书选用 BAAI/bge-reranker-v2-m3 作为重排器。它属于 BGE-M3 多语言系列中的重排序模型[20],支持中文、英文等众多语言,中文效果出色;模型体量相对轻量,CPU 推理即可接受,GPU 上更快,具体规格可查阅官方模型卡[25]。CrossEncoder 类来自 sentence-transformers 库[7],加载方式与嵌入模型一致。如果你的项目不想自己维护模型推理服务,也可以考虑 Cohere Rerank 4.0 这类托管服务,它提供 rerank-v4.0-pro 与 rerank-v4.0-fast 两档[26],通过 HTTP API 调用,思路与本章完全一致,只是把打分环节换成了远程接口。
下面的示例演示两种常用调用方式:predict 给(查询,段落)对逐一打分,适合拿到分数后自行处理;rank 直接返回按分数排好序的 top_k 结果,用起来更省事。
from sentence_transformers import CrossEncoder
# 加载交叉编码器重排器,首次运行会自动下载模型权重
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")
query = "报销单怎么填写?"
passages = [
"报销单填写指南:先登录 OA 系统,选择费用类型,再关联出差申请单...",
"年假申请流程:提前三天提交年假申请单,由直属主管审批...",
]
# 用法一:对 (查询, 段落) 对逐一打分
scores = reranker.predict([(query, p) for p in passages])
print(scores) # 第一条得分显著高于第二条
# 用法二:直接排序,返回相关性最高的 top_k 个段落
ranked = reranker.rank(query, passages, top_k=1)
print(ranked) # 结果含 corpus_id(在 passages 中的下标)与 score要点有三个。第一,predict 接收(查询,段落)对的列表,每对返回一个相关性分数,分数越高越相关;rank 只需传入查询、候选段落列表与 top_k,返回按分数降序的结果,每条包含 corpus_id(候选列表中的下标)与 score。第二,重排器应在服务启动时加载一次并复用,千万不要每个请求都重新加载模型,否则光加载就要耗费数秒。第三,重排器的分数与嵌入模型的相似度是两套量纲,不能跨模型比较大小,重排分数只在"本次重排内部排序"这个语境下有意义。
11.5 完整混合检索管线
现在把所有零件组装成完整管线。先说思路:稠密路走 Chroma 召回,稀疏路走 BM25 召回,两份榜单用 RRF 融合出候选池,最后交叉编码器对候选精排,返回 top_k。为了便于观察与调参,我们把每一段的规模都做成参数:recall 控制每路召回多少条,top_k 是最终返回条数。代码略长,但骨架就是四步。
import jieba
import chromadb
from rank_bm25 import BM25Okapi
from sentence_transformers import CrossEncoder
# ---------- 前置:语料与稠密索引(前面章节已构建) ----------
# docs: 片段原文列表;ids: 每个片段的唯一标识,与 docs 一一对应
doc_by_id = dict(zip(ids, docs))
client = chromadb.PersistentClient(path="./chroma_db")
collection = client.get_collection("kb_chunks") # 已建好的 Chroma 集合
# ---------- 稀疏路:构建 BM25 索引 ----------
# 中文使用 jieba 分词;生产环境可将索引序列化落盘,避免每次重建
bm25 = BM25Okapi([list(jieba.cut(doc)) for doc in docs])
# ---------- 重排器:进程启动时加载一次,长期复用 ----------
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")
def rrf_fuse(rankings, k=60):
"""RRF 排名融合:只看名次不看分数,公式见 11.3 节"""
scores = {}
for ranking in rankings:
for rank, doc_id in enumerate(ranking, start=1):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
return sorted(scores.items(), key=lambda item: item[1], reverse=True)
def hybrid_search(query, top_k=3, recall=20, k=60):
"""混合检索:稠密 + 稀疏召回 -> RRF 融合 -> 交叉编码器重排"""
# 第一步:稠密路,从 Chroma 召回 recall 个候选
dense_hits = collection.query(query_texts=[query], n_results=recall)
dense_ranking = dense_hits["ids"][0] # 已按相关性从高到低排好
# 第二步:稀疏路,BM25 召回 recall 个候选
query_tokens = list(jieba.cut(query))
bm25_scores = bm25.get_scores(query_tokens)
sparse_order = sorted(
range(len(docs)), key=lambda i: bm25_scores[i], reverse=True
)
sparse_ranking = [ids[i] for i in sparse_order[:recall]]
# 第三步:RRF 融合两路榜单,取前 recall 名作为重排候选
fused = rrf_fuse([dense_ranking, sparse_ranking], k=k)
candidate_ids = [doc_id for doc_id, _ in fused[:recall]]
# 第四步:交叉编码器对候选精排,取出 top_k
candidate_texts = [doc_by_id[doc_id] for doc_id in candidate_ids]
ranked = reranker.rank(query, candidate_texts, top_k=top_k)
# corpus_id 是候选列表中的下标,需映射回原始片段 id
return [
{"id": candidate_ids[item["corpus_id"]], "score": item["score"]}
for item in ranked
]
# 试跑 11.1 节里让纯向量检索失手的查询
results = hybrid_search("GB-2024-07 报销单", top_k=3)
for item in results:
print(item["id"], round(item["score"], 3), doc_by_id[item["id"]][:30])读完代码,记住几个工程要点。第一,两路召回的 recall 要明显大于最终 top_k,一般取 5 到 10 倍,确保相关片段以高概率进入重排阶段;召回太少,重排再好也无米下锅。第二,BM25 索引建在内存里,语料更新后需要重建,生产环境可以序列化落盘或服务启动时重建。第三,reranker.rank 返回的 corpus_id 是候选列表中的下标,务必映射回原始文档 id,这是最容易写错的地方。第四,jieba 首次分词要加载词典、速度较慢,生产环境建议提前预热。
我们把 11.1 节那个让纯向量检索失手的查询完整走一遍:"GB-2024-07 报销单"。BM25 路凭借编号的字面命中,把那条规范片段稳稳排在第一;稠密路可能只把它排在十几名,但只要进了召回榜单,RRF 就会凭借双路命中把它顶到前列;最后重排器把查询与片段放在一起细看,确认高度相关,锁定进前三。管线每一段都在做自己擅长的事,这就是混合检索的魅力。
11.6 成本账:重排候选数与延迟的权衡
混合检索加重排不是免费的,主要成本在延迟。BM25 检索与 Chroma 向量检索都是毫秒级;重排则要对每个候选对跑一次神经网络前向,候选数乘以单对推理时间,大致就是重排环节的耗时。20 个候选送进 bge-reranker-v2-m3,CPU 上约几百毫秒;100 个候选就要以秒计,用户会明显感觉到卡顿。所以"送多少条进重排"是整条管线里最值得调的参数。
实践经验是:"top-20 进重排、取 top-3"对多数知识库问答场景是个不错的起点。召回不足时可以放宽到 30 至 50,同时盯住延迟预算;延迟吃紧则收缩候选数,或把重排挪到 GPU 上。若是离线批处理场景,比如构建评估集、预生成摘要,延迟不敏感,候选数不妨再放大一些换更好的效果。记住,目标不是把某个指标刷到最高,而是在效果与延迟之间找到业务可接受的平衡点。
"top-20 进重排、取 top-3"这类配置的好处在于:召回阶段撒网足够大,相关片段被漏掉的概率显著降低;重排阶段用判别力更强的模型重新排序,最终送进大模型的前三个片段质量高,答案的准确率与引用质量都会明显改善。相比纯稠密检索,增加的延迟通常只有几百毫秒,换来的却是对"查不到、答错"这类硬伤的实质性改善。对知识库问答这种重准确性的场景,这笔投入非常值得。
重排是混合检索延迟的大头,别把 top-100 全送进重排器。候选数应通过压测确定:从 20 到 50 起步,观察 P95 延迟,超出预算就收缩候选数或上 GPU。确需更多候选时,要么换更轻量的重排模型,要么使用托管重排服务,不要在 CPU 上硬扛。另外提醒一句:重排器与嵌入模型是两套完全不同的权重,不要混用,更不要指望用嵌入模型完成重排工作。
11.7 本章小结
本章的主线是"互补"与"漏斗"。稠密向量擅长语义泛化,却在专有名词与编号上失手;BM25 擅长字面精确匹配,却不理解语义;两路结果用无需调参的 RRF 融合。接着用两段式漏斗的思想,让交叉编码器对融合后的候选做精排,把真正相关的片段顶到最前面。Chroma 加 BM25Okapi 加 RRF 加 bge-reranker-v2-m3 这条完整管线,是本书后续章节、也是大多数生产知识库的检索链路标准配置。
回头看,知识库的检索链路从第 7 章走到这里,正好经历了三个阶段:先用纯向量检索解决"能查到",再用本章的混合检索与重排解决"查得准",后续的评估与高级优化章节则解决"稳定地准"。把握住"互补"与"漏斗"这两个关键词,本章的内容就都握在手里了。下一章,我们进入实战篇的另一个重头戏:面对众多开源知识库平台,如何看清全景、做出选型。
脚注
[7] sentence-transformers 项目 PyPI 主页:https://pypi.org/project/sentence-transformers/ 。
[20] Chen J, Xiao S, Zhang P, et al. BGE M3-Embedding: Multi-Lingual, Multi-Functionality, Multi-Granularity Text Embeddings Through Self-Knowledge Distillation. arXiv:2402.03216, 2024。
[25] BAAI/bge-reranker-v2-m3 模型卡:https://huggingface.co/BAAI/bge-reranker-v2-m3 。
[26] Cohere Rerank 官方文档:https://docs.cohere.com/docs/rerank-overview 。
开源知识库平台全景与选型
前面十一章,我们把知识库的核心链路一环一环拆开讲:文档解析、切块、向量化、混合检索、重排序。从本章开始进入平台篇,看看开源知识库平台如何把这些环节打包成可以直接使用的产品,又该如何从众多选项中挑出最适合自己团队的那一个。本章先回答为什么要用平台,再逐一画像五大开源平台,给出横向对比表与 Star 数据,最后提供一套可以直接套用的选型决策框架与典型推荐。
12.1 为什么要用开源知识库平台
在第二部分里,我们亲手把整条知识库链路搭了起来:用解析库从 PDF 里提取文本与表格,自己编写切块策略,调用 Embedding 模型做向量化,再在向量数据库上实现多路召回与重排序。每一步都可以从零用代码实现,这种方式对理解原理无可替代。但到了交付阶段,团队负责人关心的是另一个问题:让业务人员真正用上一个知识库,到底要花多久?答案往往超出预期。
来算一笔时间账。一个最小可用的自建知识库系统,至少需要:文档上传与解析服务、切块与索引管理、检索服务、给维护人员的管理后台、给业务人员的问答界面,外加模型配置、权限控制、引用展示等配套能力。一个两三人团队,即便所有环节都有现成组件可以拼装,通常也要数周乃至数月才能达到能用的程度。更关键的是长期成本:解析器要升级、检索策略要调整、前端需求要变更,每一次都在消耗开发资源。而对大多数团队来说,这些工作并不构成核心竞争力,真正产生价值的是业务知识本身,以及基于知识构建的应用场景。
选择开源平台,本质是用已经验证过的工程量换取业务投入时间。成熟的知识库平台把解析、切块、索引、检索、重排、界面这些通用环节打包成一个可部署的整体:前面章节里需要写几周代码才能实现的功能,部署完成即可使用。团队的精力因此可以保留给真正有差异化的事情——整理业务文档、设计问答场景、调优召回效果、把知识库接进现有业务系统。平台版本更新还会带来持续红利:社区修复了解析缺陷、新增了模型支持,团队升级版本即可享受,而不必自己重新实现一遍。
当然,平台不是银弹,三类场景下自研或深度二开仍然必要。第一类是深度定制:需要实现自有切块策略、接入私有重排模型、处理特殊数据源(如私有格式文档、内部系统的结构化数据)时,平台的扩展点可能满足不了。第二类是合规与安全:金融、政务等行业要求数据不出特定网络环境,对审计日志、权限模型有严格规定,往往需要基于平台深度改造甚至完全自研。第三类是嵌入既有系统:知识库只是一个成熟产品中的模块,界面与交互需要完全定制,平台自带的标准界面反而成了包袱。
还有一笔容易被忽略的隐性账:知识库的效果调优是一个长期过程。上线只是开始,之后每次答非所问都要回溯到解析、切块、检索的某一环去修正。自建系统里,这条排查链路完全靠自己维护的工具支撑,工具不顺手,调优节奏就慢;成熟平台则把分段查看、检索测试、引用归属这些排查手段做成了现成界面,调优效率高一个量级。对结果负责的是团队,工具顺手与否直接影响迭代速度。
更多团队采取的是中间路线:先用开源平台快速验证业务价值,在使用过程中逐步明确自身需求;若平台确实无法满足,再考虑基于平台二次开发或迁移自研。得益于前面的章节,你已经理解每个环节的原理,无论是阅读平台源码、评估能力边界,还是将来迁移数据,都不会无从下手。本章的对比正是为了帮你尽量缩短验证阶段,做出一个半年后不会被推翻的选择。
12.2 五大开源平台逐一画像
开源知识库平台领域非常活跃,项目数量众多、定位各异。我们选取其中关注度高、定位区分明显的五个进行画像:Dify、RAGFlow、AnythingLLM、FastGPT、MaxKB。下文涉及的版本号与 GitHub Star 数均核实于 2026-08-18。
Dify:全功能 LLM 应用开发平台
Dify 的定位不仅是知识库,而是一个全功能的 LLM 应用开发平台:知识库是其核心能力之一,与之配套的还有应用编排、模型供应商管理、API 发布等完整能力。截至 2026-08-18,Dify 最新版本为 v1.16.1(发布于 2026-07-28),GitHub Star 数达 89.3k,是该领域热度最高的项目之一[14]。对希望把知识库与 LLM 应用放在同一个平台上管理的团队来说,Dify 通常是最先被纳入评估名单的名字。
知识库能力方面,Dify 相当完整[15]:支持递归文本切分(切分长度与重叠可配置)与父子分段召回;提供 High Quality 与 Economical 两种索引模式;召回阶段支持语义检索、关键词检索、加权混合三种模式且权重可调,还可以接入 Cohere、Jina 等外部 Rerank 模型或使用加权评分做重排;此外提供元数据过滤、检索测试、引用归属等实用功能,以及可编排的知识处理流水线与外部知识库接入。这些能力与前文讲过的原理一一对应,第十三章将逐一上手。
Dify 的部署门槛相对较低:最低要求 CPU 2 核、RAM 4GiB、Docker Compose v2.24.0 及以上,一条 docker compose 命令即可拉起整套服务,具体步骤见第 13 章。许可证方面需要留意:Dify 采用 Dify Open Source License,这是在 Apache 2.0 基础上附加条件的许可证,其中包含对多租户 SaaS 等经营场景的限制。如果公司计划基于 Dify 对外提供多租户服务,务必先通读许可证条款并与法务确认。
RAGFlow:以深度文档理解见长的 RAG 引擎
RAGFlow 的定位是以深度文档理解为核心的 RAG 引擎,GitHub Star 数 88.4k,最新版本 v0.26.4,采用 Apache 2.0 许可证,商用条款友好[16]。它的社区热度与 Dify 接近,但产品思路完全不同:不追求做全能应用平台,而是把理解复杂文档这一件事做到极致。
它的核心竞争力在文档解析与切块管理。内置的 DeepDoc 深度文档解析能力支持 Word、PPT、Excel、图片、扫描件、网页等格式,能够识别表格等复杂版面;切块提供 General、Q&A、Laws、Paper、Book、Manual 等模板化分块策略,并支持可视化编辑分块——机器切得不理想的地方,可以在界面上手工调整。检索侧支持多路召回与融合重排,还提供 GraphRAG 与 RAPTOR 等进阶能力;解析后端可按需切换为 MinerU 或 Docling。对复杂文档多、要求引用溯源的场景,RAGFlow 是目前开源阵营中最有力的候选之一。
与之对应,RAGFlow 的部署门槛是五者中最高的:要求 CPU 4 核、RAM 16GB、磁盘 50GB、Docker 24.0 及以上,还需要把系统参数 vm.max_map_count 调整到不低于 262144;官方镜像仅提供 x86 架构,ARM 服务器的使用者需要自行解决镜像问题。如果团队的文档以纯文本为主,也不追求极致的解析质量,RAGFlow 的资源投入就显得偏重,不如选择更轻量的平台。
AnythingLLM:本地优先的一体化知识库
AnythingLLM 走的是本地优先路线,主张在自己的设备上获得完整的知识库能力。最新版本 v1.15.0,GitHub Star 数 64.7k,采用非常宽松的 MIT 许可证,商用与修改几乎没有限制[19]。
它最大的特点是易用:提供 Mac、Windows、Linux 三端桌面客户端,安装包双击即可开始搭建知识库,完全不需要碰命令行;同时也支持 Docker 部署以覆盖团队场景。向量库默认使用内嵌的 LanceDB,零配置即可启动,后续数据量增长时可以切换到 PGVector、Milvus、Qdrant 等后端。对于想先用起来的个人与小团队,以及对数据出域敏感的场景,AnythingLLM 几乎是零门槛的选择。
它的相对短板在于文档解析与检索调优能力较为基础,缺少可视化分块编辑、复杂重排配置等精细化功能,工作流编排能力也偏弱。如果需求从尝鲜演进到复杂的企业场景,后期可能需要重新选型;好在 MIT 许可与轻量部署让迁移成本保持在可接受范围内。
FastGPT:面向问答与工作流的知识库平台
FastGPT 是国内社区成长起来的开源知识库平台,最新版本 v4.15.7,GitHub Star 数 25.6k[17]。它的定位非常聚焦:围绕问答场景设计,尤其适合构建智能客服与企业内部问答系统。
FastGPT 的知识库亮点包括:支持 QA 拆分导入,把一问一答直接作为知识单元入库,对已有客服知识库沉淀的场景非常实用;检索支持混合检索加重排,保证召回质量;提供 Flow 可视化工作流,可以编排多轮澄清、知识库路由、转人工等复杂问答流程,用节点拖拽的方式拼装即可。部署方面,FastGPT 依赖 MongoDB 与 PostgreSQL(需 pgvector 扩展),组件比单容器平台多,需要留意其默认配置:服务端口 3000,默认账号 root/1234,部署完成后应及时修改默认口令。
许可证方面需要注意:FastGPT 采用自定义许可证,允许以商用后台服务的形式使用,但禁止基于它提供多租户 SaaS 服务。如果集成商性质的团队计划把 FastGPT 包装成标准 SaaS 产品售卖给多家客户,需要先评估许可证风险,再决定是否选用。
MaxKB:一条命令起步的企业知识库问答系统
MaxKB 最新版本 v2.10.5-lts,GitHub Star 数 22.5k,技术栈为 Django + LangChain + pgvector[18]。它最大的特点是部署简单:一条 docker run 命令即可启动服务,数据持久化在 ~/.maxkb 目录,随后通过 8080 端口访问管理界面,默认账号 admin/MaxKB@123..,首次登录务必修改密码。
MaxKB 的功能定位与 AnythingLLM 接近:聚焦文档上传、知识库构建、问答应用这条核心链路,提供知识库管理与应用搭建等基础能力,适合中小团队快速搭一个内部问答系统。它的短板同样明显:文档解析与检索调优的精细度有限,社区规模也小于头部项目。许可证方面,MaxKB 采用 GPL-3.0:单纯部署使用不受影响,但如果修改源码并对外分发修改后的版本,需要按 GPL 协议同样开源,有二开计划的公司应提前做法律评估。
12.3 横向对比:五大平台关键指标一览
前面的画像各自为政,这里把五个平台的关键指标放进同一张表横向比较。其中文档解析强度与工作流编排是实际选型中最容易拉开差距的两个维度,上手难度则综合考虑了部署复杂度、硬件要求与学习成本。
| 平台 | 最新版本 | GitHub Stars | 许可证 | 文档解析强度 | 工作流编排 | 上手难度 | 最适合场景 |
|---|---|---|---|---|---|---|---|
| Dify | v1.16.1 | 89.3k | Dify Open Source License(Apache 2.0 附加条件) | 中等,覆盖常见文本文档 | 强,可视化应用编排 | 低 | 全功能 LLM 应用平台,知识库与应用一站式管理 |
| RAGFlow | v0.26.4 | 88.4k | Apache 2.0 | 强,DeepDoc 支持扫描件、表格、图片等 | 中等 | 中高 | 复杂文档解析与引用溯源 |
| AnythingLLM | v1.15.0 | 64.7k | MIT | 中等 | 弱 | 很低 | 个人与小团队本地快速使用 |
| FastGPT | v4.15.7 | 25.6k | 自定义许可证(允许商用后台服务,禁 SaaS) | 中等 | 强,Flow 可视化工作流 | 中等 | 国内智能客服与问答系统 |
| MaxKB | v2.10.5-lts | 22.5k | GPL-3.0 | 中等 | 中等 | 低 | 快速部署企业知识库问答 |
读这张表有三点值得注意。第一,Star 数不直接等于适合度:Dify 与 RAGFlow 的 Star 数接近,但一个是全能应用平台,一个是专精 RAG 引擎,面向的痛点并不相同。第二,许可证最容易被忽略:五者中只有 RAGFlow 的 Apache 2.0 与 AnythingLLM 的 MIT 属于无附加条件的宽松许可;Dify 对多租户 SaaS 有附加条件,FastGPT 禁止 SaaS,MaxKB 的 GPL-3.0 对二开分发有传染性约束。第三,部署难度与硬件要求往往和功能复杂度正相关,团队要结合自己的运维能力权衡,不要为了用不上的功能背上过重的运维负担。
12.4 社区热度:GitHub Star 对比
Star 数是衡量开源项目社区活跃度最直观的指标之一,也间接反映了项目的维护投入与生态繁荣程度。截至 2026-08-18 核实,五大平台的 GitHub Star 数分别为:Dify 89.3k、RAGFlow 88.4k、AnythingLLM 64.7k、FastGPT 25.6k、MaxKB 22.5k。
从数据看,五个平台大致分为三个梯队:Dify 与 RAGFlow 以 88k 以上的 Star 数构成第一梯队,社区庞大、版本迭代快、文档与生态相对完善;AnythingLLM 以 64.7k 构成第二梯队,在本地部署场景热度稳固;FastGPT 与 MaxKB 处于第三梯队,规模较小但定位清晰,在各自的细分场景里都有稳定的使用者。需要强调的是,Star 数只能说明项目活着、有人维护、社区有热度,不能替代基于自身需求的选型判断——下一节给出真正的决策框架。
12.5 选型决策框架:从四个关键问题出发
面对五个平台,很容易挑花眼。这里给出一个简明的决策框架:选型前依次回答下面四个问题,候选范围会迅速收窄。前三个问题决定哪些平台能用,第四个问题决定哪个平台养得起。
问题一:文档格式有多复杂?
这是最重要的问题,因为解析质量决定知识库的上限。如果知识库以纯文本、Markdown、结构简单的 Word 文档为主,那么各平台的解析能力都够用,重点看其他维度;如果扫描件、复杂表格、含公式的 PDF、PPT 占比高,文档解析质量就直接决定召回结果的可用性,RAGFlow 的 DeepDoc 与模板化分块在这个维度优势明显,还可以进一步切换 MinerU、Docling 解析后端。这种情况下,其他平台的功能再全面,也补不回解析环节的先天损失。
问题二:需要工作流编排吗?
如果知识库只是简单的提问、检索、回答,各平台都能满足。但如果需要多轮澄清、按问题类型路由到不同知识库、对接工单系统、人机协同等复杂流程,就需要可视化工作流能力。Dify 与 FastGPT 在这个维度上较强,都提供节点式编排界面;RAGFlow 的重心在检索链路本身,工作流能力相对弱化。判断的方法很简单:把未来三个月业务方可能提出的流程需求列出来,看哪些平台能靠编排覆盖,哪些必须写代码补齐。
问题三:许可证是否允许你的商用方式?
这个问题常被留到上线前才回答,但应该最先确认。可以依次自问:是纯内部使用吗——五个平台都没有问题;是交付给单一客户的后台服务吗——注意 Dify 的附加条件与 FastGPT 的 SaaS 禁令边界;是对外提供多租户 SaaS 吗——优先 RAGFlow 的 Apache 2.0 或 AnythingLLM 的 MIT,Dify 与 FastGPT 需要法务确认;要修改源码并对外分发吗——注意 MaxKB 的 GPL-3.0 传染性开源要求。许可证问题早确认的成本很低,事后补救的成本极高。
许可证判断不能靠印象。Dify Open Source License 不等于纯粹的 Apache 2.0,对多租户 SaaS 等经营场景有附加限制;FastGPT 的自定义许可证允许商用后台服务但禁止 SaaS;MaxKB 的 GPL-3.0 在对外分发修改版本时要求对应代码开源。任何商用场景落地前,请完整阅读对应仓库中的 LICENSE 文本,并让法务或合规同事给出书面确认,不要依赖社区帖子里的转述。
问题四:团队的维护能力如何?
开源平台不是免维护的:版本升级、模型配置、数据备份、故障排查都需要人力投入。如果团队没有专职运维,优先 AnythingLLM(桌面端开箱即用)或 MaxKB(一条 docker run 命令起步);有一定 Docker 与 Linux 基础,Dify 的 Docker Compose 部署也能胜任;RAGFlow 对运维的要求最高——硬件配置要求高、需要调整系统参数、官方镜像仅支持 x86 架构,适合有专人维护的团队。把维护成本算进总成本,选型结论往往会和只看功能时不同。
最终拍板之前,用 10 到 20 份真实业务文档和真实用户问题,把候选平台的完整流程走一遍:上传、切块、检索、问答。一天的实测往往能暴露看文档一周发现不了的问题,比如特定格式解析出错、行业术语召回不理想、默认参数下答非所问。实测数据比任何对比表都更有说服力。
典型选型推荐
综合四个问题,给出四类可以直接对号入座的推荐。它们可以作为初选基线,但请记住:无论采纳哪一条,最终决策前都要用真实文档做检索测试,让召回效果说话。
推荐一:复杂文档加引用溯源,选 RAGFlow。核心痛点是扫描件、表格、复杂版面多,且回答必须能追溯到原文出处时,RAGFlow 是首选。DeepDoc 解析、可视化分块编辑、多路召回加融合重排都为这个场景设计,Apache 2.0 许可证对商用友好。代价是部署与运维要求高,请预留至少 4 核 16GB 的资源与 x86 环境。
推荐二:全功能应用平台,选 Dify。不仅需要知识库,还要快速构建、发布、迭代各类 LLM 应用,并统一以 API 形式对外提供服务时,Dify 是最均衡的选择。它的知识库能力足够完整——多路召回、重排、元数据过滤、检索测试一应俱全,应用编排与 API 发布又补齐了闭环。注意其许可证对多租户 SaaS 有附加条件。
推荐三:个人或小团队快速用起来,选 AnythingLLM 或 MaxKB。目标是先用起来、以最小成本验证知识库价值时:AnythingLLM 桌面端门槛最低,MIT 许可证无负担,适合本地与小规模场景;MaxKB 一条命令启动,基础功能完整,适合快速搭建内部问答系统。注意 MaxKB 为 GPL-3.0,有二开分发计划需先评估。
推荐四:国内客服场景,选 FastGPT。构建智能客服或内部问答系统,尤其已有问答对数据沉淀时,FastGPT 的 QA 拆分导入、混合检索加重排、Flow 可视化工作流都针对这个场景设计,中文社区资料也相对友好。注意其许可证禁止对外提供多租户 SaaS。
最后建议把选型结论沉淀成一页纸的决策记录:写清楚选了哪个平台、四个关键问题的答案是什么、实测用了哪些文档与问题、许可证结论由谁确认。这份记录在半年后升级版本、一年后复盘扩容时都会派上用场,也能避免团队成员更替后选型理由失传、推倒重来的浪费。
12.6 本章小结
本章回答了三个问题。第一,为什么用开源知识库平台:平台把解析、切块、检索、重排、界面等通用环节打包,团队得以把有限精力留给业务知识与应用场景;而深度定制、合规要求、嵌入既有系统三类场景仍适合自研或深度二开。第二,五大平台各自是什么样:Dify 是全功能应用平台,RAGFlow 是深度文档理解专家,AnythingLLM 本地优先开箱即用,FastGPT 面向问答工作流,MaxKB 一条命令起步。第三,如何选型:依次回答文档格式复杂度、工作流需求、许可证约束、团队维护能力四个问题,再对照典型推荐做出初选,并用真实文档实测验证。
选型只是起点,接下来两章进入实战。第 13 章以 Dify 为例,完整走一遍从部署、创建知识库、调优召回到发布 API 的全流程;第 14 章实战 RAGFlow。即使你的选型结论是其他平台,这两章的方法论同样通用——前十一章学到的知识库原理,是所有平台共同的底座。
Dify 实战
上一章的选型框架中,Dify 凭借全面的功能与较低的部署门槛,成为许多团队的第一选择。本章以 Dify v1.16.1 为基准,完整走一遍实战流程:从 Docker Compose 部署、创建第一个知识库,到召回参数深度调优、把知识库接入应用,最后发布为可供业务系统调用的 API。读完本章,你应该能够独立交付一个可被业务系统调用的知识库问答服务,并掌握持续调优召回效果的方法。
13.1 部署:从零到初始化页面
硬件与环境要求
Dify 的最低部署门槛在五大平台中属于偏低的一档:CPU 2 核、RAM 4GiB 即可启动整套服务,Docker Compose 版本要求 v2.24.0 及以上[14]。这个配置足以支撑开发验证与小规模试用;若面向生产环境,建议根据文档规模与并发量预留更充裕的内存,并为数据库规划独立的备份策略。部署之前,先确认本机的 Docker 环境是否满足要求。
# 查看 Docker 版本
docker --version
# 查看 Docker Compose 版本,需为 v2.24.0 及以上
docker compose version
使用 Docker Compose 启动
Dify 官方提供完整的 Docker Compose 部署方案:平台所需的全部服务都在编排文件中定义,一条命令即可拉起,无需逐个安装和配置依赖。整个流程只有四步:进入仓库的 docker 目录、复制环境变量文件、启动服务、访问初始化页面。
# 1. 进入仓库中的 docker 部署目录
cd dify/docker
# 2. 复制环境变量文件(保留 .env.example 以便对照)
cp .env.example .env
# 3. 后台启动所有服务
docker compose up -d
# 4. 全部容器启动后,在浏览器访问初始化页面
# http://localhost/install
服务启动后不要急着打开浏览器,先在部署目录用 docker compose ps 查看各容器状态,确认全部处于 Up 状态再访问页面。个别容器反复重启通常意味着资源不足或端口冲突,可结合日志定位。首次访问 http://localhost/install 会进入初始化界面,在这里设置管理员账号的邮箱与密码。登录管理界面后,建议第一件事就进入模型供应商页面,至少配置一个可用的模型供应商:知识库向量化需要 Embedding 模型,应用问答需要对话模型,缺少任何一类配置,后续步骤都会在执行时报错。这一步常被新手遗漏,值得特别强调。
三个常见启动问题及处理思路。一是端口占用:Dify 默认会占用 80 等端口,若宿主机已有其他 Web 服务,启动时会报端口冲突,此时打开编排文件,把冲突服务的主机侧端口映射改掉(例如把 80 改为 8080)再重新启动即可。二是模型供应商未配置:部署完成不等于功能可用,创建知识库或应用前必须先在模型供应商页面填入至少一个供应商的 API Key,否则向量化与问答都会失败。三是版本过低:Docker Compose 低于 v2.24.0 时部分配置项可能无法识别,先升级 Docker 或 Compose 插件,再执行部署流程。
13.2 创建第一个知识库
上传文档
在管理界面点击知识库、创建知识库,进入文档上传页面。把整理好的业务文档上传进来,页面会展示每个文件的解析状态。解析失败的文件通常源于加密、损坏或暂不支持的格式,可以从状态列定位后单独处理。建议首次建库时先用一小批有代表性的文档试跑,确认解析与切块效果符合预期,再批量导入全量文档,避免一次性导入后大规模返工。
分段设置:自动与自定义
解析完成后进入分段设置页面,Dify 提供自动与自定义两种方式。自动模式按默认参数执行递归文本切分,适合快速起步;自定义模式可以手动设置分段长度与重叠(Overlap)。这两个参数在第 7 章详细讲过:递归切分沿段落、句子等分隔符逐层拆分,尽量保持语义完整;重叠则让相邻分段保留一段重复文本,避免关键信息恰好被切断在分段边界上而漏召回。例如分段长度设为 500、重叠设为 50,每个分段的末尾 50 个单位文本会出现在下一个分段的开头,相当于给边界信息加了一道保险。
实操建议:普通文档先用自动模式跑通全流程,通过检索测试与实际问答观察分段质量;只有当发现分段过长导致语义稀释、检索不准,或分段过短导致上下文破碎时,再切换自定义模式针对性调整。不要陷入先调参再上传的误区——切块策略的好坏最终由召回效果检验,而不是参数看起来是否精致。
索引模式:High Quality 与 Economical
分段设置之后要选择索引模式,这是影响后续检索能力的关键决策[15]。Dify 提供两种模式:High Quality(高质量)模式调用 Embedding 模型对分段做向量化,支持语义检索与混合检索;Economical(经济)模式不调用 Embedding 模型,采用关键词倒排索引组织分段,省去向量化开销。
生产环境统一选择 High Quality 模式。Economical 模式的吸引力在于省 Embedding 费用:文档无需向量化即可供检索。但关键词倒排索引只能做字面匹配,对同义改写、近义表述、口语化提问基本无能为力,召回质量与语义检索差距明显。而 Embedding 费用在知识库总成本中占比很小——文档只在上传时向量化一次,之后的每次检索都不再产生向量化开销。为了节省一笔一次性的小费用,牺牲决定知识库生死存亡的召回质量,是典型的得不偿失。Economical 模式更适合临时数据快速预览链路、或明确只需字面匹配的场景。
处理完成与查看分段
选定索引模式并确认后,知识库进入处理队列,列表页可以看到每个文档的处理进度。处理完成后,点开任意文档即可查看分段结果:每个分段的内容、长度、被命中次数都清晰可见。这个界面在后续调优中会反复用到——当检索结果不理想时,第一步往往不是改检索参数,而是回到这里检查分段本身是否合理:切块是否把表格切散、标题是否与正文分离、关键段落是否完整。分段是召回的地基,地基不牢,上层参数调得再精细也无济于事。
13.3 召回设置详解
知识库建好之后,真正的调优工作才开始。Dify 把完整的召回参数集开放给了界面:检索模式、Rerank 模型、TopK、Score Threshold、元数据过滤,都可以在应用的上下文设置中配置,并用检索测试即时验证。本节逐一拆解这些参数,它们直接决定最终答案的质量上限。
三种检索模式对比
Dify 支持三种检索模式:语义检索用查询向量寻找语义相近的分段,擅长处理同义改写与口语化提问;关键词检索做字面匹配,对专有名词、编号、条款号这类精确词元命中率高;加权混合同时运行两路,按配置的权重融合结果,兼顾语义理解与字面精准。权重可以调整:业务查询中精确词元多(如产品编码、条款编号),就调高关键词一路的权重;以自然语言提问为主,就调高语义一路的权重。
| 模式 | 原理 | 优势 | 局限 | 适用场景 |
|---|---|---|---|---|
| 语义检索 | 向量相似度匹配 | 理解同义改写与语义近似 | 可能漏掉字面精确词元 | 自然语言提问、口语化查询 |
| 关键词检索 | 关键词字面匹配 | 专有名词、编号、条款命中精准 | 不理解同义改写 | 合同条款、型号编码、精确术语查询 |
| 加权混合 | 两路并行,按权重融合 | 兼顾语义与字面,权重可调 | 需要调权重 | 大多数生产场景,建议默认选用 |
Rerank 配置:加权评分与外部重排模型
多路召回融合出候选集之后,融合分数并不完全等于相关性排序,Dify 因此提供 Rerank 能力,有两种形式。其一是加权评分:按配置的权重对多路召回结果计算综合得分,不产生额外费用,适合轻量场景。其二是接入外部 Rerank 模型:目前支持 Cohere、Jina 等供应商,用专门的重排模型对候选分段逐一精细打分,排序质量明显更好。使用外部 Rerank 模型需要先在模型供应商页面配置对应厂商的 API Key;每次检索都会调用一次 Rerank 接口,会带来少量延迟与费用,建议对答案质量要求高的场景开启。重排的工作原理在第 11 章已经详细讲过,这里只需记住结论:Rerank 是提升头部 K 条结果质量性价比最高的手段。
TopK 与 Score Threshold:两个关键旋钮
TopK 决定最终送入大模型的分段数量,Dify 默认值为 3;Score Threshold(分数阈值)决定分段被召回的最低相关性分数,默认值为 0.5。两个参数共同决定了上下文的质量与成本:TopK 过大,无关内容稀释关键信息、推高 token 成本;过小,可能漏掉答案所在分段。阈值过低,不相关分段混入上下文;过高,有用分段被挡在门外。
Score Threshold 本质上是精度与召回之间的平衡旋钮。阈值设得太低,无关分段混进上下文,模型容易被噪声带偏,表现为自信地答错;阈值设得太高,查询与文档表述差异稍大时有用分段就被过滤,模型因缺少上下文只能回答不知道,表现为明明有文档却查不到。实操调法:从默认值 0.5 出发,收集两类案例——检索测试与真实用户问题中的答错案例和答不出案例。答错多,说明噪音混入,阈值适当调高;答不出多,说明召回不足,阈值适当调低。如此往返迭代三五轮,通常就能逼近适合自身业务的平衡点。
元数据过滤
Dify 支持为分段设置元数据,并在检索时按元数据过滤,支持的类型包括字符串、数字、日期,条件之间支持 AND 与 OR 组合。举个例子:知识库存放多年合同,可以给每个文档打上签订年份、合同类型等元数据,检索时附加过滤条件,只召回指定年份与类型的合同。元数据过滤相当于在向量检索之前加了一层结构化的筛选条件,在文档属性明确的场景下能显著提升精度,排除无关文档的干扰,还顺带降低了 TopK 被无关内容占用的概率。
用检索测试验证
以上所有参数都不需要盲调——Dify 提供检索测试功能:输入一条查询,立刻看到当前配置下召回了哪些分段、各自的得分与排序。切换检索模式、调整权重、开关 Rerank、修改 TopK 与阈值,都可以即时对比召回结果的变化,形成改参数、跑测试、看差异的完整闭环。建议把常见业务问题整理成一份固定的测试查询清单,每次调整参数后统一回归,避免改好一处、弄坏另一处。
检索测试让召回调优不需要写一行代码。自建方案里,验证一次参数变更的影响往往要写脚本、准备查询集、跑对比实验,一轮迭代动辄半天;在 Dify 里,修改配置后立即能看到分段级别的召回结果,业务人员也能直接参与调优——他们往往比工程师更清楚哪个答案本该被找到。这种低门槛的试错能力,把调优周期从以周计压缩到以小时计,是平台化方案最实在的红利之一。
13.4 把知识库接入应用
方式一:聊天助手一键关联
最快的接入方式:创建一个聊天助手类型的应用,在上下文设置中勾选要关联的知识库即可。用户提问时,Dify 自动完成检索、上下文注入、大模型生成的完整流程,还可以在回答中开启引用归属,展示答案依据的分段与来源文档。标准问答场景用这种方式几分钟即可上线,上一节讲的所有召回参数也都在这个页面完成设置,无需任何开发工作。
方式二:工作流编排
需要更细粒度控制时,使用 Dify 的工作流应用:在编排画布上添加知识检索节点,配置要关联的知识库与检索参数;检索结果传给下游的 LLM 节点,结合提示词生成回答;再往后可以接条件分支、文本处理、外部工具调用等节点。一个典型结构是:开始节点接收用户问题,知识检索节点召回分段,LLM 节点基于分段与提示词生成回答,结束节点输出并携带引用信息。相比一键关联,工作流可以实现按问题类型判断是否检索、并行检索多个知识库再合并结果、置信度不足时转人工等复杂逻辑。
两种方式的取舍
取舍标准很简单:业务是单轮或简单多轮问答、没有复杂分支逻辑,用一键关联,维护成本最低;需要问题路由、多库编排、人机协同、对接外部系统,或希望显式控制检索与生成的每一个环节,用工作流编排。两者的差异可以概括为一句话:一键关联买的是速度,工作流编排买的是控制力。实践中常见的路径是:先用一键关联快速验证业务价值,业务复杂后再迁移到工作流——知识库本身可以原样复用,迁移成本主要在编排逻辑的重新搭建,前期在召回调优上的投入不会浪费。无论哪种方式,都建议开启引用归属:它不仅方便用户核对答案出处,更让团队在排查坏案例时能直接定位到具体分段,是调优闭环里不可或缺的一环。
13.5 发布为 API
应用调试完成后,就可以通过 API 接入业务系统。在应用的访问 API 页面可以查看接口文档并创建 API Key。Dify 的应用以 REST 风格接口统一对外,聊天助手对应 chat-messages 接口。下面用 curl 演示一次典型的流式调用,字段含义与最新接口规范以你所用版本的官方文档为准。
# 将 your-dify-host 替换为实际服务地址
# 将 app-xxxx 替换为在访问 API 页面创建的 API Key
curl -X POST 'https://your-dify-host/v1/chat-messages' \
-H 'Authorization: Bearer app-xxxx' \
-H 'Content-Type: application/json' \
-d '{
"inputs": {},
"query": "差旅费报销流程是怎样的?",
"response_mode": "streaming",
"user": "user-001"
}'
请求的几个关键点:query 是用户问题;response_mode 支持 streaming 流式与 blocking 阻塞两种,流式以 SSE 方式逐段返回内容,适合前端实现打字机效果,阻塞式等完整答案生成后一次返回,适合后端同步调用;user 是终端用户标识,用于会话隔离与用量统计。应用中开启的知识库与引用归属,会随响应一并返回引用信息,可用于在前端展示答案出处。完整字段、错误码与限流规则,请以管理界面访问 API 页中的官方文档为准。
API Key 相当于应用的钥匙,不要写进前端代码,也不要提交到代码仓库——一旦泄露,任何人都能以你的额度调用应用。生产环境建议由后端服务统一代理调用:密钥只保存在服务端,代理层顺便承担鉴权、限流与审计职责。团队多人协作时,还应在流程上约定 Key 的申请与保管责任人,避免散落在各处无人管理。
13.6 进阶能力简介
父子分段召回
父子分块的思想在第 7 章讲过:用较小的子分段做检索以保证命中精度,命中后返回其所属的较大父分段给大模型以保证上下文完整。Dify 的知识库支持父子分段召回:检索按子分段粒度匹配,召回结果携带父分段内容。这一策略对长文档尤其合适——小块让向量检索更准,大块让模型看到更完整的语境,精度与完整性兼得。当你在检索测试中发现命中位置正确、但答案所需上下文总差一点时,优先考虑启用父子分段,而不是继续加大 TopK。
知识处理流水线
Dify 支持编排知识处理流水线,把编排能力用在文档入库前的处理环节:以节点方式定义处理流程,在原始文档与最终分段之间插入清洗、改写、打标等步骤。例如切块前先统一规范格式,或为每个分段自动生成摘要类元数据。这一能力让知识处理变成可复用、可调整的工程化流水线,而不是依赖一次性手工脚本。对文档量大、入库频繁的场景,流水线能显著降低人工修正分段的成本,也让知识处理过程变得可审计。
外部知识库接入
除了自建知识库,Dify 还支持通过 API 接入外部知识库:把已有的检索系统、向量库或其他平台上的知识库连接进来,作为上下文来源之一参与召回与生成。对已有检索基础设施的企业,这非常实用——不必把全部数据迁移进 Dify,按接口约定对接存量系统,即可在 Dify 中统一管理应用与知识来源。接入方式与字段规范以官方文档为准,建议在接入前先明确双方的更新频率与权限边界。
这三个进阶能力对应知识库演进的三个典型阶段:文档变长、上下文不够用时,上父子分段;入库量变大、人工处理跟不上时,上知识处理流水线;组织内已有检索资产、不想重复建设时,上外部知识库接入。按这个顺序渐进引入,既不增加早期复杂度,也为后续扩展留好了接口。
13.7 本章小结
本章走完了 Dify 的完整实战链路。部署环节,用最低 2 核 4GiB 的资源与 Docker Compose 四条命令拉起服务并完成初始化,记住先配置模型供应商再谈功能。知识库环节,完成了上传文档、分段设置与索引模式选择,明确生产环境统一使用 High Quality 模式。调优环节,掌握了三种检索模式的取舍、外部 Rerank 模型的接入、TopK 与 Score Threshold 的迭代调法、元数据过滤,以及用检索测试构建调优闭环。接入环节,对比了一键关联与工作流编排两种方式的适用边界。发布环节,通过 API Key 与 chat-messages 接口把应用交给业务系统,并强调了密钥安全。
Dify 的价值在于把知识库的调优回路缩得足够短:改参数即时可验证,出问题能定位到具体分段。但平台只是放大器,真正决定效果的依然是文档组织质量、切块策略,以及基于真实问题持续调优的耐心。下一章进入 RAGFlow 实战,看深度文档解析如何驯服扫描件与复杂表格这类硬骨头。
RAGFlow 实战:深度文档理解驱动的知识库
前面几章,我们从零搭建过 RAG 流水线,也用低代码平台做过应用编排。从本章开始进入平台篇:直接站在成熟开源平台的肩膀上,把精力留给数据治理与业务落地。第一个主角是 RAGFlow——一个把深度文档理解做成核心竞争力的开源 RAG 引擎。本章从部署讲起,依次拆解它的深度解析、分块模板、可视化编辑与引用溯源、检索配置,最后巡礼一组进阶能力。
14.1 部署:先跨过硬件与内核参数两道门槛
RAGFlow 是当前社区热度最高的开源 RAG 引擎之一。截至 v0.26.4 版本(2026-07-07 发布),其仓库 Stars 数已达 88.4k,采用 Apache 2.0 许可证,商用路径清晰[16]。它把文档解析、分块、索引、检索、编排与可视化界面打包成一个开箱即用的整体:个人开发者可以用它快速验证想法,企业团队可以用它建设正式的知识库系统。本章全部实操基于 v0.26.4 展开。
RAGFlow 官方提供 Docker Compose 一键部署,但整套系统组件较多,对机器资源有明确要求。官方给出的最低硬件门槛是:CPU 不低于 4 核,内存不低于 16GB,磁盘不低于 50GB,Docker 版本不低于 24.0。这个配置比许多单容器知识库产品高出一截,初次接触的同学可能觉得夸张。理解 RAGFlow 的内部构成之后,就会明白这些资源并没有被浪费。需要说明的是,这是保证系统跑起来的下限,而非体验良好的推荐值;文档规模大、并发用户多的团队,建议在此之上留出余量。
| 项目 | 最低要求 | 消耗来源 |
|---|---|---|
| CPU | 4 核 | 文档解析与索引构建均为计算密集型任务 |
| 内存 | 16GB | 解析模型与 Elasticsearch 引擎共同消耗 |
| 磁盘 | 50GB | 存放文档原件、切块索引与图片资源 |
| Docker | 24.0 及以上 | 官方给定的运行环境要求 |
RAGFlow 吃资源,主要有两个原因。其一,它内置自研的 DeepDoc 深度文档理解模块,包含版面识别、表格结构还原、OCR 等一系列模型,模型推理本身就要消耗可观的内存与算力。其二,文档索引与检索由 Elasticsearch 引擎承担,ES 对内存与虚拟内存区域数量都有硬性要求,资源不足时索引构建会直接失败。换句话说,16GB 内存喂的是解析模型与搜索引擎两张嘴,50GB 磁盘则要为文档原件、切块索引和图片资源留出余量。这些资源换来的是解析与检索质量的上限,是一笔值得的投入。
拉取代码之前,必须先设置 Linux 内核参数 vm.max_map_count。Elasticsearch 要求该值不低于 262144,而多数发行版的默认值远低于这个数。若不提前修改,compose 拉起的 ES 容器会反复重启甚至直接退出,现象常被误判为镜像或网络问题。设置命令如下,需要 root 权限,执行后立即生效:
sysctl -w vm.max_map_count=262144内核参数就绪后,按照克隆仓库、切换标签、启动编排三步完成部署。建议显式切换到 v0.26.4 标签再启动,避免主干代码变动带来的不确定性:
# 1. 克隆官方仓库
git clone https://github.com/infiniflow/ragflow.git
# 2. 进入 docker 目录,切换到 v0.26.4 标签
cd ragflow/docker
git checkout v0.26.4
# 3. 启动全部服务(首次需要拉取镜像,请耐心等待)
docker compose -f docker-compose.yml up -dcompose 会拉起文档解析、索引存储、Web 服务等多个容器,彼此通过内部网络连接,首次启动需要几分钟完成初始化与镜像预热。判断是否就绪的最简单方式是 docker compose ps:各容器均处于运行状态后,用浏览器访问宿主机的 Web 端口即可进入 RAGFlow 界面,注册账号后即可看到知识库管理入口。个别容器重启一两次不必惊慌,用 docker compose logs 观察日志即可定位。
文档量大、扫描件多的团队,还可以为 DeepDoc 配置 GPU 加速。版面识别与 OCR 都是适合 GPU 并行推理的任务,加速后摄取吞吐显著提升,原本需要数小时的批量入库可以压缩到一小时内完成。是否配置 GPU 不影响功能完整性,只影响摄取速度,可以等基础流程跑通后再按需追加。
RAGFlow 官方镜像仅提供 x86(amd64)架构版本。如果使用 ARM 机器,例如部分国产化服务器或 ARM 云主机,直接 compose 会因拉取不到镜像而失败,需要自行从源码构建镜像后再部署。建议在选型评估阶段就确认 CPU 架构,避免机器到位后才发现无法运行。另外,sysctl -w 设置的参数在系统重启后会失效,长期运行的服务器要把这一步纳入开机初始化流程。
14.2 DeepDoc 深度解析:复杂文档的第一道防线
RAGFlow 最核心的差异化能力,是名为 DeepDoc 的深度文档理解模块。它覆盖的输入类型非常全面:Word、PPT、Excel、TXT 等办公文档,图片与扫描件,结构化数据,乃至网页内容,都可以进入同一条摄取流水线。对知识库项目来说,这意味着一个入口吃下所有资料,不必为每种格式单独维护一套解析脚本,也不必在格式转换上消耗工程人力。
DeepDoc 真正的强项在于复杂版面的处理。双栏排版的论文与杂志,它会先做版面识别,还原文字的真实阅读顺序,而不是按坐标生硬地逐行拼接;表格会做结构还原,行、列与跨单元格的关系得以保留,避免表格被读成一锅乱码;扫描件先经 OCR 转为文本,再走同样的版面分析流程;页眉、页脚、页码、脚注这类噪声则被识别并剔除,不会混入正文分块。这些能力直接决定了后续分块与检索的质量下限。
结构化数据与网页是容易被忽视的两类输入。Excel 与 CSV 这类结构化资料,DeepDoc 按表结构理解,字段与记录的对应关系不会丢失;网页内容进入流水线时,正文与导航栏、广告位会被区分对待,只有有效内容参与分块。对文档来源复杂的组织,这一覆盖面意味着知识库建设不必挑文档:先全部纳管,再按价值分层治理。
回顾第 7 章,我们练习过通用的文档解析工具:它们灵活、轻量,处理格式规整的文档得心应手,但面对复杂版面时需要自己写规则、调参数,OCR 与版面分析往往要拼接多个库才能勉强可用,后续还要自己维护。DeepDoc 把这些工程细节封装成平台内置能力,开箱即用,解析结果直接对接分块模板与可视化编辑,形成完整闭环。对没有专职算法团队的中小企业,这种封装的价值尤其明显。
解析质量是 RAGFlow 的护城河。RAG 领域有一句经验之谈:垃圾进,垃圾出。再强的向量模型与重排模型,也救不回被解析切碎的表格、乱序的双栏文本和混入正文的页眉。RAGFlow 把最重的投入放在文档理解这一层,用版面模型、表格还原与 OCR 的组合把原材料加工到位,后面的分块、检索与生成才能站在高质量语料之上。评估任何知识库平台时,都建议把复杂文档的解析效果作为第一考察项,原因正在于此。
14.3 分块模板:把领域最佳实践变成下拉框
创建知识库时,RAGFlow 要求选择一个分块模板:General、Q&A、Laws、Paper、Book、Manual 六选一。模板决定了解析与切分的策略——按什么结构切、切多大、保留哪些上下文。这相当于把分块策略这个最影响检索效果的环节,从手写配置变成了一道场景选择题。
| 模板 | 适合的文档类型 | 处理要点 |
|---|---|---|
| General | 产品说明、技术文档、报告等通用文档 | 通用切分策略,兼顾段落与版面结构,多数场景的默认选择 |
| Q&A | FAQ、客服问答对、答疑整理 | 识别一问一答结构,每个问答对作为独立分块 |
| Laws | 法律、法规、规章制度 | 沿条款结构切分,保留条款编号与层级 |
| Paper | 学术论文 | 识别摘要、章节、图表与参考文献结构 |
| Book | 书籍、长篇读物 | 按章节目录层级切分,保留章节上下文 |
| Manual | 操作手册、维护手册 | 按步骤与节切分,保证操作步骤的完整性 |
选择模板的思路很简单:让文档体裁决定切法。问答类语料选 Q&A,检索时问题与问题直接匹配,命中率远高于把答案埋在长段落里;法律法规选 Laws,按条款切分后,第几条第几款能被完整召回;论文选 Paper,摘要、章节、参考文献各得其所;书籍选 Book,沿章节层级切分;操作手册选 Manual,保证步骤不被拦腰切断。体裁不明显或多种体裁混合时,用 General 兜底。
同一个 RAGFlow 实例里可以创建多个知识库,各自绑定不同的分块模板,互不干扰。实践中常见的组织方式是按文档体裁建库:一个 Q&A 库收拢全部问答语料,一个 Manual 库收拢各类手册,一个 General 库兜底其余资料;应用侧再按需挂载组合。这种组织方式让每个库的分块策略都保持在最合适的状态,也方便按库划分维护责任。
使用模板时有一个常见误区:把模板当成一次性设置,之后便不再过问。模板决定的是切分策略,语料本身的质量与构成却在持续变化。建议每隔一段时间抽查各库的分块结果,若发现某类新文档与当前模板不匹配,就为它单独建库、换用更合适的模板,而不是让不合适的策略继续生效。
分块模板的本质,是把沉淀好的领域分块最佳实践交到用户手上。自己从零调分块,要反复试验切分粒度、重叠长度与结构识别规则,一个文档类型一个文档类型地踩坑;模板把这些经验固化成默认策略,新建知识库时勾选一下即可。对多数团队来说,这等于免去了最耗时的调参环节,把分块做得对不对从能力问题变成了选择题。
14.4 可视化编辑与引用溯源:让知识库可核验
文档摄取完成后,RAGFlow 提供分块结果可视化:每个分块的内容与它在原文中的位置都以可视化方式呈现,可以像审阅稿件一样逐块检查。发现问题的处理路径也很直接——在界面上对分块进行人工编辑干预,修正错误、删除噪声,确认无误后再让知识库对外提供服务。
人工修正分块的典型流程分四步。第一步,在知识库的分块列表里浏览或搜索,定位可疑分块,例如被表格线切断的句子、混入页眉的噪声段。第二步,直接编辑分块文本,改正识别错误,补全缺失的上下文。第三步,对整块无效的内容执行删除,避免它参与召回、污染答案。第四步,保存修改,让修正后的分块重新进入索引。整个流程不需要写代码,熟悉业务的运营人员即可操作。
回答侧,RAGFlow 提供关键参考快速预览与可追溯引用:模型给出的回答会标注来源分块,点开即可预览原文出处。这一设计带来两个直接收益。其一,降低幻觉——回答被约束在可查证的参考资料上,无据可依的内容更容易暴露。其二,方便核验——业务人员可以顺着引用回溯原文,判断回答是否可信;出错时也能快速定位是语料问题、分块问题还是检索问题,而不是对着错误答案无从下手。
对企业用户,建议把引用核验写进知识库的运营规范:抽检回答时不只看语句是否通顺,更要逐条点开引用,确认出处真实支撑结论;发现引用与回答不符,立即回到分块层修正。坚持一段时间,团队会积累起一份高置信度的问答样本,它们反过来又能作为检索测试与效果评估的基准数据,形成质量改进的正循环。
人在回路是企业级质量的保证。纯自动化的流水线在演示环境里很好看,但企业知识库里存在扫描件识别错误、格式异常、表述歧义等长尾问题,任何模型都无法保证百分之百正确。RAGFlow 把人工编辑干预与引用溯源做进产品,本质是承认自动化的边界,并为人工介入留好位置:分块可以改,出处可以查,错误可以追责。对金融、法务、政务这类对准确性要求极高的场景,这种可核验性往往比任何模型指标都更有说服力。
14.5 检索配置:多路召回与融合重排序
14.5.1 理解多路召回与融合重排序
RAGFlow 的检索采用多路召回加融合重排序的组合。多路召回指同时启用多条检索路径:向量语义检索擅长理解同义改写,全文关键词检索擅长命中专有名词、编号与型号,两路各自取回一批候选分块。融合重排序再把多路候选合并,用重排模型对查询与分块的相关性统一打分,重新排序后取头部结果送入大模型。这样既补上了单路检索的盲区,也压低了送入上下文窗口的噪声。
这套组合的价值在真实查询中很容易体会。用户问年假未休完怎么补偿,向量检索能关联到未休年休假工资报酬的条款;用户问某个国家标准编号的具体要求,关键词检索能精准命中对应条目。单靠任何一路都会有漏网之鱼,融合之后召回率与准确率同时受益。理解了这个机制,后面调参时才知道每个参数在影响哪一路。
14.5.2 用检索测试验证配置
配置是否合理,用 RAGFlow 内置的检索测试来验证。操作分三步:第一步,在知识库的检索测试入口输入代表性查询,尽量覆盖业务里的典型问法;第二步,观察返回的分块列表,检查目标分块是否排在前列、内容是否完整;第三步,针对不理想的查询调整召回数量、相似度阈值与重排设置,然后重测。调参时一次只动一个变量,才能分清是哪个设置起了作用。
建议把十几条高频问题整理成固定测试集,每次调整语料、分块模板或检索参数后都回归一遍,用结果说话。测试时要记录每条查询的预期分块,方便团队交接与复盘。检索调优没有一劳永逸的最优解:语料在变,查询分布在变,配置也要跟着迭代,固定测试集就是迭代时的方向盘。
解读检索测试结果时,还有一个容易被忽略的维度:分块的可视预览。RAGFlow 的测试返回同样附带出处信息,若某条查询召回的分块看似相关、细读却答非所问,多半是分块本身混入了噪声或丢失了上下文,此时应回到分块层修正,而不是一味调高召回数量。检索与分块从来是一个整体,测试就是两者之间的体检。
14.6 进阶能力巡礼
除了解析、分块与检索这些主线能力,RAGFlow 还有一组进阶特性。它们不一定在每个项目里都用得上,但了解其存在与适用场景,能帮你在需求升级时快速找到抓手。本节逐一简述。
14.6.1 GraphRAG 与 RAPTOR
RAGFlow 支持 GraphRAG 与 RAPTOR 两类进阶检索方法。GraphRAG 从语料中抽取实体与关系、构建知识图谱,适合回答需要跨文档关联推理的问题,例如多个制度条款之间的交叉约束;RAPTOR 通过自底向上的摘要树组织语料,适合长文档的主题式、层次式检索。两者都建议在基础流水线跑通之后,针对特定知识库按需开启,用真实问题对比它们与常规检索的实际增益,再决定去留。进阶方法不是越全越好,能解决真实问题的才是好方法。
14.6.2 MinerU 与 Docling:可切换的解析方法
DeepDoc 之外,RAGFlow 允许为摄取流水线切换解析方法,目前支持 MinerU 与 Docling。三个解析器各有擅长:DeepDoc 综合均衡,MinerU 在学术类文档上表现突出,Docling 对部分办公格式有独到处理。实践中可以为同一批文档分别试用不同解析方法,比较分块结果后择优。这种可替换设计避免了在一条解析路线上走到黑的锁定风险,也让平台能持续吸收解析领域的最新进展。
14.6.3 Agent 工作流与 MCP
RAGFlow 内置 Agent 工作流能力,可以把知识库检索、大模型调用与工具调用编排成可视化流程,同时支持 MCP 协议与外部工具生态互通。这意味着 RAGFlow 不只是一个问答后台,还能作为智能体的推理与知识底座,接入更长的业务流程。对已经把它用作知识库的团队,工作流能力让知识问答自然延伸为业务自动化,无需再引入第二套编排平台,知识资产与流程资产沉淀在同一处。
14.6.4 数据源同步与可编排摄取流水线
企业文档通常散落在各个系统里。RAGFlow 支持从 Confluence、S3、Notion、Google Drive 等数据源同步文档,让知识库跟着源头资料自动更新,免去人工导出导入的重复劳动。配合可编排的摄取流水线——解析方法、分块模板、索引构建按步骤组装——团队可以把资料入库变成一条可复现的自动化产线:新文档落入数据源,自动解析、自动分块、自动入库,人只负责抽检与修正。
14.7 本章小结
本章以 v0.26.4 为例,完整走了一遍 RAGFlow 实战。部署层面,它要求 4 核 16GB 起步的硬件与 vm.max_map_count 内核参数,官方镜像仅覆盖 x86 架构;能力层面,DeepDoc 深度解析构成质量护城河,六个分块模板把领域最佳实践产品化,可视化编辑与可追溯引用让知识库可核验;检索层面,多路召回加融合重排序兼顾语义与关键词;进阶层面,GraphRAG、RAPTOR、MinerU 与 Docling、Agent 工作流、MCP 与数据源同步提供了充足的成长空间。
从工程视角看,RAGFlow 给出的启示同样清晰:把解析做重、把分块做模板化、把人工干预做进产品、把检索调优做成可回归的流程。这四件事,即使不用 RAGFlow,也值得搬进你自己的 RAG 系统。回顾本章要点:
- 资源门槛的本质是解析模型与 ES 引擎的开销,先设内核参数再 compose;
- 复杂文档先过 DeepDoc,解析质量决定全局上限;
- 按文档体裁选择分块模板,用模板代替手工调参;
- 分块可编辑、引用可溯源,人在回路保证企业级质量;
- 用固定测试集驱动检索调优,让每次改动都有据可依。
下一章,我们看另外两条同样值得关注的开源路线——FastGPT 与 MaxKB。
FastGPT 与 MaxKB:两条快速落地的开源路线
上一章的 RAGFlow 以深度解析见长。本章介绍另外两个常被放在一起比较的开源平台:FastGPT 与 MaxKB。两者都以快速落地为核心诉求,却走了两条不同的路线——前者深耕知识库问答与可视化工作流,后者定位企业级智能体平台。本章分别完成部署与实操,章末给出一份三者选型对照,顺带介绍个人与小团队的轻量选项 AnythingLLM。
15.1 两个平台的共同点与差异
先看共同点。FastGPT 与 MaxKB 都是开源项目,都以 Docker 方式交付,都能在普通服务器上一小时内跑起一套可用的知识库问答系统。两者都内置知识库管理、检索配置与应用编排能力,都支持 MCP 协议与外部工具生态互通,也都把部署门槛压缩到几条命令。换句话说,两者解决的都是同一个高频诉求:手里有文档,想尽快变成一个能问答的系统。对先让业务用起来的团队来说,两者的起步成本都很低,差异主要体现在定位与许可证上。
差异之一是产品重心。FastGPT 当前版本 v4.15.7,Stars 25.6k[17],重心是知识库问答本身:多知识库的复用混用、灵活的分块管理、混合检索加重排,再叠加 Flow 可视化编排与 Agent Skill,适合把文档问答与业务流程快速产品化。MaxKB 当前版本 v2.10.5-lts,Stars 22.5k[18],定位是企业级智能体平台,RAG 流水线、Agentic Workflow 与 MCP 三位一体,技术栈为 Vue 前端、Django 后端、LangChain 编排框架与 pgvector 向量存储,典型场景包括智能客服、企业内部知识库与教育科研。一句话概括:FastGPT 像知识问答的产品车间,MaxKB 像智能体的总装平台。
| 维度 | FastGPT | MaxKB |
|---|---|---|
| 当前版本 | v4.15.7 | v2.10.5-lts |
| Stars | 25.6k | 22.5k |
| 许可证 | 自定义许可证 | GPL-3.0 |
| 产品定位 | 知识库问答 + 可视化工作流 | 企业级智能体平台 |
| 数据依赖 | MongoDB + PostgreSQL(pgvector) | PostgreSQL(pgvector) |
| 部署方式 | 安装脚本 + docker compose | 单条 docker run |
差异之二是许可证,选型时必须看清。FastGPT 采用自定义许可证:允许把它作为后台服务商用,但禁止以 SaaS 形式对外提供服务,商用时还需保留版权信息。MaxKB 采用 GPL-3.0,对二次分发与修改分发有传染性约束。两者都不是标准的宽松许可证,含义我们在 15.5 节与章末选型对照表里展开,商用团队尤其要逐条确认。
为什么建议选型时先看产品定位、再看许可证,而不是逐项对比功能清单?因为成熟开源平台的基础功能高度趋同:知识库管理、混合检索、可视化编排、MCP 互通,两者都有,逐项对比往往比不出结论。真正决定落地后会不会后悔的是两件事:其一,平台定位与你的场景是否匹配——想建企业智能体平台却选了深耕问答的产品车间,后期能力不够只能推倒重来;其二,许可证是否允许你的商用方式——技术问题可以花时间解决,许可证问题却可能让整个项目直接出局。所以用定位做第一轮筛选,用许可证确认底线,最后才看功能细节,这个顺序能避开绝大多数选型返工。
15.2 FastGPT 部署:一条安装脚本起步
FastGPT 的官方部署路径非常直接:运行安装脚本拉取配置文件,然后用 Docker Compose 启动全部服务。安装脚本会准备好编排所需的配置,省去手工拼凑环境的工作,具体脚本命令以官方仓库文档为准[17]:
# 1. 运行官方安装脚本,拉取 docker-compose 配置文件
# (安装脚本会准备好编排所需的配置与环境变量)
# 2. 配置就绪后,启动全部服务
docker compose up -dcompose 会同时拉起 FastGPT 本体与它依赖的 MongoDB、PostgreSQL(pgvector)容器,全部就绪后,浏览器访问 http://localhost:3000 即可进入界面。默认账号为 root,默认密码为 1234。首次登录后要做的第一件事就是修改默认密码——这套默认凭据在官方文档里写得明明白白,任何扫描器都不会错过它。
FastGPT 依赖 MongoDB 与 PostgreSQL(pgvector 扩展)两个数据库:前者存放应用与业务数据,后者承担向量存储与检索。compose 编排会把它们一并拉起,日常无需单独干预,但备份时要把两个数据库的数据卷都纳入备份范围;迁移机器时数据卷也要一并搬迁,否则知识库与应用配置都会丢失。养成定期备份数据卷的习惯,比任何高可用方案都实在。
部署完成后,建议先走一遍最小闭环:创建一个测试知识库,录入几段文本,再建一个对话应用挂载该知识库,问几个问题确认检索与回答链路通畅。这一步能在接入真实语料之前排除环境问题,避免后续把语料问题与环境问题搅在一起排查。如果 3000 端口已被占用,先停掉占用方或在 compose 配置中更换映射端口再重启;若容器反复重启,优先查看 MongoDB 与 PostgreSQL 两个依赖服务的日志,它们未就绪时 FastGPT 本体无法正常工作。排障的顺序也很重要:先依赖、后本体,先日志、后猜测,能少走很多弯路。
15.3 FastGPT 知识库实操
15.3.1 三种导入方式怎么选
FastGPT 的知识库提供三种导入方式:手动输入、直接分段与 QA 拆分。手动输入适合零散的小知识,例如一条产品政策、一个内部流程说明,直接粘贴文本入库;直接分段适合成篇的文档,平台按设定策略切块入库;QA 拆分则让模型把文档拆成一问一答的问答对,每个问答对成为一个分块。
三种方式的选择逻辑与文档形态挂钩。FAQ 语料、客服历史问答、答疑整理,优先用 QA 拆分——这与第 7 章讲过的 QA 分块思路一脉相承:问题与问题之间的语义距离近,检索命中率天然更高,答案独立成块也方便直接引用。举个直观的例子:一份售后政策文档写着七天无理由退货、需保持商品完好,QA 拆分会生成问:什么商品支持七天无理由退货?答:……这样的问答对;用户问买了三天想退可以吗时,问题与问题的语义匹配远比长段落检索来得直接。成册的说明书、制度文件用直接分段;知识点零散、没有现成文档的,用手动输入逐条补录。三种方式可以在同一个知识库里混用,按语料形态各就各位。
格式支持方面,FastGPT 覆盖 TXT、MD、HTML、PDF、DOCX、PPTX、CSV、XLSX,还支持 URL 读取与 CSV 批量导入,另有 API 知识库可供程序化写入。存量资料分散在多种格式里的团队,基本可以做到原样扔进去,不必预先做格式清洗。CSV 批量导入适合结构化问答对的规模化录入;URL 读取适合把在线文档直接纳入知识库,配合定期更新保持内容新鲜。
15.3.2 混合检索与重排配置
检索侧,FastGPT 采用混合检索加重排的组合:向量检索与全文检索两路召回,再由重排模型对候选分块统一打分排序。知识库设置里可以调整召回数量、相似度阈值与重排开关。建议的调优路径是:先用默认配置跑通,再收集十几条真实业务问题做检索测试,观察头部召回是否命中目标分块;不命中时优先检查分块质量,其次再动检索参数。分块质量差时调检索参数,往往是在给错误的数据打补丁。重排模型的选择也值得一试:不同重排模型对中文语料的适配程度不同,切换后跑一遍测试集,用数据决定是否保留。
15.3.3 分块的修改、删除与多库混用
FastGPT 的分块(chunk)支持修改与删除。发现某个分块切得不好、内容过时或混入了噪声,直接在分块列表里编辑文本或删除整块,改动即时生效,无需重建知识库。这一点对运营期的知识库非常关键:知识库不是建成即封存的,业务在变,文档在变,持续修剪与更新才能维持检索质量。建议为每个知识库指定维护人,把分块巡检纳入例行工作。
另一个实用特性是多知识库的复用与混用。同一个应用可以同时挂载多个知识库,例如产品手册库、售后 FAQ 库与价格政策库,检索时跨库召回、统一排序。知识按业务域分库维护,应用按场景自由组合,维护责任与使用场景得以解耦。新建一个客服应用,勾选三个现成知识库即可上线,无需复制任何语料。
多库复用与混用的好处,本质上是把知识从“一次性物料”变成“可复用资产”。没有这套机制时,每上线一个新应用都要把语料复制一份单独维护:一条政策更新要改好几处,漏改任何一处,不同应用就会给出互相矛盾的答案,用户一问便穿帮。知识库独立且可组合之后,语料维护收敛到单一位置,改一处全局生效;新应用的上线成本从“重新整理一遍语料”降为“勾选几个现成知识库”。规模越大收益越明显:十个应用共享三个知识库的维护成本,远低于维护十份互相打架的语料副本。
15.4 FastGPT 工作流:Flow 编排与客服场景
15.4.1 Flow 编排节点速览
FastGPT 的 Flow 是一块可视化编排画布:节点代表一步操作,连线代表数据流向。常用节点包括用户输入、知识库检索、LLM 调用、条件分支、HTTP 请求与回复输出。把节点拖上画布、连线、配置参数,一个对话应用或插件工作流就搭好了,无需编写后端代码。对习惯第 13 章低代码编排方式的读者,这套画布的心智模型是相通的。
Flow 之外,FastGPT 还提供 Agent Skill 与对话、插件两类工作流形态,并支持双向 MCP:既可以作为 MCP 服务被外部客户端调用,也可以作为客户端连接外部 MCP 工具。平台内置调用链路日志与应用评测——前者记录每次请求经过的节点与耗时,方便排查问题;后者支持对应用效果做回归验证。编排能力与可观测性配套,是 FastGPT 区别于简单问答机器人的一点。
15.4.2 一个客服场景的编排思路
以智能客服为例,看一条典型的编排链路。第一步,用户问题进入知识库检索节点,从产品 FAQ 与售后政策两个知识库混合召回相关分块。第二步,召回结果与用户问题一起进入 LLM 节点,提示词约束模型只依据召回内容作答,答不上来时明确告知而不是硬编。第三步,LLM 的输出进入条件分支节点:属于常规咨询的,直接输出回复;命中投诉、退款、转人工等意图的,走转人工分支,把会话摘要与用户问题一并推送给客服坐席。
这条链路的关键在于把不确定性收敛成确定性:模型只负责生成,是否转人工由分支规则兜底,避免模型觉得自己能答却答错的尴尬。上线后结合调用链路日志复盘转人工的原因,把高频的新问题补录进知识库,转人工率会逐月下降。配合内置的应用评测,还可以把典型客服问题整理成评测集,每次调整提示词、更换模型或更新知识库后都回归一遍,确保改动不引入效果回退。运营客服机器人和运营任何线上服务一样,靠的是可重复的验证,而不是手感。
15.5 MaxKB:一条命令起服务的企业级智能体平台
MaxKB 的名字取自 Max Knowledge Base,当前版本 v2.10.5-lts,Stars 22.5k[18]。它的定位是企业级智能体平台,能力版图由三块组成:RAG 流水线负责文档的摄取、分块与检索;Agentic Workflow 负责智能体编排;MCP 负责与外部工具生态互通。官方给出的典型场景包括智能客服、企业内部知识库与教育科研,技术栈为 Vue 前端、Django 后端、LangChain 编排框架与 pgvector 向量存储。
MaxKB 的部署是所有平台里最省事的——一条 docker run 命令:
docker run -d --name=maxkb -p 8080:8080 -v ~/.maxkb:/opt/maxkb 1panel/maxkb命令中 -v ~/.maxkb:/opt/maxkb 把数据目录挂载到宿主机,升级或删除容器时数据不丢,这也是后续备份的对象。启动后访问 http://localhost:8080,默认账号 admin,默认密码 MaxKB@123..。与 FastGPT 一样,首次登录后应立即修改默认密码,并确认 8080 端口的暴露范围符合安全要求。
登录后的使用路径是一条清晰的能力地图,自下而上分三层。第一层是知识库:上传文档,配置分块与检索策略,把原始资料变成可检索的知识资产。第二层是应用:把知识库挂到对话应用上,配置提示词与模型,得到一个可对外提供问答的入口。第三层是智能体工作流:需要更复杂逻辑时,进入工作流编排,接入 MCP 工具,让应用具备查询外部系统、执行业务动作的能力。从知识库到应用再到智能体,三个层级逐级递进,覆盖了从简单问答到复杂业务自动化的连续光谱。
从场景适配看,MaxKB 的三层结构与企业需求天然对齐:智能客服对应应用层加工作流层的组合,企业内部知识库对应知识库层加应用层,教育科研场景则可以利用知识库管理文献资料、以问答方式辅助检索与学习。场景不同,用到的层级组合不同,但底层资产都是同一套知识库,这避免了为每个场景重复建设语料。
MaxKB 采用 GPL-3.0 许可证,选型前务必理解其约束:你可以自由使用、修改与分发,但一旦把修改后的版本对外分发,例如交付给客户的私有化部署包,就必须同样以 GPL-3.0 开源修改后的源码。纯内部使用不受此约束;对要把 MaxKB 改造后作为产品交付的软件厂商,则需要法务评估合规方案,或与官方商谈其他授权形式。许可证不是技术问题,却常常是项目能不能继续的问题。
15.6 补充选项:AnythingLLM 与三者选型建议
如果需求只是个人或小团队与自己的文档对话,还有一条更轻的路线:AnythingLLM。它当前版本 v1.15.0,Stars 64.7k,采用宽松的 MIT 许可证[19]。AnythingLLM 是本地优先的一体化产品,文档对话、Agent 与多用户协作开箱即用;桌面端覆盖 Mac、Windows 与 Linux,也支持 Docker 部署。向量库默认使用内置的 LanceDB,需要时可切换为 PGVector、Milvus 或 Qdrant。不想起服务器、只想装个客户端就用起来的场景,它是省心的选择。
| 维度 | FastGPT | MaxKB | AnythingLLM |
|---|---|---|---|
| 当前版本 / Stars | v4.15.7 / 25.6k | v2.10.5-lts / 22.5k | v1.15.0 / 64.7k |
| 许可证 | 自定义(可作后台服务商用,禁 SaaS,商用保留版权信息) | GPL-3.0 | MIT |
| 产品定位 | 知识库问答 + 可视化工作流 | 企业级智能体平台(RAG + Agentic Workflow + MCP) | 本地优先的一体化文档对话 + Agent + 多用户 |
| 部署形态 | 安装脚本 + docker compose(含 MongoDB 与 pgvector) | 单条 docker run | 桌面端(Mac/Win/Linux)或 Docker |
| 适合谁 | 要快速上线知识问答与客服工作流的团队 | 以智能体平台为目标、能处理 GPL 约束的企业 | 个人与小团队的本地知识助手 |
三者选择的经验法则:诉求是对内快速上线一套知识问答与客服流程,且希望以后台服务方式商用,选 FastGPT,注意其许可证禁止 SaaS 转售;诉求是建设企业级智能体平台、看重开箱即用的部署体验,且内部使用或能妥善处理 GPL 合规,选 MaxKB;诉求只是个人或小团队与私有文档对话、不想维护服务器,选 AnythingLLM。平台之间并非互斥,不少团队的实际架构是:用 AnythingLLM 做个人探索与小范围试用,用 FastGPT 或 MaxKB 承载团队生产系统。
还要提醒一点:选型表里的许可证信息只是结论,落地前请亲自读一遍许可证原文。许可证条款会随版本演进,社区的解读也可能失真,只有原文才是依据。对商用项目,这一步的时间投入远比想象中值得。
15.7 本章小结
本章完成了 FastGPT 与 MaxKB 两条路线的实操巡礼。FastGPT 以安装脚本加 compose 快速起步,三种导入方式覆盖从零散知识到 FAQ 语料的录入需求,混合检索加重排保证召回质量,分块可改可删、多库可复用可混用,Flow 编排让客服这类场景的确定性流程触手可及;MaxKB 用一条 docker run 命令交付了从知识库到应用再到智能体工作流的完整能力地图,代价是 GPL-3.0 的分发约束;AnythingLLM 则为个人与小团队提供了 MIT 许可的本地化选项。下一章,我们把关注点从搭建转向度量,用 RAGAS 系统评估 RAG 系统的效果。
用 RAGAS 评估 RAG
知识库上线了,效果到底怎么样?如果这个问题的回答还是"感觉还行",那项目就仍处于盲改阶段。这一章引入 RAGAS 评估框架:构建一份黄金测试集,用 Faithfulness、LLMContextRecall 等指标把"好不好"拆解成检索与生成各环节的分数,定位问题出在哪一环,让每一次优化都有数字可依、有账可查。
16.1 为什么评估先行
RAG 系统的链路很长:分块、嵌入、检索、重排、提示词、生成模型,任何一环的改动都可能影响最终答案。没有评估时,优化的循环通常是这样的:改一下分块大小,人工问几个问题试试,感觉不错,上线。这种"人肉测试"有三个致命缺陷。其一,样本太少,覆盖不到边缘情况;其二,人的判断受答案流畅度影响,一本正经的胡说八道特别容易蒙混过关;其三,无法归因——答案变差了,你不知道是哪一环坏了,只能逐个乱试。
所以我们强调评估先行:在动任何参数之前,先建立可度量的基线。上线前评估回答的是"这个版本能不能发";上线后监控回答的是"线上效果有没有退化"——真实用户的问题比测试集更野更多样,对线上对话做抽样打分,能捕捉到测试集覆盖不到的问题。两者用的是同一套指标、同一份评估代码,只是数据来源不同,因此上线前建立的评估能力可以无缝复用到上线后监控。本章聚焦上线前评估,监控只是它的自然延伸。
一套能用的 RAG 评估还应具备三个特征。可复现:同一版本跑两次,分数应基本一致,版本之间才能比较;可归因:分数下降时,能定位到检索还是生成哪一环,而不是只给一个笼统的总分;可自动化:评估流程能用一条命令跑完,融入日常迭代,而不是一次性的人工表演。RAGAS 的设计正是围绕这三点展开的。
没有度量,就没有改进。RAG 的优化本质上是一连串 A/B 决策:分块大小哪个好?重排值不值得加?提示词改了是变好还是变坏?没有固定测试集与指标,每个决策都靠感觉,几轮改动之后效果叠加成什么样子完全不可控。先把"黄金测试集 + 指标"立起来,等于给系统装上了尺子:每次改动的收益都能用数字呈现,出现退化可以立刻回滚。这是 RAG 持续迭代的工程基础,而它的成本,仅仅是项目初期几天左右的投入。
不少团队把评估一拖再拖,是觉得它昂贵:要建测试集、要接指标、要盯数字。其实第一版可以非常粗糙——50 个问题、3 个指标、1 个脚本,一个下午就能跑通。真正昂贵的从来不是评估,而是上线后反复盲改与返工的时间。
工具选型上,我们用 RAGAS[5],它是目前社区使用最广泛的 RAG 评估框架,核心思想是用大模型当裁判,把 RAG 拆成检索、生成等多个环节分别打分。安装用 pip 即可:
# 安装 RAGAS 与 LangChain 的 OpenAI 集成(用于包装评估 LLM)
pip install ragas langchain-openai本章代码基于 RAGAS 0.4 系列 API,主要入口是 evaluate 函数与 EvaluationDataset 数据集类[6]。如果你安装的版本较旧,部分类名与导入路径可能有差异,请以对应版本的官方文档为准。
16.2 构建黄金测试集
评估的核心是一份高质量的"问题 + 标准答案"集合,业内常称之为黄金测试集(golden test set)。RAGAS 的每条评估数据包含四个字段:user_input 是问题,reference 是标准答案,retrieved_contexts 是系统检索到的片段列表,response 是系统生成的答案。其中后两个字段由被测系统跑一遍测试集后自动产生,需要人工准备的主要是问题与标准答案。
问题从哪里来
第一个来源是真实用户日志脱敏。如果系统已经上线过一段时间,从真实对话里挑出高频、有代表性的问题,这些问题最贴近实际需求,也最能暴露你建库时没想到的角落。注意入库前去除个人隐私与敏感信息,并对相似问题去重,避免分数被某一类问题主导。
第二个来源是领域专家编写。请业务同事围绕关键、易错的知识点出题,比如"报销上限是多少""请假有哪些特殊情形""跨部门审批怎么走"。这类"硬骨头"问题最能检验系统的真实水位,专家给出的标准答案也更权威。缺点是人力成本高,适合打磨核心题目。
第三个来源是 LLM 辅助生成加人工校验。把片段喂给大模型批量生成问答对,再逐条人工审核,剔除低质量、事实错误与过于直白的条目。这种方式最快,一个下午就能产出上百条候选题,适合快速搭起测试集的第一版。但要记住,LLM 生成的问题普遍偏"乖",仍需人工补充一些口语化、含糊、带陷阱的真实问法。
规模与字段
规模建议:先做 50 到 100 条。太少则统计不稳定,一条判错分数就抖得厉害;太多则维护成本高,首版评估的边际收益也有限。系统运转起来后再逐步扩到几百条,并按业务主题分层,分别观察各主题的得分。覆盖面同样重要:高频问题、边缘情况、绝不能答错的问题都应有代表,别让测试集变成某一类题型的主场。整理时还可以给每条问题标注难度:直接查找型的算简单,需要跨片段推理的算困难,重点盯住困难题的分数变化。
下面是一条完整数据的四个字段,注意 retrieved_contexts 是字符串列表,因为一次检索通常返回多个片段:
{
"user_input": "差旅餐补的标准是什么?",
"reference": "一线城市每人每天 120 元,二线城市每人每天 100 元,其他城市每人每天 80 元。",
"retrieved_contexts": [
"差旅餐补标准:按出差城市分档,一线城市每人每天 120 元,二线城市每人每天 100 元,其他城市每人每天 80 元。"
],
"response": "差旅餐补按城市分档:一线城市每人每天 120 元,二线城市每人每天 100 元,其他城市每人每天 80 元。"
}其中 user_input 与 reference 由人工准备,retrieved_contexts 与 response 在跑知识库管线后回填。有些指标(如 Faithfulness)只需要问题、检索上下文与答案,不依赖标准答案;但仍建议每条都写全 reference,这样所有指标都能算,诊断时才不会有盲区。测试集要像代码一样做版本管理,每次增删改都留下记录,分数波动时才能追溯是不是测试集本身变了。
测试集不是静态资产。线上每发现一个坏案例,脱敏后就应补录为新的测试题;业务知识每更新一次,也要同步核对对应标准答案是否过时。一份持续流动、越养越厚的黄金测试集,是团队最有价值的评估资产。
16.3 核心指标通俗解读
RAGAS 提供的指标很多[6],日常使用中,吃透下面三个就能覆盖大部分诊断需求。它们分别对应 RAG 最常见的三种失败模式:幻觉、漏召回、事实错误。下面逐个用大白话讲清楚。
Faithfulness:抓生成环节的"幻觉"
Faithfulness 回答的问题是:答案里的每一句话,是否都被检索到的上下文支持?它的工作流程大致是:先把答案拆成一条条独立的陈述,再逐条判断每个陈述能否从 retrieved_contexts 中推导出来,可被支持的陈述占比就是忠实度得分。得分低,说明模型在"加戏"——说了上下文里根本没有的内容,这就是最典型的幻觉。注意,它不关心答案与标准答案是否一致,只盯住答案是否"跟着证据走"。
LLMContextRecall:抓检索环节的"漏召回"
LLMContextRecall 回答的是反方向的问题:标准答案所需要的信息,是否都在检索到的上下文里?它让裁判 LLM 逐句检查 reference 中的每个信息点能否在 retrieved_contexts 中找到依据,覆盖率越高得分越高。得分低,说明检索环节漏掉了关键片段——巧妇难为无米之炊,后续生成再好也只能漏答或硬编。这个指标是检索环节的听诊器,也是判断"查没查到"最直接的信号,我们把它简称为上下文召回。
FactualCorrectness:和标准答案对事实
FactualCorrectness 把答案与 reference 标准答案做比对,将两者都拆解成原子事实,再计算一致程度。它与前两个指标形成互补:Faithfulness 只看答案是否跟着上下文,但上下文本身可能是错的、过时的;FactualCorrectness 直接对着标准答案核对,能抓住"上下文在场、答案却写错"的情况。它的得分受事实拆解粒度影响,不同版本之间的绝对值可比性有限,重点看相对变化。
AnswerRelevancy:一句话带过
还有一个常用指标 AnswerRelevancy,衡量答案是否切题回应了问题、有没有答非所问,本章不展开,感兴趣的读者可查阅官方文档[6]。
| 指标 | 回答的问题 | 低分说明哪个环节出问题 |
|---|---|---|
| Faithfulness | 答案是否被检索到的上下文支持 | 生成环节:模型没跟着材料答,产生幻觉 |
| LLMContextRecall | 检索到的上下文是否覆盖标准答案所需信息 | 检索环节:漏召回或召回偏差 |
| FactualCorrectness | 答案与标准答案的事实是否一致 | 检索或生成:可能拿错材料,也可能用错材料 |
| AnswerRelevancy | 答案是否切题回应了问题 | 生成环节:答非所问、绕圈子 |
记住这张表就够用了:看分数时,先问"这个指标对应哪个环节",低分即指向对应环节的病灶。至于多个指标如何组合判读、怎样把病灶翻译成行动,我们在 16.5 节给出一份速查手册。
16.4 完整评估代码
下面把完整流程跑一遍。思路是:先用 EvaluationDataset.from_list 构造测试集,演示只放 3 条数据,其中第三条是故意埋的坏案例——检索到的片段与问题无关,答案还编了个数字,正好观察指标如何把它揪出来;然后用 LangchainLLMWrapper 包装评估 LLM,这里选 gpt-4o-mini 作为裁判模型;最后调用 evaluate 传入测试集与指标列表,结果用 to_pandas 转成 DataFrame 分析。
from ragas import evaluate, EvaluationDataset
from ragas.metrics import Faithfulness, LLMContextRecall, FactualCorrectness
from ragas.llms import LangchainLLMWrapper
from langchain_openai import ChatOpenAI
# 三条黄金测试数据:前两条是正常样本,第三条是故意埋的坏案例
dataset = EvaluationDataset.from_list([
{
"user_input": "差旅餐补的标准是什么?",
"retrieved_contexts": [
"差旅餐补标准:按出差城市分档,一线城市每人每天 120 元,二线城市每人每天 100 元,其他城市每人每天 80 元。"
],
"response": "差旅餐补按城市分档:一线城市每人每天 120 元,二线城市每人每天 100 元,其他城市每人每天 80 元。",
"reference": "一线城市每人每天 120 元,二线城市每人每天 100 元,其他城市每人每天 80 元。",
},
{
"user_input": "工作满五年的员工有几天年假?",
"retrieved_contexts": [
"年假管理规定:员工累计工作满 1 年不满 10 年的,年休假 5 天;满 10 年不满 20 年的,年休假 10 天;满 20 年的,年休假 15 天。"
],
"response": "工作满五年的员工有 5 天年假。",
"reference": "5 天。",
},
{
"user_input": "生育费用的报销上限是多少?",
"retrieved_contexts": [
"员工手册第三章:公司为正式员工购买补充医疗保险,覆盖门诊与住院费用。"
],
"response": "生育费用的报销上限是 20000 元。",
"reference": "生育费用的报销上限为 8000 元。",
},
])
# 包装评估 LLM:RAGAS 将用这个 LLM 充当"裁判",为各指标打分
evaluator = LangchainLLMWrapper(ChatOpenAI(model="gpt-4o-mini"))
# 一次性评估三个指标
result = evaluate(
dataset,
metrics=[Faithfulness(), LLMContextRecall(), FactualCorrectness()],
llm=evaluator,
)
# 转成 DataFrame,便于分析与存档
df = result.to_pandas()
print(df[["user_input", "faithfulness", "llm_context_recall", "factual_correctness"]])几个注意点。evaluate 的 metrics 参数接收实例化后的指标对象;llm 参数指定打分用的裁判模型,RAGAS 会调用它完成陈述拆解、支持性判断等工作。如果用的是其他模型服务,只要有对应的 LangChain 集成,同样可以用 LangchainLLMWrapper 包装后传入。返回的 result 对象调用 to_pandas() 即可得到 DataFrame,每行对应一条测试数据,列包含原始字段与各指标得分。
耗时方面先有个预期:3 条数据乘 3 个指标,通常一两分钟内跑完,时间主要耗在裁判 LLM 的多轮调用上。数据量扩到上百条时,RAGAS 会并发执行,也要注意 API 的速率限制,必要时调低并发。
下面是一次典型运行的输出(裁判模型版本不同,分数会略有浮动,属正常现象):
| user_input | faithfulness | llm_context_recall | factual_correctness |
|---|---|---|---|
| 差旅餐补的标准是什么? | 1.00 | 1.00 | 0.96 |
| 工作满五年的员工有几天年假? | 1.00 | 0.95 | 0.92 |
| 生育费用的报销上限是多少? | 0.08 | 0.00 | 0.12 |
前两条数据三项指标都很高:检索命中了正确片段,答案跟着上下文走,与标准答案也一致。第三条则是典型的"事故现场":llm_context_recall 为 0,检索到的上下文与标准答案所需信息毫无关系,检索环节已经失败;faithfulness 接近 0,答案里的"20000 元"在上下文中找不到任何依据,属于纯粹的幻觉;factual_correctness 同样很低,与标准答案的"8000 元"对不上。一条坏案例,三个指标从三个视角各自暴露了问题,这正是分环节评估的价值。
实际项目中,通常会再对 df 按列求均值,得到本次运行的总体分数,同时把每条的明细得分存档。分数波动时,下钻到具体条目,把那条的 retrieved_contexts 与 response 调出来看,问题往往一目了然。
16.5 诊断 playbook:指标组合判读
单个指标低分只能告诉你"有问题",组合判读才能告诉你"问题在哪"。下面是我们在实践中总结的速查表:
| 指标组合 | 可能的病灶 | 行动建议 |
|---|---|---|
| Faithfulness 低 + LLMContextRecall 高 | 上下文检索到了,但生成模型没跟着材料答 | 强化提示词约束(如"只根据材料作答")、调低温度、换指令跟随更好的生成模型 |
| Faithfulness 高 + LLMContextRecall 低 | 检索漏召回,模型只能"就米下锅" | 回到检索环节:调整分块策略(第 7 章)、引入混合检索与重排(第 11 章)、查询改写等高级优化(第 17 章) |
| 两者都高 + FactualCorrectness 低 | 标准答案可能过时,或语料中存在互相冲突的片段 | 先核对 reference 本身,再清理语料冲突、补充时效性元数据 |
| 三项都低 | 多半是测试集质量或管线本身故障 | 先检查测试数据与管线连通性,别急着调参 |
使用方法很简单:每次评估后,先看哪个指标低,再对照表格锁定嫌疑环节,最后到对应章节找手段。召回低,就是检索问题,翻出第 7、11、17 章的工具箱:调分块大小、上混合检索与重排、做查询改写、加元数据过滤;忠实度低,就是生成问题:加提示词约束、调温度、换模型。评估把"效果不好"从一句模糊的抱怨,变成了可执行的工单。
还有一种情况要保持淡定:指标偶尔会"打架",比如 Faithfulness 很高、FactualCorrectness 却很低。这时先别怀疑指标,回头去看那条数据的 retrieved_contexts 与 reference——多数时候你会发现,要么标准答案过时了,要么语料里存在两种互相矛盾的说法,而模型选了错的那一份。指标冲突往往暴露的是数据问题,而不是模型问题。
拿 16.4 节的第三条数据完整走一遍诊断:LLMContextRecall 为 0,先锁定检索环节——"生育费用报销上限"的条款根本没被检索到。接着排查检索侧:是该条款所在片段过大被截断了?是嵌入模型对这类财务术语不敏感?还是片段的元数据缺失导致被过滤?定位原因后,或回第 7 章调分块,或回第 11 章加混合检索,改完再跑一遍测试集,验证这条的分数是否回升。这就是"评估、诊断、优化、回归"的完整闭环。
16.6 把评估嵌入迭代流程
评估的价值不在"上线前测一次",而在贯穿整个迭代周期。推荐做法:把评估固化为可执行脚本(如 eval_rag.py),每次改动分块策略、嵌入模型、提示词或生成模型,都先跑一遍黄金测试集回归评估,与上一版分数对比,无退化或有明显提升才允许合入。这与软件工程中的回归测试思路完全一致,只是被测对象从代码换成了管线。
# 每次改动后运行评估回归,分数存档以便对比
python eval_rag.py --dataset golden_v1.jsonl --output reports/run_20260818.csv把评估嵌入迭代的好处是:每个优化决策都有了前后对照——分块大小哪个好、重排值不值得加、提示词改一行是提升还是退化,全部用分数说话;团队讨论从"我觉得"变成"看数据";新成员接手时,跑一遍就能摸清系统的水位与短板环节。长期看,这套回归机制还能防止"修一个坏一个":召回提升了,忠实度却在悄悄下降,没有回归评估根本发现不了。
评估 LLM 要选比被测系统更强或至少同级的模型。评估的本质是让一个大模型去评判另一个大模型的输出质量,如果裁判的能力明显弱于选手,打分会很不稳定,甚至放过细微的幻觉。实践中,用 gpt-4o-mini 级别的模型评估中小模型绰绰有余;若被测系统本身就是顶级大模型,裁判也要相应升级。另外,建议把评估 LLM 的温度设为 0,以提高分数的可复现性。
成本方面再交代一句:每次评估,每条数据、每个指标都会触发裁判 LLM 的多轮调用,100 条数据乘 3 个指标,可能产生数百次调用。好在这些开销只发生在迭代时,与盲改浪费的时间相比完全值得。日常小改动可以只跑核心子集,版本发布前再跑全集,在频率与成本之间取得平衡。
基线管理上还有个小建议:每次评估的分数文件按版本号保存,例如 reports/v1.2_baseline.csv,并在说明里记录这一版改了什么。几轮迭代之后,你会得到一条系统分数的演化曲线,哪次改动带来了提升、哪次改动埋了坑,一目了然。
16.7 本章小结
这一章为 RAG 建立了完整的评估闭环:先明确"没有度量就没有改进",再从真实日志、专家编写与 LLM 辅助三个来源构建 50 到 100 条的黄金测试集,然后用 Faithfulness、LLMContextRecall、FactualCorrectness 三个指标把系统拆成环节打分,最后用组合判读表定位问题环节,回到对应章节找手段。
RAGAS 的价值不在于那些数字本身,而在于提供了一套可对比、可归因、可复现的迭代基础设施。有了这把尺子,后面的高级优化章节——查询改写、多路召回、重排调参——才能做到"改之有据"。最后想强调:评估不是为了追求好看的分数,而是让团队有依据地承认问题;哪个指标低,就承认哪个环节有短板,然后用对应章节的方法把它修好,这才是工程化的常态。下一章,我们进入高级检索优化的系统打法。
脚注
[5] ragas 项目 PyPI 主页:https://pypi.org/project/ragas/ 。
[6] RAGAS 官方文档 rag_eval(RAG 评估)章节:https://docs.ragas.io/ 。
高级检索优化
流水线跑通、评估体系就位之后,优化进入深水区。本章聚焦查询与结果两端:用查询改写、HyDE、多查询扩展把“说不清楚的查询”变清楚,用父子文档索引把“碎片化的结果”变完整。章末给出这些手段的组合顺序与一套避免玄学调参的纪律。
17.1 优化地图:检索前、检索中、检索后
在第 5 章,我们把 RAG 流水线拆成了这样一串环节:用户提问、查询处理、从索引中检索、结果后处理、拼装提示词、模型生成答案。当时关心的是“每个环节怎么跑通”,从本章开始关心另一个问题:“每个环节怎么跑得更好”。工程经验表明,绝大多数检索质量问题都可以归到三个阶段之一:查询本身有问题、索引与匹配方式不够好、检索结果的组织方式不适合生成。先把地图画出来,后面的讨论才不会迷路。
| 阶段 | 核心问题 | 代表手段 | 本书位置 |
|---|---|---|---|
| 检索前 · 查询侧 | 查询是否完整、清晰、贴近文档的表述方式 | 查询改写、HyDE、多查询扩展 | 本章 17.2 ~ 17.4 |
| 检索中 · 索引与匹配 | 知识切分是否合理、匹配手段是否足够强 | 分块策略、嵌入模型、混合检索、重排序 | 前面章节已系统讨论 |
| 检索后 · 结果处理 | 检出的内容是否既完整又不冗余,适合送进生成 | 父子文档索引、结果去重、上下文压缩 | 本章 17.5 |
检索中的手段——分块、嵌入选型、混合检索与重排序——已经在前面章节系统讲过,本章不再重复。本章集中在流水线的两端:检索前的查询侧优化(17.2 到 17.4)与检索后的结果侧优化(17.5)。这个安排也暗含了排查思路:遇到“检索不准”,先怀疑查询,再怀疑索引,最后怀疑结果处理。很多看似是索引不行的问题,根源其实是查询侧根本没有把问题说清楚。
还要预先强调一个原则:本章所有手段都是“加法”性质的优化,它们不能替代分块与评估这两块地基。如果分块策略明显不合理,或者手里连一份像样的评估集都没有,请先回到第 7 章和第 16 章补课。地基不稳时上高级手段,往往只是把已有的问题放大一遍。
17.2 查询改写:多轮对话的指代消解与上下文补全
多轮对话里的指代陷阱
多轮对话中,用户天然倾向于使用代词和省略。看一段很真实的对话:用户先问“知识库产品的私有化部署版支持离线使用吗”,助手回答“支持,私有化部署版可以完全离线运行”,用户接着追问一句:“那它支持更新模型吗?”
最后一句“它支持更新模型吗”在对话语境里毫无歧义,可一旦把它单独拿去做嵌入检索,它就是一个没有主语的查询:“它”是谁?检索系统可能召回一堆各个语境下讲“模型更新”的文档,也可能什么都召不回。这类问题在语言学上叫指代消解问题:查询中的代词必须结合上下文才能还原成它实际指代的对象。除了代词,省略同样致命——“支持批量导入吗”省略了主语产品,“那费用怎么算”省略了讨论的是哪个功能。这些查询单独看都是残缺的。
解决思路很直接:在检索之前插入一步 LLM 处理,把最近几轮对话历史和用户最新问题一起交给模型,让它把最新问题改写成一个独立、完整、脱离上下文也能理解的问题,再拿改写后的问题去做嵌入与检索。上例的改写结果应当是“知识库产品的私有化部署版支持更新内置大模型吗”这样一句主语完整的问题。这一步通常就叫查询改写。
为什么不把最近几轮历史直接拼在查询后面一起嵌入?原因有三。其一,长文本嵌入会稀释语义焦点,历史里包含大量与本次提问无关的信息,向量会被旧内容“拉偏”,检索方向跑歪。其二,历史里既有问句又有答句,混在一起让表述形态混乱,嵌入模型并不知道哪一句才是当前意图。其三,改写迫使系统显式地“理解”用户到底在问什么,而改写结果本身还可以写日志、回显给用户确认、用于线上排查,这些工程价值是简单拼接完全给不了的。
改写器的实现
改写器的实现非常轻量,一个提示词加一次补全调用即可。注意提示词里要写清楚四条要求:替换代词、补全省略的背景、不许回答问题本身、拿不准时原样输出。最后一条是重要的兜底,防止模型自作主张编造出一个新问题。
REWRITE_PROMPT = """你是查询改写助手。请根据对话历史,把用户最新的问题
改写成一个独立、完整、不依赖上下文就能理解的问题。
要求:
1. 把代词(它、这个、该方案)替换为它们实际指代的对象;
2. 补全省略的背景(哪个产品、哪个版本、哪个模块);
3. 不要回答问题本身,只做改写;
4. 如果无法确定指代,原样输出用户问题。
只输出改写后的问题,不要任何解释。"""
def rewrite_query(client, history, question):
"""history 是最近几轮对话的文本,形如 用户:... 助手:..."""
if not history.strip():
return question # 首轮对话没有上下文,直接放行
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": REWRITE_PROMPT},
{"role": "user",
"content": f"对话历史:\n{history}\n\n用户最新问题:{question}"},
],
temperature=0,
)
return resp.choices[0].message.content.strip()
工程上有几个细节值得注意。第一,只在有历史时才调用改写,首轮提问直接放行,省下不必要的开销。第二,temperature 设为 0,改写要的是稳定而不是文采。第三,改写结果建议回传给前端,以“您想问的是:……”的形式让用户确认,既改善体验,又把一次潜在的误检索消灭在萌芽状态。第四,把每次改写前后的查询对写进日志,它们是后续分析坏案例的第一手材料。
代价方面要说清楚:每次多轮提问多了一次 LLM 调用,视模型不同增加约几百毫秒延迟;若模型改写错误、曲解了原意,后续整条检索都会跑偏。缓解办法是同时用原问题和改写后的问题各检索一次,结果合并去重——这样即使改写翻车,原问题的召回还在,系统只是多花了点钱,不会答错方向。总体而言,在多轮对话场景里,查询改写是性价比最高的单项优化之一。
17.3 HyDE:让 LLM 先写一段假设性答案再去检索
问题与文档之间的表述鸿沟
有时候查询本身完整清晰,向量检索依然不准。原因在于问题与文档之间存在天然的表述鸿沟:问题是简短的疑问句,文档是冗长的陈述句。用户问“打印机卡纸怎么弄”,对应的文档可能写着“本手册适用于 XX 型打印机的日常维护与常见故障处理,包括纸路清洁、卡纸排除步骤与安全注意事项”。两边的词汇重合度低得可怜:一个用口语,一个用书面语;一个在提问,一个在陈述。多数嵌入模型在训练时见到的是文档与文档、或规范问答对,面对“口语问句对正式文档”这种组合,向量距离常常不够近。
HyDE(Hypothetical Document Embeddings,假设文档嵌入)的思路相当巧妙:既然问题长得不像文档,那就先让 LLM 把问题“翻译”成一段文档。具体做法分两步:第一步,让 LLM 针对用户问题写一段假设性答案;第二步,不用原问题、而用这段假设性答案去做嵌入检索。注意,假设性答案的内容完全可能是错的——LLM 并不知道你的私域知识库里有什么——但它的形态是文档式的:陈述语气、带领域术语、结构与真实文档相似。在向量空间里,这段假设性文本比原问题更可能落在真实文档的邻域里,从而把正确文档“带”出来。
完整实现
下面的代码给出完整实现:generate_hyde_query 负责生成假设性答案,hyde_retrieve 用它完成检索。检索侧完全不需要改动,因此 HyDE 对已有索引是即插即用的。
HYDE_PROMPT = """请针对下面的问题写一段简短的技术文档式答案,
就像它真实存在于某份内部文档里一样。
要求:
1. 用陈述句直接写内容,不要出现“根据问题”“假设”这类措辞;
2. 尽量使用问题所在领域常见的术语和表述方式;
3. 控制在 150 字以内。"""
def generate_hyde_query(client, question):
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": HYDE_PROMPT},
{"role": "user", "content": f"问题:{question}"},
],
temperature=0.7,
)
return resp.choices[0].message.content.strip()
def embed_text(client, text):
resp = client.embeddings.create(
model="text-embedding-3-small",
input=[text],
)
return resp.data[0].embedding
def hyde_retrieve(client, vector_store, question, top_k=4):
# 第一步:让 LLM 写一段假设性答案
hypothetical_doc = generate_hyde_query(client, question)
# 第二步:用假设性答案的向量去检索真实文档
query_vec = embed_text(client, hypothetical_doc)
hits = vector_store.search(query_vec, top_k=top_k)
return hits, hypothetical_doc
两个实现细节解释一下。其一,生成假设性答案时 temperature 设为 0.7,比查询改写略高:我们希望假设文本适度发散,多覆盖几种可能的措辞与术语,反正它只是检索的“探针”,不直接呈现给用户。其二,把假设性答案一并返回,是为了写日志与排查——当 HyDE 检索结果不理想时,看一眼假设文本写了什么,通常立刻就能明白问题出在哪。
HyDE 的收益非常有的放矢。对口语化、超短、聊天式的查询提升尤其明显,因为这类查询的“问句形态”与文档差距最大,可改善的空间也最大。对跨语言场景也有效:用户用中文提问、文档库是英文的,可以让假设性答案直接用英文书写,等于在检索前完成了一次语义对齐。更重要的是,HyDE 对索引侧零改动,纯查询侧增强,上线与回滚都容易,试错成本很低。
代价主要有两点。第一是延迟:写一段话比改写一句话更长,多出一次完整的 LLM 生成,整体延迟的增加通常比查询改写更明显,对实时性要求极高的场景要掂量。第二是带偏风险:如果 LLM 的公共先验与库内事实相悖——比如公司内部流程与行业惯例完全不同——假设性答案就会写错方向,把检索带到错误文档的邻域。缓解办法有两个:一是在 HyDE 之后接重排序做二次把关,让真正的排序由交叉编码器决定;二是生成两三段不同的假设性答案,分别检索后合并结果,用多样性对冲单次生成的偏差。还要提醒一句:如果知识库全是高度专业的私域内容,LLM 对其一无所知,HyDE 的效果会打折扣,值不值得上要用评估集说话。
17.4 多查询扩展:从不同角度检索同一个问题
单一查询的视角局限
单一查询还有一个更隐蔽的问题:它只代表一种表述视角。用户说“显卡配额申请”,文档里可能写的是“GPU 算力资源审批流程”;用户说“报销”,文档里可能写的是“费用报销管理办法”。两套词汇体系不对齐,一个向量只能偏向其中一边,另一边的文档就被漏掉了。注意这类问题与 17.2 的指代问题不同:查询本身是完整的,只是表达角度单一,一次嵌入无法覆盖同一意图的多种说法。
多查询扩展的做法是:让 LLM 基于原问题生成 2 到 3 个角度不同的子查询,与原查询一起并行检索,结果按文档标识去重合并。常见的角度变换有四类:同义替换(显卡换成 GPU)、语域转换(口语换成正式术语)、视角转换(从提问者视角换成管理员视角)、具体化或抽象化(把“怎么申请资源”具体成“资源申请的审批人与时限”)。生成子查询时要求语义一致、表述相异,避免跑题。
有读者会问:直接把 top_k 调大不就行了?因为两者解决的是不同问题。调大 top_k 只是沿着同一个语义方向往深处挖,挖出来的依然是“相似但不对口”的内容;多查询是换方向检索,去够原向量根本够不着的表述区域。方向错了,挖得再深也没用;方向对了只是召回不足,调大 top_k 才更直接。实践中两者是互补关系:先用多查询把面铺开,再用足够大的 top_k 把每个方向的深度保住。
实现与去重合并
MULTI_QUERY_PROMPT = """请从 3 个不同角度改写下面的问题,
用于在同一个知识库里并行检索。
要求:
1. 每个改写与原问题语义一致,但措辞、视角或侧重点不同;
2. 可以使用同义词替换、术语与口语互换、具体化或抽象化;
3. 每行输出一个改写,不要编号、不要解释。"""
def generate_multi_queries(client, question, n=3):
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": MULTI_QUERY_PROMPT},
{"role": "user", "content": f"原问题:{question}"},
],
temperature=0.7,
)
text = resp.choices[0].message.content
lines = [ln.strip(" -·") for ln in text.splitlines() if ln.strip()]
return lines[:n]
def multi_query_retrieve(client, vector_store, question, top_k=4):
queries = [question] + generate_multi_queries(client, question)
seen, merged = set(), []
for q in queries:
vec = embed_text(client, q)
for doc in vector_store.search(vec, top_k=top_k):
if doc.id not in seen: # 按文档标识去重
seen.add(doc.id)
merged.append(doc)
return merged
实现上有三点提醒。第一,原问题必须保留在查询列表里,扩展查询是增补而不是替代,防止 LLM 生成的子查询集体跑偏。第二,去重只是最朴素的合并策略,它丢掉了分数信息;如果对质量要求更高,可以把各路结果送入重排序器统一打分,或用按排名融合的算法合并排序,这部分与第 5 章讲过的重排序衔接即可。第三,合并后的候选数量变多了,送入生成前要控制总量,否则提示词膨胀、成本上升、注意力被稀释。
代价方面:N 个查询意味着 N 次嵌入与 N 次检索,延迟近似翻倍,工程上可以用并发请求把这部分延迟压回去;子查询越多噪声越多,一般要接重排序兜底。经验上 2 到 3 个子查询是性价比甜点,再多收益递减,账单却是线性上涨。
17.5 父子文档索引:小块检索、大块生成
块大小的两难
第 7 章讨论分块时留下一个两难:小块(100 到 300 字)语义聚焦,向量匹配精度高,但送给生成时太碎,上下文不完整,模型读到的是一句孤零零的话,不知道前因后果;大块(1000 到 1500 字)上下文完整,但语义混杂,一段里讲了三四件事,嵌入向量被平均化,召回精度下降。用同一种块大小同时满足检索与生成两个环节,注定顾此失彼。父子文档索引就是为解开这个两难而设计的。
思路一句话就能说清:两层结构、各司其职。索引时,先把文档切成大块作为父文档(例如 1200 字),再把每个父文档切成小块作为子文档(例如 200 字);只对子文档做嵌入,并在每个子文档的元数据里记录它所属父文档的 parent_id。查询时,用子文档去匹配以保证精度;命中之后,顺着 parent_id 找回对应的父文档,把父文档送给 LLM 以保证上下文完整。检索用小块,生成用大块,两全其美。
# —— 索引期 ——
def build_parent_child_index(doc_store, vector_store, raw_docs):
for doc in raw_docs:
parents = split_text(doc.text, chunk_size=1200) # 大块:父文档
for parent in parents:
parent_id = doc_store.put(parent) # 父文档存入文档表
children = split_text(parent, chunk_size=200) # 小块:子文档
for child in children:
child.metadata["parent_id"] = parent_id # 关键:记录父文档标识
vector_store.upsert(child) # 只对子文档做嵌入
# —— 查询期 ——
def parent_child_retrieve(vector_store, doc_store, query_vec, top_k=4):
child_hits = vector_store.search(query_vec, top_k=top_k) # 小块保精度
parent_ids, parents = set(), []
for hit in child_hits:
pid = hit.metadata["parent_id"]
if pid not in parent_ids: # 同一父文档去重
parent_ids.add(pid)
parents.append(doc_store.get(pid)) # 大块保上下文
return parents
如果不想自己实现,主流框架都有现成能力。LangChain 的 ParentDocumentRetriever 用子切分器、父切分器加一个文档存储(docstore)组合出同样的逻辑;LlamaIndex 提供两种相近的思路——SentenceWindowNodeParser 按句子检索、命中后通过元数据把周围窗口一并带出,AutoMergingRetriever 则在多个子节点同时命中时自动向上合并为父节点。各框架的接口细节随版本变化,以官方文档为准,但思想与本节示例完全一致。
父子文档索引的本质,是把检索单元与生成单元解耦。检索需要的是“针”——小、准、一击即中,块越小语义越聚焦,向量越容易命中;生成需要的是“布”——大、全、上下文连贯,块越完整模型越能读懂来龙去脉。强迫同一个块同时扮演两个角色,必然牺牲其一。让每个环节各用各的最优粒度,是这类两难问题的通用解法,这个思路在后面章节的上下文压缩里还会再见到。
收益是双向的:召回精度与生成上下文完整性同时保住,答案的完整度与引用质量明显提升。它对结构严谨的长文档尤其有效——合同、法规、论文、财报——这类文档里命中点往往只是某一条款,但模型必须读到前后条款才能理解其限定条件与例外情形。实现成本也可控:只是多了一个元数据字段和一张父文档表,不需要更换嵌入模型,不需要重建向量空间。
代价有三点要如实说明。其一,存储增加,父文档需要额外保存,索引体积接近翻倍。其二,父文档更长,输入 token 与生成成本随之上升,也更容易逼近上下文窗口的上限。其三,父文档过大时关键信息被稀释,模型要在更长的文本里“找针”。实践经验是:父文档粒度取“能讲完整一件事”的大小,一般在 800 到 1500 字之间;多个子文档命中同一父文档时必须去重,否则提示词里会出现大段重复。
17.6 组合策略与上手段的顺序
本章的手段可以叠加使用,但叠加讲究顺序。推荐的路线分四步。第一步,先修分块与评估:回到第 7 章审视分块策略是否合理,回到第 16 章把评估集建起来、跑出基线分数,这是后续一切优化的地基与标尺。第二步,上查询侧手段:多轮对话场景先加查询改写,查询以口语化短查询为主再叠加 HyDE,领域词汇错位严重则加多查询扩展。第三步,动索引侧:引入父子文档索引,这一步要重建索引,动作较大,放在查询侧收益吃干净之后。第四步,回头看重排序是否需要随新的候选分布调整参数。
按这个顺序走,本质是尊重边际收益递减的规律。经验数字大致是:修复明显不合理的分块,可能把召回率从六成拉到八成;查询改写与 HyDE 在此基础上再贡献两到五个点;父子文档索引再补两三个点。越靠后的手段,收益越小、改动成本越高。按性价比排序,就是让最便宜的手段先解决最大的问题,把预算花在刀刃上,而不是一上来就把所有手段堆满。
最忌讳一次改多个变量。每上一个手段,都要在同一份评估集上跑一遍,记录前后指标对比,确认收益归属之后再动下一个。如果同时叠加三四个手段,指标涨了说不清是谁的功劳,指标跌了也不知道是谁惹的祸,最后优化就沦为了玄学调参。同时务必把改写后的查询、假设性答案这类中间产物写进日志,线上出现坏案例时,才能快速定位是哪一步出的问题。
建议维护一份“坏案例集”:把线上用户问过而系统没答好的问题持续沉淀进去,定期并入评估集。没有靶子的优化容易自娱自乐,坏案例才是最诚实的优化路线图。
17.7 本章小结
本章围绕查询与结果两端,讲了四种高级检索优化手段:
查询改写解决多轮对话的指代与省略问题,把“它支持更新模型吗”还原成带完整主语的独立问题,是多轮场景性价比最高的单项优化。HyDE 用 LLM 生成的假设性答案去做检索,弥合问句与文档之间的表述鸿沟,对口语化、短查询提升明显,代价是多一次生成调用与潜在的带偏风险。多查询扩展从多个角度并行检索同一问题,缓解词汇体系错位,需要去重合并并衔接重排序。父子文档索引把检索单元与生成单元解耦,小块保精度、大块保上下文,是长文档问答的利器。
最后强调的纪律是:先分块与评估,再查询侧,再索引侧;一次只改一个变量,让评估集说话。下一章我们跳出向量检索的框架,去看两种面向更复杂问题的新范式:GraphRAG 与 Agentic RAG。
GraphRAG 与 Agentic RAG
向量 RAG 能回答“某份文档里 X 是什么”,却很难回答“这批语料的主要主题是什么”,也很难完成“公司 A 的供应商资质是否齐全”这样的连环追问。本章介绍两种突破该边界的进阶范式:把全局视野预先计算进图结构的 GraphRAG,以及把检索决策交给智能体的 Agentic RAG。
18.1 向量 RAG 的能力边界
经过上一章的优化,向量 RAG 已经能把“在文档里找某个具体答案”这类问题做得相当好。但在真实生产环境里,有两类问题无论怎么调参都难以胜任。认清这两类问题,才能理解后面两种范式各自的设计动机。
第一类是全局性问题。例如合规负责人问:“这 300 份采购合同里,主要存在哪些风险条款?”又如研究者问:“这批语料的主要主题是什么?”这类问题的答案必须俯瞰整个语料库,而向量检索的机制是“找出与查询最相似的 k 个块”——top_k 通常只有个位数,相对 300 份文档连百分之一都覆盖不到。更麻烦的是,这类查询本身没有具体语义锚点,“主要风险条款”与几乎每个块都有点像,也就与哪个块都不够像。把 top_k 调到能覆盖全库?成千上万个块塞不进上下文窗口,token 成本也不可接受。
第二类是多跳推理。例如采购专员问:“公司 A 的供应商资质是否齐全?”回答这个问题需要三步:先查出公司 A 的供应商是谁——这条信息藏在某份合同的附件里;再逐个查这些供应商的资质文件;最后对照资质要求逐项核对。难点在于:原始查询里根本没有出现任何供应商的名字,向量检索第一跳就无从下手。答案必须跨越多份文档“接力”得出,而每一跳的查询内容都依赖上一跳的检索结果。
怎么判断自己的问题是否落进了这两类?一个简单的检验办法:把问题拿给一位不熟悉语料的同事看,如果他说“这得把资料全读一遍才能答”,那是全局性问题;如果他说“这得先查到某个中间信息,才能接着往下查”,那是多跳推理。两类问题的共同点是:答案不在任何一个单独的块里,而块级相似度检索的全部能力,恰恰只是“找到一个块”。
向量检索的本质是一次性的局部操作:给定查询,返回表面语义最相近的 k 个块。全局汇总需要“遍历并聚合”,多跳推理需要“逐步分解、环环相扣”,两者都超出了“局部相似度匹配”的表达力。这不是嵌入模型不够好,也不是 top_k 没调对,而是范式本身的能力边界。突破边界有两条路:要么把知识预先组织成支持聚合与链式查询的结构,这是 GraphRAG 的思路;要么把检索过程交给一个会动态决策的规划者,这是 Agentic RAG 的思路。
18.2 GraphRAG 原理:把全局视野预计算进社区摘要
GraphRAG 是微软研究院团队在 2024 年提出的方法[28]。它的核心洞察只有一句话:既然全局性问题无法靠临场检索回答,那就在索引期把“理解整个语料库”的工作提前做完,并把理解的结果固化成摘要,查询时直接用。
索引期:四步把语料变成图
GraphRAG 的索引阶段分为四步。第一步,实体与关系抽取:LLM 逐块阅读语料,从中抽取实体(人物、组织、产品、地点、事件)与实体之间的关系(A 供应 B、A 隶属于 B、A 发生于 B),通常还会为每个实体生成一段简要描述。第二步,构建知识图谱:以实体为节点、关系为边,形成一张覆盖整个语料库的图。第三步,社区检测:用 Leiden 等算法把图划分成若干层次的社区——内部连接紧密、外部连接稀疏的节点簇。每个社区实际上就是一个“主题簇”,例如围绕某家供应商的所有实体与关系会自然聚成一个社区。第四步,预生成社区摘要:LLM 为每个社区撰写摘要。这一步最关键——语料库层面的全局信息被“预计算”并沉淀在这些摘要里,查询时不再需要临时通读全库。
值得单独说一句的是抽取质量对整个系统的决定性影响。实体抽取是 GraphRAG 的第一道工序,抽取的粒度与命名规范会一路传导到图结构、社区划分与摘要质量:抽得太粗,关系网络稀疏,社区空洞;抽得太细,实体爆炸,同名异指与异名同指混杂。实践中通常要在提示词里明确实体类型清单与抽取粒度,并对一批样本块做人工抽检,确认抽取结果符合业务直觉之后,再放心跑全量索引。
查询期:局部搜索与全局搜索
查询阶段对应两种模式。局部搜索面向具体实体类问题:从与查询相关的实体出发,沿图的边扩展,收集邻域内的实体描述、关系与关联原文块,拼装成上下文交给 LLM。图结构让“公司 A 的供应商的资质”这类多跳问题可以通过沿边遍历自然展开。全局搜索面向全局性问题,采用 map-reduce 两阶段:map 阶段让 LLM 逐份阅读社区摘要,抽取与问题相关的要点并打分;reduce 阶段把高分要点按序分批汇总,得出最终答案。由于输入是预计算好的社区摘要而非原始块,全局问题第一次变成了成本可控的可解问题。
flowchart LR A[原始语料]-->B[LLM 逐块抽取
实体与关系] B-->C[构建知识图谱] C-->D[社区检测
划分主题簇] D-->E[LLM 预生成
社区摘要] E-->F[全局查询
map 逐份摘要打分] F-->G[reduce 汇总
输出全局答案]
用四段式来总结这个范式。它解决的问题是全局性问题与关系型多跳问题;做法是索引期抽实体建图、社区检测、预生成摘要,查询期对摘要做 map-reduce 汇总;收益是答案具备语料库级别的视野,全局汇总与关系追踪都能胜任;代价则是索引期每个块都要过一遍 LLM 抽取、每个社区都要过一遍 LLM 摘要,token 消耗比纯向量索引高出若干个数量级,语料更新时还涉及索引重建。收益与代价都同样显著,是否采用取决于问题类型与预算。
18.3 工程现状:Microsoft GraphRAG v3.1.1
GraphRAG 已经开源并持续迭代,截至本书写作时最新发布版本为 v3.1.1[27]。这个版本有几个值得关注的工程亮点。其一是流式索引管道:文档可以增量地流入处理管道,不必等全部数据就位再启动,内存占用与中断恢复都更友好。其二是 TableProvider 存储抽象:索引产物以表格化接口存取,可以对接不同的存储后端,不再绑定单一实现,便于按团队的基础设施选型。其三是移除了 NetworkX 依赖:图处理链路更轻量,部署更干净。
使用方式大致如下:用 pip install graphrag 安装包;用 graphrag init 初始化项目目录与配置文件;把语料放入约定的输入目录;在配置里填好 LLM 与嵌入模型的接入信息;运行 graphrag index 构建索引——这一步是成本消耗的大头;索引完成后,用 graphrag query 之类的命令执行全局或局部查询。需要说明的是,不同版本的命令参数、配置项与目录结构可能有所变化,本书只给出整体流程轮廓,具体用法以官方文档为准。
索引期的 LLM 调用成本是 GraphRAG 最大的门槛。几百页语料可能消耗数百万乃至上千万 token,且成本随语料规模近似线性上涨。动手之前,务必先用十几份文档跑一个小样本,观察 token 消耗与索引质量,估算出全量预算再决策。同时提前规划语料更新策略:多久重建一次、能否接受更新延迟、重建窗口安排在何时。这些问题要在立项时想清楚,不要等索引建完才发现维护不起。
18.4 LightRAG:更轻的图检索与增量更新
GraphRAG 强大但沉重:建索引贵,语料更新更麻烦——传统做法是全量重建,这对每天都有新文档进入的企业知识库来说难以接受。LightRAG 正是在这一背景下出现的:它同样用图来组织知识,但显著降低了构建与维护成本[30]。
图加向量的双层检索
LightRAG 的检索是图与向量结合的双层结构。查询时它提供两种模式:low-level 模式聚焦具体实体及其关系细节,适合“公司 A 的供应商是谁、合同金额多少”这类问题;high-level 模式聚焦主题与概念概览,适合“这批语料关注哪些主题”这类问题。机制上,系统会从用户问题中生成两组查询键:低层键是具体实体,高层键是抽象主题,分别在图与向量索引中查找对应的细粒度与粗粒度信息,再组合成上下文。双层设计让同一套索引既能答细节、又能谈概览。
举个对照的例子。同一份采购语料上,问“公司 A 与哪些供应商签过框架协议,金额分别是多少”,系统走 low-level 路径:从“公司 A”这个实体出发,沿关系边找到相连的供应商节点与协议记录,给出结构化的明细;问“这批采购合同整体暴露出哪些供应链风险”,系统走 high-level 路径:汇总主题层面的描述与关联,给出概览性判断。同一个索引、两种粒度,这正是双层检索的价值所在。
增量更新与工程现状
LightRAG 的另一个关键设计是增量更新算法[29]。新文档进入时,系统只对新文档做实体与关系抽取,把结果与图中已有的同名实体合并,更新相关描述与向量索引即可,全程是局部操作,不需要像 GraphRAG 那样重建整张图。这让 LightRAG 能以较低成本维护一个持续生长的知识库。
相比 GraphRAG,LightRAG 的定位是更轻、更便宜、更适合持续更新的知识库。它不在索引期预生成大量社区摘要,token 消耗显著更低;更新是增量式的,新文档可以快速并入而不必全量重建。取舍在于:它的全局汇总能力不如 GraphRAG 系统性强。如果是对一份固定语料做一次性深度洞察,GraphRAG 更合适;如果语料是持续流入的合同、工单、报告,LightRAG 的架构更贴合。
工程侧,截至本书写作时 LightRAG 最新版本为 v1.5.4[29],提供了较丰富的存储后端选择:向量索引可对接 Milvus、Qdrant 等向量数据库,图存储可对接 Neo4j,文档与键值数据可存入 PostgreSQL。项目还自带 API Server 与 WebUI,便于快速体验检索效果、也便于集成到自己的系统里。对想先试水图检索的团队,这是一个相当友好的起点。
当然,LightRAG 也不是免费的:索引期依然需要 LLM 做实体与关系抽取,成本高于纯向量索引;抽取质量依赖模型能力与提示词设计;实体合并的效果——两处提及是否应归并为同一个实体——需要在自己的语料上实际观察与调优。选型依然是那句话:先看问题类型与更新频率,再看预算。
18.5 Agentic RAG:把知识库变成 Agent 的工具
从固定流水线到自主决策
前面两种范式都在“预先组织知识结构”,Agentic RAG 走了另一条路:不改变知识的组织方式,而是改变检索的决策者。传统 RAG 的流程是写死的——检索一次、生成一次,成不成都在这一锤子买卖里。Agentic RAG 则把知识库变成智能体(Agent)可以调用的工具(呼应第 9 章的 create_agent),由 Agent 自主决定一连串问题:要不要检索、从哪个知识源检索、是否先把问题分解、信息不足时是否追问用户、结果不理想时是否换个说法二次检索、生成之后是否自我校验。
flowchart LR
A[用户问题]-->B[Agent 规划与分解]
B-->C{需要检索吗}
C-->|需要| D[选择知识源
调用检索工具]
D-->E[评估检索结果]
E-->|信息不足| B
E-->|信息充分| F[生成答案]
C-->|不需要| F
F-->G[自我校验]
G-->|存疑| B
G-->|通过| H[输出最终答案]
回看 18.1 的多跳问题“公司 A 的供应商资质是否齐全”。Agentic RAG 的处理过程大致是:Agent 先分解问题,调用知识库工具检索“公司 A 的供应商”,拿到供应商名单;再逐个检索每家供应商的资质文件;然后对照资质要求逐项核对;最后汇总结论并标注每一步的信息来源。这一连串动作不是预先写死的流程,而是 Agent 依据中间结果动态规划的——如果第一跳没查到供应商名单,它可能会换“公司 A 合同附件”再查一次。这种临场应变,是固定流水线做不到的。
把知识库包装成工具
def search_knowledge(query):
"""检索内部知识库,返回最相关的 4 个段落。"""
vec = embed_text(client, query)
hits = vector_store.search(vec, top_k=4)
return "\n\n".join(h.text for h in hits)
agent = create_agent(
model="gpt-4o-mini",
tools=[search_knowledge],
instructions=(
"你是企业知识助手。回答前必须先调用 search_knowledge 查证;"
"如果检索结果不足以回答,换一个表述再次检索;"
"两次检索仍不足时,明确告知用户缺少哪方面资料,不要编造。"
),
)
resp = agent.run("公司 A 的供应商里,哪些营业执照已经过期?")
print(resp)
代码延续第 9 章的风格:search_knowledge 就是一个普通的检索函数,被当作工具交给 create_agent;行为准则写在 instructions 里——先查证、不足则换说法二次检索、仍不足则如实相告。模型、工具、循环机制全部复用已有组件,这正是 Agentic RAG 的工程优势:它不要求重建一套索引体系,而是在既有检索能力之上加了一个“大脑”。同一个 Agent 还可以挂多个工具——向量库、图库、SQL 查询、搜索接口——由它在每一步挑选最合适的来源。
循环里还有两个容易被忽略却很有价值的行为:追问与自我校验。当用户问题含糊时,Agent 可以选择不检索,而是先向用户澄清——“您指的是 A 公司还是 A 集团?”——把歧义消灭在检索之前,避免拿错误查询去浪费召回。生成答案之后,Agent 还可以回头核对:答案里的每个论断是否都能在检索结果里找到出处,数字与日期是否一致,发现疑点就发起二次检索确认。这两步让系统从“一问一答的管道”变成了“会反思的工作流”,也是 Agentic RAG 在严肃业务场景里可信度的重要来源。
Agentic RAG 的价值在于对复杂多步问题的分解能力。固定流水线只能处理事先想清楚的流程,遇到开放性问题就束手无策;Agent 可以临场规划,把一个大问题拆成一串小查询,每一步根据上一步的结果决定下一步。问题越复杂、知识源越异构,这种灵活性越值钱。它本质上是把“检索策略”从编译期移到了运行期。
Agent 不是免费午餐。每一轮规划与工具调用都是一次 LLM 调用,延迟从几百毫秒涨到几秒甚至几十秒,token 成本成倍上升;Agent 的行为不确定,同一个问题可能走出不同路径,测试、监控与效果归因都比固定流水线困难。简单的事实性问答,直接走向量 RAG 就够了,不要上 Agent;把 Agent 留给真正多步、跨源、需要规划的问题。
18.6 三种范式的选型表
最后把三种范式放到同一张表里比较。选型时主要回答四个问题:主要回答什么类型的问题、愿意为索引投入多少成本、用户能容忍多高的延迟、语料以什么频率更新。
| 维度 | 向量 RAG | GraphRAG | Agentic RAG |
|---|---|---|---|
| 擅长问题类型 | 局部事实问答、定义与流程查询 | 全局概览、主题洞察、多跳关系推理 | 多步任务、跨知识源、需要动态规划 |
| 索引成本 | 低,只需嵌入计算 | 高,LLM 实体抽取与社区摘要 | 低至中,复用既有索引 |
| 查询延迟 | 低,通常秒级以内 | 中高,全局搜索需 map-reduce | 高,多轮 Agent 调用 |
| 语料更新 | 增量添加,便宜 | 全量重建昂贵;LightRAG 可增量 | 取决于底层知识源 |
| 适合场景 | FAQ、文档问答、客服助手 | 语料洞察、风险分析、关系调查 | 复杂业务助手、多源查询编排 |
需要强调:三种范式并不互斥。许多成熟的生产系统是混合架构——用向量 RAG 做基线,承接大部分简单问题;用 GraphRAG 或 LightRAG 作为关系型与全局型问题的补充来源;再在上面加一层 Agent,负责问题路由与多步编排。本章的三节其实展示了三层能力,而不是三选一的单选题。选型的正确姿势是:先从问题类型出发确定主范式,再按成本预算决定补充哪些能力,最后用评估集验证组合效果。
18.7 本章小结
本章讨论了向量 RAG 的能力边界与两种突破边界的范式:
全局性问题与多跳推理是向量 RAG 难以胜任的两类问题,根源在于“局部相似度匹配”无法支撑遍历聚合与链式查询。GraphRAG 在索引期用 LLM 抽取实体、构建知识图谱、做社区检测并预生成社区摘要,查询期用 map-reduce 汇总摘要来回答全局问题,效果系统但索引成本高。LightRAG 以图加向量的双层检索和增量更新算法,做到更轻、更便宜、更适合持续更新的知识库。Agentic RAG 把知识库变成 Agent 的工具,由 Agent 自主规划检索时机与来源,擅长多步复杂问题,但带来额外延迟与 token 成本。
下一章进入本书的最后一站:企业落地与调优,讨论如何把这些手段稳定地跑在生产环境里。
企业落地与调优手册
前面十八章我们把知识库的每一个零件拆开讲了一遍:检索怎么做、分块怎么切、评估怎么跑。这一章换个视角,站在"要把系统真正用起来"的人这一边:先看三个不同行业的落地案例,再给出一份可以直接对照使用的工程治理与调优手册。案例中的公司与数字均为示例,用于说明方法而非陈述事实。
19.1 案例一:电商智能客服
19.1.1 痛点:大促一来,客服先崩
某中型电商公司(下称"案例 A",情节为示例)的日常咨询量并不算大,但每到 618、双 11 这类大促节点,咨询量会翻上三四倍。人工客服排班再怎么加,也扛不住瞬间涌入的问题洪流,用户等待时间拉长,投诉随之上升。更麻烦的是,客服回答所依赖的知识高度分散:商品参数躺在商详页,退换货政策写在一份两年前的 Word 文档里,物流时效规则散落在几张 Excel 表里,还有一部分只存在于老客服的个人经验中。
知识分散带来的直接后果是回答不一致。同一个"退货要几天"的问题,不同客服给出的答案可能差出一截;新入职的客服需要跟班两三周才能独立上线,培训成本居高不下。大促期间临时招来的外包客服更是只能照着话术本机械应答,遇到稍复杂的问题就只能转人工,转人工率一直居高不下。
团队最初尝试过传统的关键词机器人:把 FAQ 整理成"问题—答案"条目,靠关键词匹配命中。这类方案在问题表述规整时效果尚可,但用户真实的提问方式千奇百怪——"我买的东西不想要了能退不"和"退货政策是什么"明明是同一个意图,关键词匹配却常常失之交臂。这正是语义检索能够发挥价值的典型场景。
19.1.2 方案:Dify + QA 分块 + 混合检索重排
案例 A 最终选择了开源平台 Dify 来搭建智能客服(同样可以用 FastGPT 实现,思路一致)。整体方案分为四层:知识组织、检索策略、答案生成、兜底流转。我们逐层拆开看。
知识组织层采用 QA 分块:把客服知识整理成"问题—答案"成对的结构,每一个问答对就是一个检索单元。与长文档切块不同,QA 分块的检索目标是"问题文本",它与用户查询天然处于同一个表述空间,语义匹配更容易命中。答案部分则作为命中后的返回内容,直接供生成环节引用。
检索策略层使用混合检索:稠密向量负责语义召回,处理"说法不同但意思一样"的问题;BM25 稀疏检索负责字面召回,兜住型号、订单号、活动名称这类专有词。两路结果用 RRF 融合后,再经过一个重排模型精排,留下最相关的前 5 条。下面是一份与 Dify 思路对应的检索配置示例(yaml 为示意配置,非真实文件):
retrieval:
mode: hybrid # 稠密 + 稀疏 混合检索
top_k: 20 # 每路各召回 20 条
rerank:
enabled: true
top_n: 5 # 重排后保留 5 条进入生成
score_threshold: 0.35 # 融合分低于该值视为未命中
fallback:
action: handoff_human # 未命中时转人工
reply: "这个问题我暂时拿不准,正在为您转接人工客服"
答案生成层把重排后的 QA 条目作为上下文交给大模型,并在提示词中做了两条硬约束:其一,只能依据给定的知识作答,知识中没有的信息一律回答"不确定";其二,回答末尾必须附上知识条目编号,便于后续追溯。这两条约束是压制幻觉的第一道防线。
兜底流转层是客服场景区别于内部搜索的关键设计:系统识别到"未命中"(融合分低于阈值)、"用户连续追问同一问题"或"用户主动要求人工"这三种信号时,立即把会话连同已有上下文转交人工客服。转人工不是失败,而是系统边界感的体现——让机器回答有把握的问题,把没把握的问题及时交出去。
为什么 QA 分块特别适合客服场景?因为客服知识天然是"问—答"成对的:用户来提问,系统给答案。QA 分块把检索单元做成"问题文本",等于让知识库里的每一条记录都站在用户的视角表述,与真实查询的语言风格高度一致,语义距离更近,命中率自然更高。反过来,如果把整份退换货政策长文直接切块,检索到的是"政策条文"而非"问题",模型还要多做一步"条文到问题"的对齐,既费 token 又容易偏。问答成对的场景,就让分块也成对。
19.1.3 上线后运营:每周盯着"未命中"补库
系统上线只是起点,真正拉开差距的是上线后的运营。案例 A 建立了一个雷打不动的周度例行:每周一上午,运营同学导出上一周所有"未命中"的查询(即触发转人工或回答"不确定"的问题),逐条归类。属于知识库缺失的,补充对应 QA 条目;属于知识过期的(比如上一档促销的满减规则),更新或删除;属于表述变体的,把新说法挂到已有条目上作为扩展问法。
除了补库,还有两件事每周必做。一是抽检"命中但被点踩"的回答,看是知识本身错了还是模型组织语言出了问题;二是核对高频问题的答案与最新业务规则是否一致,尤其是价格、时效、权益这类敏感字段。这套"未命中清单—补库—复核"的循环跑起来之后,知识库的命中率得以持续爬升,而不是上线即巅峰。
不要把整本产品手册不加处理地灌进 QA 库。手册里的长段落不是问答结构,硬塞进去会让检索单元变得又大又模糊,命中率反而下降。正确做法是先做一次"知识萃取":从手册、政策文档中提炼出高频问答对,再入库。宁可由人工花一周整理出 300 条高质量 QA,也不要一键导入 3000 条低质量切片。
19.1.4 效果(案例示意)
以下数字为示例,仅用于说明此类项目的典型收益量级,不代表任何真实企业的数据。案例 A 在系统稳定运行两个大促周期后统计:大促期间的转人工率较纯人工时代下降约三成;用户首次响应的平均等待时间从分钟级降到秒级;新客服的上手培训周期明显缩短,因为机器人给出的标准答案本身就是最好的培训材料。更重要的是,回答一致性显著改善——同一个问题在不同时间、不同渠道得到的答案终于统一了。
19.2 案例二:制造业内部知识搜索
19.2.1 痛点:资料都在,就是找不到
某装备制造企业(下称"案例 B",情节为示例)的困境与电商完全不同:它不缺知识,缺的是"找到知识"的路径。几十年积累下来,技术资料散落在各个角落——图纸说明是 CAD 导出的 PDF,设备维护手册是早年扫描的纸质件,质量体系文件是 Word 格式的制度文档,还有一部分工艺参数只存在于老师傅的脑子里。格式混杂只是表象,真正的问题是没有人说得清"某台设备的某个故障代码到底在哪份文档里解释过"。
工程师找资料的日常是这样的:先问同事,再翻共享盘,再查纸质档案柜,实在不行就打电话问退休返聘的老专家。一次资料查找花掉半天并不罕见。扫描件更是重灾区——普通解析工具面对扫描 PDF 只能得到一堆无法检索的图像,里面的文字必须先经过 OCR 才能利用。
还有一个绕不开的约束:权限。研发图纸、工艺配方属于敏感资产,不能对全员开放;而行政制度、安全规程又需要人人可查。知识要共享,权限要隔离,这两个看似矛盾的需求必须同时满足。
19.2.2 方案:RAGFlow DeepDoc + 引用溯源
案例 B 选择了开源的 RAGFlow,核心理由是它的深度文档理解组件 DeepDoc。DeepDoc 能够识别复杂版式中的标题层级、表格、图文关系,并对扫描件执行 OCR,把"图片里的文字"变成可检索、可分块的结构化内容——这正是制造业文档场景最稀缺的能力。分块环节针对设备手册选用 Book 模板、针对制度文件选用 Manual 类模板,让切分尽量沿着章节边界进行,避免把一段完整的装配步骤拦腰截断。
答案生成环节强制开启引用溯源:模型的每一段回答都必须标注出处文档与页码,用户点击引用即可跳转原文核对。对工程师而言,这个功能的价值甚至超过答案本身——他们需要的不只是一个结论,而是"这个结论从哪来、我能不能信"。有了溯源,工程师敢把检索结果当作查找资料的入口,而不是必须逐字背诵的教条。
扫描件占比高的企业,上线前务必先抽样测试 OCR 质量:挑几十份有代表性的扫描件(含表格、手写批注、低分辨率样本)跑一遍解析,人工核对识别准确率。如果某些关键文档识别效果差,优先安排重新扫描或获取电子版,而不是指望检索层去弥补解析层的缺陷。解析质量是地基,地基不稳,上层调优都是徒劳。
19.2.3 权限控制:元数据过滤 + 敏感文档隔离
权限设计上,案例 B 采用了"双轨制"。普通文档(制度、规程、公开手册)放在共享 collection 中,每个分块携带 department、visibility 等元数据字段,检索时按当前用户的部门与角色做过滤,实现"同一个库、不同人看到不同范围"。
而研发图纸、工艺配方这类敏感文档则单独隔离到独立 collection,访问白名单精确到具体项目组,与应用层账号体系打通。这样做的好处是:敏感数据在物理层面与普通数据分开,即使共享库的过滤逻辑出现疏漏,也不会波及敏感资产。两种手段的取舍逻辑,19.4 节会用一张表展开。
19.2.4 收益(案例示意)
以下同样为示例数字。案例 B 上线半年后对工程师做了一次抽样回访:查找一份技术资料平均耗时从过去的"小时级"(示例:约 2 小时)降到"分钟级"(示例:10 分钟以内);新员工不再需要反复打扰老专家,常见问题直接问知识库即可;跨部门协作时,"这份标准到底归谁管、最新版本是哪个"这类扯皮也明显减少,因为引用溯源把版本与出处摆在了明面上。
19.3 案例三:金融合规审查助手
19.3.1 痛点:准确率为生命线
某城商行合规部(下称"案例 C",情节为示例)面对的是三类案例中最苛刻的要求:答案错了,后果不是用户体验变差,而是监管处罚。合规审查员日常需要核对内部制度与外部监管条文是否一致,撰写审查意见时必须逐条引用依据。而监管条文的更新速度很快,文件动辄上百页且相互引用,人工跟踪既慢又容易漏。
这个场景有三个硬性约束。第一,数据不能出域:监管条文与内部制度均属敏感信息,不允许调用公网大模型 API,必须私有化部署。第二,宁缺毋滥:系统宁可回答"未找到依据",也绝不允许编造一条看似合理的条文。第三,一切可追溯:每个结论都要能回溯到具体文件的具体条款,接受人工复核。
19.3.2 方案:私有化 + 高阈值 + 强制引用 + 人工复核
案例 C 的技术栈全部私有化:向量库与模型部署在行内机房,检索采用混合检索并设置了明显高于普通场景的 Score 阈值——召回结果的最高分若达不到阈值,系统直接回复"未在知识库中找到相关依据",绝不勉强作答。生成环节的提示词要求逐句标注引用来源,任何没有引用支撑的陈述都会被前端标红提示。
流程上,审查助手的输出永远只是"初稿":审查员对每一条引用进行人工复核,确认无误后才写入正式审查意见。复核过程中发现的错误会被记录归类——是检索没召回、召回了但排序靠后,还是模型曲解了原文——这些记录直接转化为下一轮调优的输入。下面用 RAGAS 跑回归评估的代码片段,就是该团队每次改动检索配置后的固定动作:
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_recall
# golden_set 为人工整理的黄金测试集:
# 每条包含 问题 / 参考答案 / 应命中的条文出处
result = evaluate(
dataset=golden_set,
metrics=[faithfulness, answer_relevancy, context_recall],
)
print(result)
# 示例输出:{'faithfulness': 0.93, 'answer_relevancy': 0.88,
# 'context_recall': 0.90}
为什么这个场景必须"评估先行"?因为合规审查的每一次调优——换分块大小、调阈值、加重排——都可能同时影响多个指标:阈值调高,幻觉少了,但漏检可能多了。没有黄金测试集与回归评估,所有调整都是盲调,你以为修好了一个问题,其实埋下了三个新问题。这正是第 16 章反复强调的观点:评估不是上线前的走过场,而是高风险场景下每一次变更的"准入关卡"。案例 C 的团队甚至把评估报告作为变更审批的附件——没有达标的评估结果,配置不允许上线。
19.3.3 收益(案例示意)
示例数字:审查员核对单份制度的平均用时明显缩短,条文定位从"翻文件"变为"点引用";监管新规发布后,知识库更新与回归评估可以在一两个工作日内完成,而过去依赖人工梳理往往需要更久。最关键的是,上线以来没有出现因助手编造条文而导致的审查失误——"强制引用 + 人工复核"的双保险经受住了实战检验。
19.4 工程治理三件事
三个案例的行业不同,但落到工程治理层面,要解决的问题高度收敛:权限怎么管、成本怎么控、系统怎么盯。这一节把这三件事拆成可以直接照做的清单。
19.4.1 权限与数据隔离
企业知识库几乎必然面对"不同人看不同内容"的需求。主流做法有三种:元数据过滤、多 collection 隔离、行级权限。三者的取舍可以参考下表:
| 方案 | 实现方式 | 优点 | 局限 | 建议场景 |
|---|---|---|---|---|
| 元数据过滤 | 每个分块携带部门/角色字段,检索时按用户身份过滤 | 单库管理简单,跨部门检索灵活,上线快 | 过滤依赖应用层正确传参,漏传即越权;字段设计要提前规划 | 大多数企业的首选方案,适合权限粒度为"部门/角色级"的场景 |
| 多 collection 隔离 | 按业务线或密级建独立 collection,按白名单授权 | 物理隔离强度高,敏感数据边界清晰 | collection 数量膨胀后运维成本高;跨库联合检索困难 | 图纸、配方、薪酬等高敏数据;案例 B 的"双轨制"即此思路 |
| 行级权限 | 依赖向量库或平台原生的行级访问控制(RBAC/ABAC) | 权限粒度最细,策略集中管理、可审计 | 对平台能力有要求,策略配置复杂,学习与运维成本高 | 有强合规审计要求的金融、政企场景 |
实践中的经验法则:先用元数据过滤起步,把 department、doc_type、visibility 三类字段设计好;一旦出现"绝对不能泄露"的数据,立即为它单独建 collection,不要图省事留在共享库里赌过滤逻辑永远正确。权限方案宁可保守,不可侥幸。
19.4.2 成本管控
知识库的成本大头有三块:embedding 计算、向量存储、模型调用。对应三招:
第一招,embedding 缓存。同一份文档在调试期往往被反复重建索引,若每次都全量重算向量,费用会成倍放大。以文本哈希为键缓存向量,重建索引时只对变化过的内容调用 embedding 接口,常见能省下大半调用量(示例)。核心逻辑如下:
import hashlib
_vec_cache = {} # 生产环境建议落盘或放入 Redis
def embed_with_cache(texts, embed_fn):
"""按内容哈希缓存向量,只对未见过的文本调用 embed_fn"""
results = [None] * len(texts)
missing_idx, missing_texts = [], []
for i, t in enumerate(texts):
key = hashlib.md5(t.encode("utf-8")).hexdigest()
if key in _vec_cache:
results[i] = _vec_cache[key]
else:
missing_idx.append(i)
missing_texts.append(t)
if missing_texts:
for idx, vec in zip(missing_idx, embed_fn(missing_texts)):
key = hashlib.md5(texts[idx].encode("utf-8")).hexdigest()
_vec_cache[key] = vec
results[idx] = vec
return results
第二招,MRL 维度截断。支持 Matryoshka 表示学习的 embedding 模型允许把向量截断到更低维度(例如从 1024 维截到 512 维)而只损失少量精度,存储与检索成本随之下降。是否值得截断,用你的黄金测试集跑一轮对比即可,数据会替你做决定。
第三招,问答模型分级。不是每个问题都值得动用最大的模型:FAQ 类简单问题路由给小模型甚至直接返回 QA 条目原文,只有多文档综合、复杂推理类问题才升级到大模型。路由可以很简单——按查询长度、命中条数、是否跨文档等规则分流,就能省下可观的调用成本(示例)。
19.4.3 监控与可观测
知识库上线后最怕的不是出错,而是出错了没人知道。最低限度的可观测要求是:为每一次检索问答记录一条结构化日志,至少包含查询文本、命中分数、响应延迟与用户反馈。一条典型日志如下:
{
"ts": "2026-08-18T10:23:41+08:00",
"query": "退货需要几天",
"top1_score": 0.81,
"hit": true,
"latency_ms": 642,
"feedback": "up"
}
有了这些日志,三张报表就水到渠成:命中率趋势(未命中占比是否抬头)、点踩清单(哪些回答被用户否定)、延迟分布(P95 是否超标)。在此基础上,把第 16 章的评估流程定期化——例如每周用最新日志中抽样的真实查询更新黄金测试集,跑一轮 RAGAS 回归——监控与评估就合流成了一个持续运转的闭环。
把"点踩"和"未命中"日志定期回流为测试用例,等于让真实用户替你维护黄金测试集。评估集不再是一次性的人工构造物,而是随业务一起生长的活资产;每一次线上暴露的问题,都会变成下一次回归评估的防线。这是成本最低、收益最高的运营动作。
19.5 故障排查速查表
下面这张表汇总了知识库上线后最常见的八类故障。建议把它打印出来贴在工位旁边——真正出问题的时候,按图索骥远比临场发挥可靠。
| 症状 | 先查哪里 | 处理手段 |
|---|---|---|
| 答非所问、编造内容 | Faithfulness 指标;提示词是否有硬约束 | 在提示词中加入"仅依据给定资料作答,资料不足时明确说不知道";开启强制引用;调高 Score 阈值减少低质上下文 |
| 检索不到相关内容 | Score 阈值是否过高;分块是否过大;是否缺稀疏路 | 下调阈值观察召回变化;把过大的块切小;补充 BM25 稀疏检索覆盖专有词与型号 |
| 答案不完整、以偏概全 | 分块是否过小、上下文被切断 | 改用父子文档检索(小块命中、大块作答);适当增加块间重叠;检查切分是否破坏了段落结构 |
| 效果时好时坏 | 不同查询表述下的召回差异 | 收集失败查询做聚类;引入查询改写或 HyDE 生成假设性答案再检索;为高频问法补充同义表述 |
| 响应延迟高 | 重排候选数量;HNSW 检索参数;模型调用耗时 | 减小进入重排的候选数;下调 efSearch 在延迟与召回间找平衡;生成侧开启流式输出改善体感 |
| 知识更新不生效 | 索引是否重建;增量同步链路是否正常 | 确认文档入库后触发了重建或增量写入;检查解析失败被静默跳过的文件;验证新版本是否覆盖了旧版本 |
| 疑似越权访问 | 元数据过滤是否缺失或被绕过 | 为所有分块补齐权限字段并在检索入口强制过滤;敏感文档迁移至独立 collection;对历史查询做权限审计 |
| 成本飙升 | embedding 调用量;是否存在重复计算 | 上线 embedding 缓存;检查是否有任务在反复全量重建索引;核查模型分级路由是否失效导致小问题也打到大模型 |
排查时最忌讳"一次改多处"。阈值、分块、重排一起动,效果变了也不知道是哪个参数起的作用。正确姿势是每次只改一个变量,改完跑一遍回归评估,确认方向对了再动下一个。调优是实验,不是抽奖。
19.6 结语:知识库是运营出来的,不是交付出来的
回顾这三个案例:电商客服靠"每周补库"维持命中率,制造企业靠"OCR 抽检 + 溯源"赢得工程师信任,金融合规靠"评估先行 + 人工复核"守住准确率底线。行业不同,套路各异,但内核完全一致——知识库不是一个验收即结束的项目,而是一个需要持续喂养、持续体检的系统。
这个系统的运转节律可以概括成一个闭环:评估发现问题,迭代修复问题,监控暴露新问题。黄金测试集随真实查询生长,回归评估为每次变更把关,线上日志把下一批问题送回起点。只要闭环在转,系统就会一天比一天好用;闭环一旦停转,再精致的架构也会迅速腐化。
这也回到了贯穿全书的那条主线:我们反复追问每个设计"为什么这么做",不是为了堆砌知识点,而是希望你在面对真实世界的模糊需求时,有能力自己推导出答案。组件会过时,框架会更迭,唯有"理解为什么"的能力可以长期复用。下一章的附录里,我们为你准备了环境安装速查与术语表,愿它们成为你动手路上最趁手的两件工具。
环境安装速查
本附录汇总全书示例代码所依赖的环境与安装命令,供你动手实践时快速对照。所有版本号均为本书写作期间实际核实过的版本,建议优先按此清单安装,以避免接口差异带来的困扰。
A.1 Python 版本要求
全书代码要求 Python 3.10 及以上版本。部分依赖(如新版 langchain 与 ragas)在更低版本上无法安装或运行异常。安装前请先确认版本:
python --version
# 输出应不低于 Python 3.10.x;若版本过低,请先升级解释器
# 建议为本书示例单独创建虚拟环境,避免与已有项目互相污染
不建议把全部依赖装进全局环境。chromadb、llama-index 等包的依赖树较深,不同章节的示例若混装在一起,日后升级任何一个包都可能牵连一片。为本书建一个独立虚拟环境,是成本最低的避坑手段。
A.2 pip 安装清单
下表列出全书用到的全部 Python 包。"核实版本"一列为写作时实际测试通过的版本;未标注具体版本的包,安装最新版即可。一条命令装齐:
python -m pip install openai==3.2.0 chromadb==1.5.9 \
langchain==1.3.15 langchain-openai==1.5.0 \
llama-index==0.14.23 sentence-transformers==5.7.0 ragas==0.4.3 \
numpy faiss-cpu pymilvus rank-bm25 pypdf python-docx
| 包名 | 核实版本 | 用途 |
|---|---|---|
| openai | 3.2.0 | 官方 SDK,调用对话与 embedding 接口 |
| numpy | 最新版 | 向量运算与数值处理的基础库 |
| faiss-cpu | 最新版 | 高性能向量检索库,用于 ANN 检索实验 |
| chromadb | 1.5.9 | 轻量级向量数据库,本地快速起步首选 |
| pymilvus | 最新版 | Milvus 向量数据库的 Python 客户端 |
| langchain | 1.3.15 | RAG 流程编排框架 |
| langchain-openai | 1.5.0 | LangChain 的 OpenAI 模型集成组件 |
| llama-index | 0.14.23 | 以数据为中心的 RAG 框架 |
| sentence-transformers | 5.7.0 | 本地运行开源 embedding 模型 |
| ragas | 0.4.3 | RAG 系统评估框架(Faithfulness 等指标) |
| rank-bm25 | 最新版 | BM25 稀疏检索的 Python 实现 |
| pypdf | 最新版 | PDF 文档解析与文本抽取 |
| python-docx | 最新版 | Word 文档解析与文本抽取 |
A.3 环境变量配置
全书示例通过 OPENAI_API_KEY 与 OPENAI_BASE_URL 两个环境变量读取密钥与服务地址。使用兼容 OpenAI 协议的第三方服务或本地模型网关时,只需修改 OPENAI_BASE_URL 指向对应地址即可,代码无需改动:
# Linux / macOS(当前会话生效;长期使用请写入 shell 配置文件)
export OPENAI_API_KEY="sk-你的密钥"
export OPENAI_BASE_URL="https://api.openai.com/v1"
# Windows PowerShell
$env:OPENAI_API_KEY = "sk-你的密钥"
$env:OPENAI_BASE_URL = "https://api.openai.com/v1"
密钥只放在环境变量或 .env 文件中,永远不要硬编码进代码,更不要随代码提交到仓库。团队协同时,把 .env 加入 .gitignore 是最基本的卫生要求。
A.4 三个平台的 Docker 部署速查
第 19 章案例涉及的三个开源平台均可用 Docker 快速部署。以下命令为最小可运行版本,生产环境请再按官方文档调整持久化与资源参数。
Dify:克隆仓库后进入 docker 目录,复制环境变量模板再启动:
git clone https://github.com/langgenius/dify.git
cd dify/docker
cp .env.example .env
docker compose up -d
RAGFlow:其内置的 Elasticsearch 对内核参数有要求,启动前必须先确保 vm.max_map_count 不低于 262144,否则容器会反复重启:
# 临时生效;如需开机保持,请写入 /etc/sysctl.conf
sudo sysctl -w vm.max_map_count=262144
# 进入 ragflow 仓库的 docker 目录后启动
docker compose up -d
MaxKB:单条命令即可启动,数据持久化到用户主目录:
docker run -d --name=maxkb \
-p 8080:8080 \
-v ~/.maxkb:/opt/maxkb \
1panel/maxkb
三个平台默认占用的端口不同,若本机 80 或 8080 端口已被占用,启动会失败或互相冲突。部署前先用端口检查命令确认空闲,或在 compose 文件中显式映射到其他端口。另外,RAGFlow 与 Dify 首次启动需要拉取多个镜像并初始化数据库,耐心等待几分钟属于正常现象,不要急着判定失败。
术语表
本术语表收录全书出现的核心概念,每个术语用一句话解释,供快速回顾与查阅。若需深入理解,请按术语回到对应章节。
| 术语 | 一句话解释 |
|---|---|
| RAG | 检索增强生成:先从知识库检索相关资料,再让大模型依据资料作答,以缓解幻觉与知识过时问题。 |
| 幻觉 | 模型一本正经地编造不存在的事实或出处,是知识库系统要重点防范的失效模式。 |
| 知识截止 | 模型训练数据的时间边界,晚于该边界的新知识模型无从知晓,需靠外部知识库补足。 |
| Embedding | 把文本映射为定长数值向量的模型或过程,使语义相近的文本在向量空间中距离相近。 |
| 向量 | 一组有序数值,在本书中特指文本经 embedding 后得到的数值表示。 |
| 余弦相似度 | 以两向量夹角余弦值衡量方向相似程度的度量,取值越大表示越相似,是检索排序的常用打分。 |
| 稠密检索 | 基于 embedding 向量的语义检索,擅长匹配"说法不同但意思相同"的内容。 |
| 稀疏检索 | 基于词频等字面特征的传统检索,向量绝大多数维度为零,擅长精确匹配专有词。 |
| BM25 | 经典的稀疏检索打分算法,在词频基础上引入长度归一与饱和机制,至今仍是强基线。 |
| ANN | 近似最近邻检索:以可控的精度损失换取大规模向量库上的毫秒级检索速度。 |
| HNSW | 分层可导航小世界图索引,通过多层跳表式图结构快速逼近最近邻,是当前主流的 ANN 索引。 |
| IVF | 倒排文件索引:先把向量聚类分桶,查询时只扫描少数候选桶以加速检索。 |
| PQ 量化 | 乘积量化:把高维向量切段后分别压缩编码,以少量精度损失大幅降低存储开销。 |
| MRL | Matryoshka 表示学习:允许把 embedding 截断到更低维度使用,在精度与成本之间灵活取舍。 |
| 分块 | 把长文档切分为适合检索的小单元的过程,切分粒度直接影响召回质量。 |
| 重叠 | 相邻分块之间保留的重叠文本,用于缓解边界处语义被切断的问题。 |
| 元数据 | 附着在分块上的结构化字段(如来源、部门、时间),用于过滤、溯源与权限控制。 |
| 召回 | 检索阶段从全库中取回候选内容的过程,也指应命中内容被成功取回的比例。 |
| 重排序 | 对初步召回的候选用更强模型二次精排,把真正相关的内容顶到前列。 |
| Cross-encoder | 把查询与文档拼接后联合编码打分的模型,精度高但慢,常用于重排阶段。 |
| Bi-encoder | 查询与文档分别独立编码为向量的模型,可离线预计算,适合大规模初筛。 |
| RRF | 倒数排名融合:按名次倒数加权合并多路检索结果的算法,无需校准各路分数。 |
| HyDE | 假设性文档嵌入:先让模型生成一段假设答案,再拿它去检索,以弥合问法与文档的表述差异。 |
| 父子文档 | 用小块做检索命中、用其所属大块提供作答上下文的分块策略,兼顾命中精度与内容完整。 |
| GraphRAG | 以知识图谱组织实体与关系、支持多跳推理与全局摘要的 RAG 形态。 |
| Agentic RAG | 由智能体自主规划检索路径、多轮调用工具并自我修正的 RAG 形态。 |
| Faithfulness | 忠实度指标:衡量答案是否完全由给定上下文支撑,是检测幻觉的核心评估指标。 |
| Context Recall | 上下文召回率:衡量参考答案所需信息被检索上下文覆盖的程度。 |
| 黄金测试集 | 人工整理的高质量"问题—参考答案"集合,是回归评估的基准资产。 |
| 引用溯源 | 为答案标注出处文档与位置的能力,让用户可核对、可追责、可信任。 |
以上 30 个术语覆盖了从检索原理、索引结构、分块策略到评估指标的完整链路。建议读者在遇到概念混淆时先查此表定位章节,再回到正文细读——术语表是路标,不是终点。