1. 为什么我要自己搭一套知识库问答系统
去年下半年,我接手了一个内部资料检索的活儿。当时的情况是:团队积累了大量的产品文档、会议纪要、技术方案和历史工单,散落在好几个地方,找东西全靠关键词搜索加人工翻页。新同事入职问得最多的一句话就是“那个XX文档在哪”,老同事每天也要花不少时间在重复回答相似问题上。我粗略统计了一下,一个二十来人的小团队,每周花在“找信息”和“重复解释”上的时间加起来能超过十五个小时。这个数字看着不起眼,但折算成人力成本相当可观。
最开始我想得很简单,直接上大模型不就行了?把文档丢进去让它读,然后回答问题。实际一跑就发现两个致命问题:第一,模型根本不知道我们内部的业务术语和产品代号,回答起来一本正经地胡说八道;第二,上下文窗口再大也装不下全部资料,而且每次全量塞进去成本高得离谱。于是思路转向了检索增强生成这条路——先从一个可控的知识库里把相关片段捞出来,再让模型基于这些片段组织答案。这就是我后来花了一个多月折腾的基于知识库和 LLM 的问答系统。
这套东西说白了就是给大模型配一个“随身资料库”:用户提问,系统先去资料库里找最相关的几段内容,再把问题和这些内容一起交给模型,让它照着材料回答。它解决的核心问题是让模型的回答有据可查、有源可溯,而不是凭空编造。适合谁来参考?我觉得三类人最对口:一是手里有一堆非结构化文档、想做成智能检索的开发者;二是想给内部工具加个问答入口的技术负责人;三是对 RAG 流程好奇、想动手跑通一个完整 Demo 的学习者。下面我把自己踩过的路、做过的取舍、调过的参数,尽量完整地摊开讲。
2. 整体架构设计与技术选型思路
2.1 系统分层的核心逻辑
我把整套系统拆成了四个相对独立的层:文档处理层、向量检索层、生成层、交互层。这么拆不是为了好看,而是因为每一层的失败模式完全不同,分开之后排查问题会快很多。文档处理层负责把各种格式的原始资料清洗成干净的文本块;向量检索层负责把这些文本块变成向量、建索引、做相似度匹配;生成层负责把检索结果和用户问题组装成提示词、调用模型;交互层就是前端界面和会话管理。
为什么强调分层?我举个实际例子。系统刚上线时,有用户反馈“问某个功能怎么配置,回答总是答非所问”。我一开始以为是模型不行,换了两个模型都没改善。后来逐层排查才发现,问题出在文档处理层——那个功能的说明文档是表格形式,解析时表格结构被压平成了连续文本,关键参数和说明混在一起,切块之后语义完全乱了。如果系统是铁板一块,这种问题很难定位。分层之后,我可以单独把那一批文档的切块结果打印出来看,一眼就发现了症结。
提示:分层设计的一个隐性好处是,每一层都可以单独做评估。检索层看召回率,生成层看答案忠实度,互不干扰。
2.2 向量库与模型选型的取舍
向量库这块,我对比过几个常见方案。轻量场景下,本地文件型的向量索引足够用,部署简单、零依赖;数据量上到几十万块以上,就得考虑带持久化和增量更新能力的服务型方案。我最终选的是一个支持本地持久化、API 简洁的轻量库,原因是我们的文档量在十万块这个量级,单机完全扛得住,而且团队里没人想维护额外的数据库服务。
嵌入模型的选择上,我试过通用型的中文嵌入模型和几个多语言模型。实测下来,通用中文嵌入模型在业务术语上的表现明显更稳,因为我们的文档里大量出现产品代号和内部缩写,多语言模型反而容易把这些词映射到不相干的语义空间。这里有个经验:嵌入模型不是越大越好,关键看它在你所在领域的语料上有没有充分训练过。我当时的做法是抽了五十组“问题-正确片段”对,手动算了一下各模型的召回命中率,选命中率最高的那个,而不是盲目追新。
生成模型这块,我建议先用一个中等规模的模型把流程跑通,再根据效果决定要不要换更大的。很多人一上来就上最强模型,结果发现瓶颈根本不在生成环节,而在检索质量上,白白烧了预算。我自己的配置是:日常问答用中等模型,遇到复杂推理类问题再路由到更强的模型,这样成本和效果能兼顾。
2.3 为什么不用微调而选 RAG
这个问题我被问过好几次。微调当然也能让模型“记住”内部知识,但它有几个绕不开的短板:知识更新要重新训练、训练成本高、而且模型记错了你很难纠正。RAG 的优势在于知识是外挂的,文档改了重新索引一下就行,模型本身不用动。对于我们这种文档每周都在更新的场景,RAG 的维护成本低了一个数量级。
当然 RAG 也不是银弹。它的短板是检索环节可能漏掉关键信息,导致模型“没看到”正确答案。所以我在设计时把重心放在了检索质量上,后面会详细讲切块和召回优化的细节。一句话总结选型逻辑:知识频繁变动、需要溯源、预算有限,选 RAG;知识相对固定、追求极致响应速度、有充足训练资源,才考虑微调。
3. 文档处理与切块:最容易被低估的环节
3.1 文档清洗的实操要点
原始文档的脏乱程度远超想象。PDF 里有页眉页脚、有水印文字;网页导出的 Markdown 里混着导航栏残留;Word 文档里的自动编号在转换后变成一堆乱码字符。我的清洗流程是这样的:先统一转成纯文本,再用规则去掉重复出现的页眉页脚模式(比如每页都出现的公司名加页码),然后处理连续空行和特殊符号。
这里有个细节值得说:表格的处理要单独对待。我一开始把表格直接转成文本,结果列与列之间没有分隔,读起来像天书。后来改成把每一行转成“列名:值”的形式,比如“超时时间:30秒;重试次数:3次”,这样切块之后语义依然完整。这个改动让涉及配置参数的问答准确率提升了一大截。
注意:清洗阶段宁可多花时间,也不要带着脏数据往下走。脏数据在检索阶段会被放大,最后表现为“模型答非所问”,而你还以为是模型的问题。
3.2 切块策略的参数计算
切块是 RAG 里最考验经验的一环。块太大,检索出来的内容冗余,模型容易被无关信息干扰;块太小,语义不完整,检索到了也答不好。我用的策略是按语义边界切分,再控制块大小。具体做法是先按段落和标题层级切,然后对超长的段落做二次切分,块大小控制在三百到五百字之间,块与块之间保留五十字左右的重叠。
为什么是这个范围?我做过一组对比实验。块大小设成两百字时,召回率不错但答案经常缺上下文;设成八百字时,上下文够了但检索精度下降,因为一个块里混了多个主题。三百到五百字这个区间,在我们的文档类型上表现最均衡。重叠部分的作用是防止一个完整的句子被切断在两块之间,五十字大约是一到两句话的长度,够用了。
另外我加了一条规则:标题和它下面的内容尽量放在同一个块里。因为很多问题的答案依赖于“这个内容属于哪个章节”这个上下文。比如“重试策略”这个词,单独出现时你不知道它说的是网络请求还是任务调度,但带上所属章节标题就清楚了。
3.3 元数据的附加价值
每个块除了文本内容,我还附带了几个元数据字段:来源文档名、章节路径、更新时间、文档类型。这些字段在检索时可以参与过滤,比如用户问“最新的部署流程”,我可以先按更新时间过滤掉过期文档,再做向量匹配。元数据还解决了溯源问题——答案里可以标注“来自某文档某章节”,用户点进去就能看原文,信任度完全不一样。
我踩过的一个坑是元数据字段设计得太随意,早期只存了文档名,结果同一个文档有多个版本,检索出来根本分不清是新版还是旧版。后来强制要求每个块带上版本号和更新时间,这个问题才彻底解决。
4. 检索环节的调优与召回增强
4.1 向量检索的基本流程与参数
检索的基本流程不复杂:用户问题进来,用同一个嵌入模型转成向量,然后在向量库里做相似度搜索,取回 top-k 个最相似的块。关键在参数上。top-k 取多少?取太少可能漏掉正确答案,取太多会把噪声塞给模型。我实测下来,k 取四到六之间比较合适,再多的话生成阶段的提示词会变得很长,而且后面的块相关性明显下降。
相似度阈值也值得设一个。我设了一个下限,低于这个分数的块直接丢弃,哪怕它排在 top-k 里。原因是有些问题在知识库里根本没有对应内容,这时候硬塞几个不相关的块给模型,它反而会强行编一个答案。设了阈值之后,系统在“不知道”的时候能老实说“资料里没有找到相关内容”,这比胡编强得多。
4.2 混合检索:关键词与向量的互补
纯向量检索有个软肋:对精确匹配不敏感。比如用户问某个具体的错误码“E1024”,向量检索可能召回一堆语义相近但错误码不同的内容。解决办法是加一路关键词检索,把两路结果做融合。我的做法是向量检索取 top-6,关键词检索取 top-6,然后用倒数排名融合的方式合并排序,取前五条送给生成层。
这个改动效果立竿见影。涉及具体编号、参数名、函数名的问答,准确率提升非常明显。原因很直白:关键词检索擅长精确命中,向量检索擅长语义泛化,两者互补。融合时我给两路设了相近的权重,没有刻意偏袒哪一方,因为实测下来平衡权重在大多数问题上都够用。
4.3 查询改写提升召回
用户的提问往往很口语化,直接拿去检索效果打折扣。比如“那个上传文件老失败咋整”,向量化之后和文档里“文件上传失败排查步骤”的相似度未必高。我加了一个轻量的查询改写步骤:用一个小模型把口语化问题改写成更接近文档表述的检索式,同时生成两到三个变体,分别检索后合并结果。
改写不是让模型自由发挥,而是给了明确的约束:保留原问题的核心意图,补充可能的同义词和术语。比如“咋整”改写成“排查方法”,“老失败”改写成“失败原因”。这一步增加了一点延迟,但召回率的提升值得这个代价。我实测过,改写前后的召回命中率差距能到十个百分点以上。
提示:查询改写用的小模型不需要很强,重点是快和稳定。改写结果要做长度限制,防止模型把问题扩写成一大段话,反而稀释了检索意图。
4.4 重排序的取舍
检索回来一批候选块之后,还有一个可选步骤:重排序。用一个专门的重排序模型对候选块和问题的相关性做精细打分,重新排列。我试过加这一层,效果确实有提升,尤其是候选块之间相关性接近的时候。但代价是延迟增加明显,因为重排序模型要对每个候选块单独推理。
最后的决定是只在复杂问题上启用重排序。判断标准是检索结果的分数分布:如果 top 几名的分数拉得很开,说明检索很确信,不需要重排;如果分数挤在一起,说明区分度不够,这时候才启用重排序。这个按需启用的策略,把平均延迟控制在了可接受范围内。
5. 生成阶段的提示词设计与答案控制
5.1 提示词的结构化组织
生成阶段的提示词我改了很多版,最后稳定下来的结构是这样的:先给模型一个明确的角色设定和任务说明,然后放检索到的资料片段(每段标注来源编号),最后放用户问题,并明确要求“只依据提供的资料回答,资料中没有的内容要明确说明”。这个“只依据资料”的约束非常关键,它把模型的发挥空间压到了最小,大幅降低了编造的概率。
资料片段的排列顺序也有讲究。我把相关性最高的放在最前面和最后面,中间放相关性稍低的。这是借鉴了模型对首尾位置更敏感的特性,实测下来比按顺序平铺的效果好一些。每段资料前面加上编号,方便模型在答案里引用,也方便用户核对。
5.2 答案格式与引用规范
我要求模型在答案里用方括号标注引用的资料编号,比如“根据资料[2],超时时间默认是三十秒”。这个要求一开始模型执行得不好,经常忘记标注。后来我在提示词里加了几个示例,明确展示标注的写法,执行率就上来了。引用标注的价值在于可验证:用户看到答案后能直接定位到原文,发现不对可以立刻反馈,这比一个黑盒答案可信得多。
答案长度我也做了约束。太短可能没答全,太长则啰嗦。我的做法是让模型先给一句直接结论,再展开细节,最后如果有必要补充注意事项。这个“结论先行”的结构对使用者很友好,扫一眼就知道答案是什么。
5.3 处理“资料不足”的场景
这是很多人容易忽略的场景。用户问了一个知识库里没有的问题,系统该怎么办?我的处理是分两级:如果检索阶段所有块的分数都低于阈值,直接返回“资料库中未找到相关内容,建议补充资料或换个问法”;如果检索到了一些块但相关性一般,就让模型基于现有资料给出“可能相关”的回答,并明确标注“以下内容基于有限资料,仅供参考”。
这个设计避免了两个极端:既不会在没资料时硬编,也不会在资料不足时直接摆烂。实际使用中,用户对这个诚实的反馈接受度很高,反而比一个似是而非的答案更让人信任。
6. 实操落地:从零跑通的完整步骤
6.1 环境准备与依赖安装
整个系统的依赖不算多,核心是嵌入模型、向量库和生成模型的客户端。我用 Python 搭的原型,环境用虚拟环境隔离。安装依赖时有个小坑:嵌入模型和向量库对某些底层库的版本有要求,版本冲突会导致导入报错。我的做法是先装向量库,再装嵌入模型相关的包,让依赖解析器自己协调版本,实在冲突再手动锁版本。
python -m venv qa_env source qa_env/bin/activate pip install vector-store-client embedding-model-sdk llm-client模型文件我提前下载到本地,避免每次启动都去拉取。嵌入模型选的是中等规模的中文模型,生成模型通过接口调用。配置文件里把模型路径、向量库路径、检索参数都抽出来,方便调参时不用改代码。
6.2 文档入库的完整流程
入库流程分四步:读取、清洗、切块、向量化写入。我写了一个批处理脚本,遍历文档目录,按扩展名分派不同的解析器。解析出来的文本先过一遍清洗规则,然后按前面说的策略切块,每个块带上元数据,批量调用嵌入模型转成向量,最后写入向量库。
批量写入时要注意控制批次大小。一次塞太多会导致内存飙升,太少则速度慢。我实测每批五十到一百块比较合适。写入完成后做一个校验:随机抽几个块,用它的原文去检索,看能不能把自己召回。这个自检能快速发现索引写入是否正常。
6.3 问答接口的实现要点
问答接口的逻辑是一条流水线:接收问题、查询改写、双路检索、结果融合、组装提示词、调用生成模型、返回答案和引用。每个环节我都加了日志,记录耗时和中间结果。这些日志在调优时是金矿,能清楚看到是哪一步拖了后腿或者丢了信息。
接口返回的结构里,除了答案文本,我还带了引用列表和检索耗时。前端可以据此展示“答案来源”和性能指标。会话管理上,我保留了最近几轮对话的上下文,但做了长度限制,防止历史对话把提示词撑爆。
6.4 效果评估的简易方法
没有评估就没有优化。我建了一个小型的评估集:五十个真实用户问题,每个问题人工标注了正确答案所在的文档块。评估指标看两个:检索命中率(正确答案块有没有进 top-k)和答案忠实度(生成的答案有没有超出资料范围)。前者靠脚本自动算,后者抽样人工看。
这个评估集不大,但足够指导调优方向。每次改切块策略或检索参数,跑一遍评估集,看指标是升是降,避免凭感觉瞎调。我建议一开始就建这个评估集,哪怕只有二三十个问题,也比没有强。
7. 常见问题排查与避坑经验
7.1 典型问题速查表
| 现象 | 可能原因 | 排查方向 | 解决思路 |
|---|---|---|---|
| 答案答非所问 | 切块语义断裂或检索召回错误 | 打印检索到的块看内容 | 调整切块大小,加混合检索 |
| 模型编造内容 | 提示词约束不足或资料不足硬答 | 检查提示词和相似度阈值 | 强化“只依据资料”约束,设阈值 |
| 精确术语检索不到 | 纯向量检索对精确匹配弱 | 用错误码等词测试召回 | 加关键词检索做融合 |
| 回答冗长啰嗦 | 提示词未约束长度和结构 | 看提示词是否有格式要求 | 要求结论先行,限制展开 |
| 响应慢 | 重排序或生成模型过大 | 看各环节耗时日志 | 按需启用重排序,模型分级 |
| 新文档检索不到 | 索引未更新 | 检查入库流程是否执行 | 建立文档更新触发重建索引 |
7.2 我踩过的三个深坑
第一个坑是切块时把代码块切断了。技术文档里有大量代码示例,按字数硬切会把一段代码拦腰截断,检索到半段代码根本没法用。后来我加了规则:检测到代码块边界时,优先在代码块结束后切分,宁可块大一点也不要切断代码。
第二个坑是嵌入模型和生成模型对术语的理解不一致。嵌入模型把某个产品代号理解成一个意思,生成模型理解成另一个意思,导致检索对了但回答跑偏。解决办法是在提示词里给关键术语加简短说明,让生成模型和检索的语义对齐。
第三个坑是忽略了多轮对话的指代问题。用户第一轮问“部署流程”,第二轮问“它需要多久”,这个“它”在检索时完全丢失了指代对象,检索结果乱七八糟。后来我在查询改写环节把对话历史带上,让改写模型把指代消解掉,问题才解决。
7.3 性能与成本的平衡技巧
成本和性能的平衡贯穿始终。我的几个做法:嵌入计算做缓存,相同文本不重复算;检索结果做短时缓存,高频问题直接命中;生成模型分级,简单问题走小模型;批量入库放在低峰时段跑。这些优化单独看都不起眼,叠加起来能把整体成本压下来不少。
还有一个容易被忽略的点:向量库的索引类型选择。不同的索引类型在召回速度和精度上有差异,数据量不大的时候用精确检索就行,数据量上来了再换近似检索。我一开始就上了近似索引,结果召回精度下降,后来数据量没到那个程度,换回精确检索反而又快又准。
8. 上线后的迭代与个人体会
系统上线后,我养成了一个习惯:每周看一遍用户的实际提问日志。这些日志比任何评估集都真实,能暴露很多设计时想不到的问题。比如有用户问“这个功能和那个功能有什么区别”,这种对比类问题一开始答得很差,因为检索只召回了一个功能的内容。后来我针对对比类问题做了特殊处理,强制两路检索分别召回两个对象的内容,效果才上来。
还有一个体会是,知识库的质量决定了系统的上限。模型再强,资料本身写得含糊,答案也不可能清晰。我在迭代过程中花了不少时间反过来推动文档规范化——把过时的文档标记归档,把关键参数集中到一处,这些工作看着和系统无关,但对问答质量的提升是根本性的。
如果让我给准备动手的人一句建议,那就是:先把评估集建起来,再开始调参。没有评估的调优就是盲人摸象,改了半天不知道是变好还是变坏。评估集不用大,但一定要真实,来自实际用户的问题最有价值。这套系统没有什么高深的技术,拼的是对细节的耐心——切块切得细一点,检索多一路,提示词多改几版,答案就靠谱一分。