1. 为什么我要自己搭一套知识库
手里攒了几百份 PDF 论文、产品手册、会议纪要,每次想找一句话都得靠系统自带的全文搜索,搜出来的东西要么驴唇不对马嘴,要么把整段原文甩给你让你自己肉眼筛。这种体验用久了就会明白一个道理:传统关键词检索解决的是“字面匹配”,而人真正想要的是“语义理解”。你问“这个接口超时了怎么排查”,它应该直接给你那段重试配置和超时参数,而不是把包含“超时”两个字的三十页文档全列出来。
这就是我动手搭这套知识库的直接动机。核心思路很朴素:把文档切碎、向量化、存进向量库,用户提问时先做语义召回,再把召回的片段喂给大模型组织答案。整条链路里,InternLM负责“理解和生成”,LangChain负责“编排和串联”,向量库负责“记忆”。三者各司其职,拼起来就是一个能读懂你私有资料、还能用自然语言回答问题的问答系统。
这套东西适合谁?我的判断是:有一定 Python 基础、能看懂报错、愿意折腾配置文件的人。你不需要是算法工程师,但要能理解“向量”“嵌入”“召回”这些概念大概在干什么。如果你连 pip 装包都没跑过,建议先补一下基础再来,否则后面每一步报错都会卡住你。反过来,如果你已经用过 LangChain 做过简单的对话 Demo,那这套知识库对你来说就是一次“从玩具到可用”的升级,重点在于文档处理、切分策略和检索质量调优这些真正决定效果的环节。
我踩过的第一个坑就是低估了“文档处理”的复杂度。很多人以为搭知识库最难的是模型调用,实际上模型调用是最简单的一步,几行代码就通了。真正耗时间的是:PDF 里的表格怎么提取、扫描件怎么处理、切分粒度怎么定、召回数量给多少合适、召回了不相关内容怎么办。这些问题没有标准答案,只能靠实测调。下面我把自己从零到跑通、再到调出可用效果的完整过程拆开讲,包括每一步为什么这么做、参数怎么定、哪里容易翻车。
2. 整体架构设计与技术选型逻辑
2.1 这套系统到底由哪几块拼成
先把骨架说清楚,不然后面讲细节容易迷路。整套系统我拆成四个层次:文档接入层、文本处理层、向量存储层、问答生成层。文档接入层负责把各种格式的文件读进来变成纯文本;文本处理层负责切分和向量化;向量存储层负责把向量存起来并支持相似度检索;问答生成层负责拿召回结果拼 Prompt 调模型出答案。
用 LangChain 的好处在于,这四个层次它都有对应的抽象。文档加载有DocumentLoader系列,切分有TextSplitter系列,向量存储有VectorStore接口,模型调用有LLM和Embeddings抽象。你换一个向量库或者换一个模型,上层代码基本不用大改。这种“可替换性”是我选 LangChain 而不是自己手写胶水代码的核心理由——手写当然更轻,但一旦你想换嵌入模型或者加一个重排序环节,改动量会让你怀疑人生。
InternLM 在这套架构里承担的是生成端。选它而不是别的模型,我的考虑有三点:一是它对中文的支持比较扎实,我的资料大部分是中文,英文模型处理中文长文本时偶尔会出现“答非所问”的情况;二是它有不同参数规模的版本,我可以在本地先用小参数版本跑通流程,确认链路没问题后再换大参数版本提升回答质量;三是它的对话模板和 LangChain 的集成比较顺,不需要自己写一堆适配代码。这里要说明的是,具体用哪个规模的模型取决于你的硬件条件,显存不够就先用小的把流程跑通,别一上来就追求大模型,链路不通的时候大模型只会让你更迷茫。
2.2 为什么是“检索增强生成”而不是“微调”
这个问题我被问过很多次。有人觉得,既然要让模型懂我的资料,为什么不直接拿资料去微调一个模型?我的回答是:微调和检索增强解决的是不同的问题。微调改变的是模型的“行为习惯”和“表达风格”,它适合让模型学会某种输出格式或者某种推理模式;而检索增强解决的是“知识时效性”和“知识私有性”,它让模型在回答时能引用你提供的最新资料。
打个比方,微调像是让一个学生反复练习某种解题套路,练完之后他做题的风格变了,但他脑子里记的具体知识点还是那些;检索增强像是给这个学生配了一本随时能翻的参考书,他遇到问题时先去查书,再根据书上的内容组织答案。你的资料会更新,参考书可以随时换页;但微调一次成本高、周期长,资料一变就得重新训。所以对于“私有知识问答”这个场景,检索增强是更务实的选择。
当然,两者不冲突。如果你发现模型总是不能按照你想要的格式输出答案,可以在检索增强的基础上再做轻量微调。但那是后话,先把检索链路跑通再说。
2.3 向量库选型:轻量优先,别一上来就上集群
向量库的选择上,我一开始也纠结过。市面上的选项很多,有需要独立部署服务的,有嵌入式的,有支持分布式集群的。我的建议很明确:个人或小团队场景,先用嵌入式向量库把流程跑通。原因很简单,你现在的瓶颈不在向量库的性能上,而在文档质量和切分策略上。你花两天部署一个集群,结果发现召回效果差是因为 PDF 解析把表格搞乱了,那这两天就白花了。
我实际用的是基于本地文件的向量库方案,数据落盘、随用随读,不需要额外起服务。它的检索速度在几千到几万条片段这个量级完全够用,而且调试的时候可以直接把向量库文件删了重建,非常省心。等你确认整条链路的效果达标、数据量也确实上来了,再考虑换支持更大规模检索的方案。到那时候,因为 LangChain 的VectorStore接口是统一的,替换成本并不高。
这里有个细节要注意:不同向量库对“相似度度量”的默认实现可能不一样,有的是余弦相似度,有的是内积,有的是欧氏距离。你在一个库上调好的召回数量,换到另一个库上效果可能变差,因为分数的分布变了。所以换库的时候不要只改连接配置,召回数量和阈值都要重新测一遍。
3. 文档处理与切分:决定效果上限的关键环节
3.1 文档加载:先把“脏数据”挡在门外
很多人搭知识库效果不好,根子不在模型,而在喂进去的文本本身就是脏的。PDF 是最典型的例子:双栏排版的论文,解析出来文字顺序是乱的;带表格的文档,表格内容被拆成一行行散字;扫描件干脆提取不出文字。这些问题不解决,后面切分和向量化做得再精细也是白搭。
我的处理原则是“先清洗再入库”。具体做法分几步:第一,按文件类型分流,纯文本和 Markdown 直接读,PDF 用专门的解析器读,扫描件单独标记出来走 OCR 流程;第二,解析完之后做一次人工抽检,随机打开几个文件看看提取的文本是否连贯,发现乱序或者乱码就针对这类文件调整解析参数;第三,对表格密集的文档,考虑把表格单独抽出来转成结构化文本再入库,而不是指望通用解析器把表格还原成可读的段落。
LangChain 的文档加载器生态比较全,但你要清楚它只是“调用解析库的封装”,解析质量取决于底层库。所以选加载器的时候,重点看它底层用的是什么解析引擎,而不是看名字好不好听。我实测下来,对于文字版 PDF,基于布局分析的解析器效果明显好于简单的文本抽取;对于扫描件,必须走 OCR,没有捷径。
注意:不要跳过人工抽检这一步。我见过太多人把几百个文件一股脑灌进去,然后抱怨模型答不准,最后一查发现解析出来的文本里混着大量页眉页脚和乱码。抽检花十分钟,能省你后面几小时的排查。
3.2 文本切分:粒度比你想的更敏感
切分是整套系统里最容易被低估的环节。切太大,一个片段里混着好几个主题,向量表示被“平均”掉了,召回时不够精准;切太小,一句话被拦腰截断,语义不完整,模型拿到手里也拼不出完整意思。我试过几种策略,最后稳定下来的方案是按语义边界切分,配合重叠窗口。
具体来说,优先按段落和标题切,因为段落本身就是作者划定的语义单元;如果单个段落太长,再按句子切;句子还太长(比如那种一句话三行的长句),才退回到按字符数硬切。重叠窗口的作用是防止关键信息正好落在切分边界上被切断,一般设成片段长度的百分之十到十五。比如片段定五百字,重叠就设五十到七十五字。
片段长度怎么定?这个没有万能值,但有个经验区间:中文场景下,三百到八百字是比较稳妥的范围。太短于三百字,片段承载的信息量不够,模型容易答得空洞;长于八百字,向量表示开始稀释,召回精度下降。我最后定的是五百字左右,配合八十字的overlap,实测召回的相关性和答案的完整性比较平衡。
这里要提醒一个坑:不同切分器对“字符”和“token”的计数方式不一样。有的按字符数算,有的按 token 数算。中文一个字大约对应一到两个 token,你按字符数设的参数换到按 token 计数的切分器上,实际片段长度会缩水。所以调参数的时候要看清楚单位,别照搬别人的数字。
3.3 元数据:给每个片段贴上“身份证”
切分完之后,每个片段除了文本内容,还应该带上元数据。元数据的作用有两个:一是检索时可以按来源过滤,比如你只想在“产品手册”这个目录下检索,不想让“会议纪要”混进来;二是生成答案时可以标注引用来源,让用户知道这句话是从哪个文件哪一页来的,增加可信度。
我一般会给每个片段打上这些字段:源文件名、文件路径、页码或章节号、入库时间、文档类别。这些信息在加载和切分阶段就能拿到,成本很低,但后面用起来非常方便。LangChain 的Document对象本身就支持metadata字典,切分器也会把原文档的元数据继承到子片段上,你只需要在加载时把该填的填好就行。
有个细节:页码信息在切分后可能会丢失,因为一个片段可能跨页。我的处理方式是切分时尽量不跨页,或者在元数据里记录“起始页”,接受一定的不精确。追求绝对精确的页码归属需要更复杂的处理,对于大多数问答场景,知道大概在哪几页就够了。
4. 向量化与检索:让“意思相近”能被算出来
4.1 嵌入模型的选择与实测对比
向量化的本质是把文本映射到一个高维空间里,语义相近的文本在这个空间里的距离也相近。嵌入模型就是这个映射函数。选嵌入模型的时候,我主要看三点:中文支持、向量维度、推理速度。
中文支持不用多说,我的资料以中文为主,用纯英文模型效果会打折扣。向量维度影响存储成本和检索速度,维度越高表达越细,但存储和计算开销也越大,常见的是几百到一千多维。推理速度决定了你入库和查询时的等待时间,尤其是入库阶段要处理成千上万个片段,模型太慢会让你等到怀疑人生。
我实际对比过几个方案,最后选的是一个在中文语义匹配任务上表现稳定的模型。这里不点名具体型号,因为模型迭代很快,你今天选的最优解半年后可能就被超越了。我的建议是:选一个当前社区反馈好、文档齐全、能本地跑的模型,先用小批量数据测一下召回效果,觉得不行再换。测试方法很简单:准备二十个你确定答案在库里的问题,看正确片段能不能排进召回结果的前几名,能进就说明模型可用。
提示:嵌入模型和生成模型是两回事,不要混用。嵌入模型只负责把文本变成向量,它不生成任何文字。你完全可以用 A 家的嵌入模型配 B 家的生成模型,LangChain 的抽象层支持这种混搭。
4.2 相似度检索的参数调优
检索阶段的核心参数是“召回数量”,也就是每次查询返回多少个最相似的片段。这个数字给太小,可能漏掉关键信息;给太大,又会把不相关的内容塞进 Prompt,干扰模型判断,还浪费上下文窗口。
我的经验值是三到六个片段。为什么是这个范围?因为大多数问题的答案集中在一到两个片段里,给三个能覆盖大部分情况,给六个是留一点冗余应对切分不完美的情况。超过六个之后,边际收益急剧下降,噪音反而上升。当然这跟你的片段长度有关,片段短就多给几个,片段长就少给几个,总的原则是让召回内容的总长度控制在一个合理范围内,别把上下文窗口撑爆。
除了数量,还有一个容易被忽略的参数是“相似度阈值”。低于某个分数的片段直接丢弃,不参与生成。这个阈值怎么定?我的做法是先不加阈值跑一批问题,观察正确片段和错误片段的分数分布,找一个能分开两者的分界点。不同嵌入模型的分数分布不一样,所以这个值必须实测,不能抄。
4.3 检索质量不够?试试这几个补救手段
如果实测下来召回效果不理想,别急着换模型,先排查这几个方向。第一,检查切分是否合理,很多召回问题其实是切分问题,关键信息被切散了,向量表示自然不准。第二,检查查询本身,用户的问题如果太短或者太口语化,和文档的书面表达差距大,可以尝试在检索前对问题做一次改写或扩展。第三,考虑加一个重排序环节,先用向量检索召回较多候选(比如二十个),再用一个更精细的模型对这二十个重新打分排序,取前几个。重排序能明显提升精度,代价是增加一点延迟。
我用过的一个实用技巧是“多查询召回”:把用户的一个问题用模型改写成几个不同角度的问法,分别去检索,然后把结果合并去重。这样做能覆盖更多表达方式,召回率会提升。缺点是查询次数变多,响应变慢,适合对准确性要求高、对延迟不敏感的场景。
5. 问答生成:Prompt 设计与上下文组织
5.1 Prompt 怎么写才不让模型“自由发挥”
检索增强生成最容易出的问题是模型“脑补”:召回的片段里明明没有答案,模型却根据自己预训练的知识编一个出来。要压制这种行为,Prompt 里必须明确约束。我的 Prompt 结构大概是这样的:先给一段系统指令,说明“你是一个基于给定资料回答问题的助手,只能使用资料中的信息作答,资料中没有的内容要明确说不知道”;然后把召回片段按顺序贴进去,每段标注来源;最后放用户问题。
关键点在于“明确说不知道”这句约束。不加这句,模型倾向于硬答;加了之后,它在资料不足时会老实承认。另外,我还会要求模型在答案里标注引用的片段编号,这样用户能核对答案是否有据可依。这个要求本身也能抑制编造,因为模型知道自己的输出会被对照检查。
还有一个细节:召回片段的排列顺序。我一般把相似度最高的放最前面,因为部分模型对上下文开头的内容注意力更强。如果你的模型有“中间内容容易被忽略”的倾向,可以考虑把最关键的片段放在开头和结尾,把次要的放中间。
5.2 上下文窗口的管理
召回片段加上对话历史加上系统指令,很容易把上下文窗口撑满。管理策略有几个:一是控制召回数量,前面说过三到六个;二是对片段做截断,超长的片段只保留和问题最相关的部分;三是对话历史只保留最近几轮,太早的轮次压缩成摘要或者直接丢弃。
我实际的做法是给整个 Prompt 设一个总长度上限,按“系统指令、召回片段、对话历史、用户问题”的优先级分配预算。召回片段优先保证,对话历史弹性压缩。这样能保证核心信息不被挤掉。LangChain 里有对应的上下文管理工具,但逻辑要你自己想清楚,工具只是帮你执行。
5.3 回答质量的评估方法
怎么判断这套系统“能用”了?我不用那些复杂的评估指标,就用最朴素的方法:准备一个测试集,包含三十到五十个问题,每个问题标注好正确答案所在的文档和大致内容,然后人工看系统回答是否准确、是否引用了正确来源、有没有编造。准确率能到八成以上,我就认为可以投入日常使用了。
这个测试集要覆盖不同类型的问题:事实型(某个参数是多少)、对比型(两个方案有什么区别)、操作型(某件事怎么做)、以及“库里没有答案”的问题(看它会不会老实说不知道)。最后一类特别重要,很多系统在“该说不知道”的时候硬答,这是最影响信任度的。
6. 实操踩坑记录与排查速查
6.1 我遇到过的典型问题
问题一:入库很慢,几千个片段跑了快一个小时。排查下来是嵌入模型在 CPU 上跑,而且没做批处理。解决办法是开批处理、调大 batch size,有条件的话用 GPU 加速。批处理大小不是越大越好,太大容易爆内存,我一般从十六开始试,逐步往上加直到内存吃紧。
问题二:召回的内容总是差一点,正确答案排第四第五。这是典型的切分问题,关键信息被切到了相邻片段里。解决办法是加大重叠窗口,或者调整切分边界让语义单元更完整。我加了重叠之后,正确答案进前三的比例明显上升。
问题三:模型回答里出现了资料中没有的数字。这是编造,Prompt 约束不够强。我在系统指令里加了“如果资料中没有明确给出数字,不要推测”,并且在输出后做了一次简单的校验,检查答案里的数字是否出现在召回片段中,不出现就标记出来人工复核。
问题四:同一个问题问两次,答案不一样。生成模型有随机性,温度参数调低能缓解。我把温度设到接近零,答案的稳定性明显提升。但要注意,温度太低有时会让回答变得生硬,这个要权衡。
6.2 排查速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 召回内容完全不相关 | 嵌入模型不适配中文,或切分过碎 | 换模型测试,检查片段长度 |
| 正确答案排不进前几 | 切分边界切断语义,或召回数量太少 | 加大重叠,增加召回数量 |
| 模型答非所问 | Prompt 约束不足,或召回噪音太多 | 强化指令,加相似度阈值 |
| 模型编造内容 | 资料不足时硬答 | 加“不知道”约束,做输出校验 |
| 入库速度极慢 | 未批处理,或模型在 CPU 上跑 | 开批处理,考虑硬件加速 |
| 回答不稳定 | 生成温度过高 | 调低温度参数 |
6.3 几条压箱底的经验
第一,先小后大。先用十几个文件、几百个片段把整条链路跑通,确认每个环节都正常,再灌全量数据。全量数据出问题时,你很难定位是哪个文件、哪个环节的锅。
第二,保留原始片段的可追溯性。每个片段都要能追回源文件和位置,出问题时能快速定位。我见过有人切分完就把原文丢了,结果召回出问题想核对都无从下手。
第三,别迷信参数,相信测试。网上流传的各种“最佳实践”参数,换个数据集就不灵了。你自己的资料什么特点,只有你自己的测试能告诉你。花半天时间做个小测试集,比抄十个参数值都管用。
第四,版本要锁。LangChain 和各类模型库迭代很快,接口时不时变。跑通之后把依赖版本锁死,别手贱升级,否则某天早上你会发现昨天还能跑的代码今天报 import 错误。
7. 后续可以继续打磨的方向
这套系统跑通之后,我还在持续调。目前想到的几个方向:一是加多路召回,向量检索之外再叠一路关键词检索,两路结果融合,对专有名词和编号类的查询效果更好;二是加对话记忆的压缩,让多轮对话不丢上下文又不撑爆窗口;三是把引用来源做成可点击的链接,直接跳到原文位置,提升使用体验。
还有一个我比较看好的方向是“查询路由”:不是所有问题都需要检索,有些寒暄或者通用知识问题直接让模型答就行,检索反而引入噪音。加一个轻量的分类器判断问题类型,走不同处理路径,能提升整体效率和准确率。这个改动不大,但收益明显,我打算下一步就做这个。
最后说一句实在话:这套东西的上限取决于你的资料质量,不取决于模型多强。资料干净、结构清晰、切分合理,小模型也能答得不错;资料一团乱麻,再大的模型也救不回来。所以如果你刚开始搭,把七成精力花在文档处理上,剩下的再分给模型和参数调优,这个投入产出比是最划算的。