公司管理层把两千多页产品资料丢过来,要求做一个“内部AI助手”——员工问什么它就答什么,还要标注回答来源。我一开始以为这只是接个大模型API的活儿,但真正梳理下来才发现,横在面前的是三个绕不过去的门槛:数据不能出内网、问答必须溯源、文档还得持续更新。最终这个项目落地成了一套私有化部署的企业RAG知识库,前后正好用了两周。把当初怎么设计架构、怎么一步步搭起来、以及最值得记录的坑,在这里做一个完整的复盘。
我会尽量把决策背后的理由也讲清楚,而不是只列技术清单。毕竟私有化RAG这种东西,网上教程一抓一大把,真正拉开差距的往往是踩坑后的取舍。
1. 需求拆解:为什么企业知识库最终没有选择公有云方案
1.1 数据边界与合规是最大的拦路虎
这个项目最初的诱惑非常大:直接把文档丢到某公有云平台的“知识库”功能里,上传、解析、问答一气呵成,甚至用不着自己写代码。但讨论到一半就卡住了——文档里包含未公开的产品参数、客户方案、报价逻辑,还有一部分合作方的技术资料。企业一旦把这些内容传到外部服务上,首先就过不了自己内部的合规评估。
这不是信不信任某个厂商的问题,而是企业文档的所有权和使用边界必须严格限定在内网。只要数据出过内网,哪怕是“只用来做向量化”,在审计和合同层面都很难交代。所以需求评审的第一条结论就是:所有环节,包括文档解析、向量化、检索、生成,都必须跑在我们自己的服务器上。私有化不是一种技术偏好,而是企业级知识库的硬性前提。
1.2 权限控制与溯源要求看似简单,做到却不容易
第二个需求点是权限隔离。企业问答不是单纯“翻文档回答问题”,不同角色能看的文档范围完全不同。比如销售团队可以问报价政策,但新入职实习生不应该看到完整的成本结构。这意味着检索阶段就必须做权限过滤,而不是在答案生成之后再删减。
同时还要求回答必须可溯源。管理层明确说,AI给出任何结论,都要能定位到对应的文档和页码,方便人去复核。这就决定了不能一步到位把问题丢给大模型直接作答,而是先走“文档召回→重排→根据召回内容生成答案”的链路。也就是说,这个项目本质上不是做个ChatGPT套壳,而是要做一个带检索和引用机制的私有化知识库系统。
1.3 两周工期内如何把需求切成阶段
需求边界确认后,我把工期切成两段:第一周打通从“文档上传”到“问答返回”的完整链路,第二周专门用来解决准确率、并发和更新问题。头两天只做一件事——确定架构和技术选型,因为后面所有开发都依赖这套骨架。
这里也想给一个经验:接到类似任务时,别一上来就陷入“用哪个框架”的纠结,先把业务边界说清楚。知识库是给多少人用的、文档总量多大、更新频率多高、回答要不要分权限,这四个问题直接决定了架构的复杂度。这个项目的实际情况是:内部约300个员工使用,文档总量初期在2000页左右,按周更新,需要按部门做权限过滤。这个体量决定了我的选型方向——轻量、可控、不过度设计。
2. 总体架构设计:模块划分与技术选型的真实考量
2.1 五层架构大致长什么样
整个系统我按职责拆成了五个层面,每一层只关心一件事:
- 接入层:对外提供文档上传和问答接口,走FastAPI实现,问答流用SSE(Server-Sent Events)做流式输出。
- 文档处理层:负责解析PDF、Word、扫描件,清洗内容,做分块和元数据抽取。
- 索引与检索层:包含向量库、关键词索引、重排序模块,是RAG最核心的部分。
- 模型层:embedding模型负责文本向量化,生成模型负责根据召回内容组织答案。
- 管理层:批量入库任务、日志、监控、权限映射,保证系统能被人维护。
之所以拆这么细,是因为踩坑后的复盘点非常清晰:数据解析出问题找文档处理层,答非所问找检索层,生成质量差找模型层和提示词,谁都不用来回扯皮。
2.2 技术栈对比与选型理由
这里直接放一张当初对比后的选型表,后面每项都会解释为什么这么选:
| 模块 | 最终选择 | 没选的方案 | 理由 |
|---|---|---|---|
| embedding模型 | BGE-M3 | 通用英文模型、text2vec | 中文长文档效果更好,支持多语义粒度 |
| 向量库 | Milvus 2.x | Chroma、pgvector | 支持批量写入、过滤检索、文档规模扩展余地大 |
| 生成模型 | Qwen2.5-14B-Instruct | 更大参数模型 | 单张A100可部署,中文效果足够 |
| 后端框架 | FastAPI自研编排 | LangChain全流程 | 可控性高,链路每一环都能单独调试 |
| 重排序模型 | BGE-Reranker-v2-m3 | 无rerank直接取topk | 显著提升“精确命中”概率 |
| OCR | PaddleOCR | 商用OCR服务 | 私有化要求,模型可本地部署 |
我这边的推理服务器是单张A100 80GB,所以生成模型选了14B量级,跑起来余量充足。如果是两张4090或者单张A100也可以考虑7B版本,再小的话答案组织能力会明显下滑,尤其是在多文档融合问答时,小模型经常忘了引用来源。
2.3 没有全盘使用LangChain或LlamaIndex的原因
很多教程推荐直接用LangChain把RAG链路拼起来,确实方便,但我实际测试后发现,LangChain的抽象层太厚,出了问题很难定位。比如想看“某一步到底用了哪些文档片段”,它内部的retriever逻辑包装了好几层,打印出来的trace很长也很难读。而企业知识库最需要的恰恰是可观测、可单独调试的链路。
所以最终方案是:参考LangChain的思路,但核心流程全用FastAPI和Python自己编排,遇到需要工具类函数时才引用它的模块。这样做的代价是代码量多了一些,但换来了对每个环节的绝对掌控——比如我可以在检索阶段随时打印向量召回和关键词召回的原始分数,这在调优时帮了大忙。
3. 核心链路从零搭建:每一环的落地细节
3.1 文档解析:PDF、扫描件和表格各有各的处理方式
第一周有整整两天耗在文档解析上,因为RAG的上限由数据质量决定,解析出来的文本如果是乱的,后面检索再优化也白搭。
我根据文档类型做了分流:普通文本型PDF用PyMuPDF抽取,速度快且能保留页码信息;扫描件走PaddleOCR,因为不少历史资料是图片格式;Word文档用python-docx直接读,但要注意把页眉页脚去掉;最麻烦的是带复杂表格的PDF,后面第4章会详细讲这个坑。
统一的处理入口我定义成一套内部schema,每个解析结果都包含doc_id、page、source_name、chunk_text、metadata这五个字段。这样做的好处是无论原始文件是什么格式,后面分块、向量化、检索都只面对一种标准化结构,不用反复适配。解析完的内容还会做一个简单清洗:去掉多余空白符、合并被PDF换行切断的英文单词、按页记录页码。这些看起来不起眼,但对后续召回准确率影响非常大。
3.2 分块策略:不是越大越好,也不是越小越好
分块是整个RAG里最容易被忽略但又最重要的参数。块太大,向量化的语义容易被稀释,召回精度下降;块太小,单个片段缺乏上下文,生成模型无法组织出完整答案。我做了几组实验对比,最终选择了“先按文档结构切分,再按块大小回退”的策略。
具体做法是:先根据Markdown标题或PDF书签定位章节边界,把每个章节作为天然的分块候选。如果某个章节内容超过设定上限,再按段落合并,控制在768个token左右,同时保留96个token的overlap。overlap的作用是避免检索时刚好截断在关键边界上,让相邻块之间有一点信息冗余,召回率会好看很多。
def split_document(text, max_chunk=768, overlap=96): sections = split_by_headings(text) for section in sections: if len(section) < max_chunk: yield {"chunk_text": section} else: paragraphs = split_by_paragraphs(section) chunk = "" for para in paragraphs: if len(chunk) + len(para) > max_chunk: yield {"chunk_text": chunk} chunk = para else: chunk += "\n\n" + para实测下来,这种“结构优先+大小兜底”的策略比单纯按固定长度硬切,Top5命中率大概提升了十个百分点左右。原因也很好理解:按段落和章节切分出来的片段,本身就是一个相对完整的语义单元,而硬切出来的块经常是一段话讲到一半就断了。
3.3 向量化与写入:批量入库的工程细节
embedding用的是BGE-M3,支持稠密向量、稀疏向量和多向量三种表示。我在生产环境只启用了稠密向量加稀疏向量两种模式,稠密向量负责语义相似度召回,稀疏向量用来做关键词层面的匹配兜底。它的中文效果比早期那些以英文为主的模型好很多,这个后面踩坑章节会重点说。
批量入库时第一个教训是:千万不要一条一条请求embedding模型,慢到无法忍受。我改成每次打包32个chunk请求一次,利用GPU批量推理的优势,2000页文档全部向量化大约用了四十分钟。写入Milvus时开了批量insert,每批500条向量,整体吞吐量非常可观。
写入的每条向量都带上metadata,重点包括doc_id、chunk_index、department、update_time。这个设计直接支撑了后面的增量更新和权限过滤。没有这一步,增量更新那部分几乎没法做。
3.4 检索与生成:混合检索、重排序与流式输出的组装
检索设计成了三段式:向量召回 + 关键词召回 + 交叉重排序。向量召回负责语义相近但表达不同的情况,关键词召回保证专有名词和编号不被漏掉,重排序则把两路结果合并后的候选重新打分排序。
我的接口大致长这样:
def hybrid_search(query, top_k=20, bm25_k=10): dense_result = vector_search(query, top_k) sparse_result = bm25_search(query, bm25_k) merged = merge_and_dedupe(dense_result, sparse_result) reranked = reranker.rerank(query, merged) return reranked[:5]这里的重点是top_k不能太小。向量和关键词两路召回先各自取前十到二十个候选,合并后再由reranker精排,只取前五个喂给大模型。如果一开始就只取五个,rerank阶段可选余地太小,正确结果很容易被过滤掉。
提示词模板是这套系统效果稳定的一半,开场明确告诉模型:只基于提供的资料片段回答,找不到相关内容就直接说明没有找到,不许编造。同时要求答案末尾标注引用来源的文档名和页码,方便用户点击核查。
流式输出用的是SSE,FastAPI里通过StreamingResponse实现,前端收一个事件流逐字渲染。这个体验和直接用API接口等完整结果完全不同,企业内部第一次demo的时候,流式输出带来的“AI感”明显更强,决策层对项目的接受度也高了不少。
4. 两周里最有必要记录的五个坑
4.1 表格解析错乱导致回答准确率跌到三成
这个坑排在我所有踩坑记录的第一位。项目里的产品资料有大量参数表,比如“内存容量、CPU型号、最大功耗”这种三列结构。一开始我用PyMuPDF直接提取文字,出来的结果是每个单元格的文本按阅读顺序被拆得乱七八糟,有时一行内容横跨了表格的三列,有时两行数据被并成一行。
问题现象是:那些涉及产品参数的问题,系统要么答不出来,要么把A型号的功耗说成B型号的功耗,准确率一度只有三成左右。我当时排查的第一件事是把提取后的原始文本打印出来,结果一眼就发现问题不在检索也不在模型,而是解析阶段就把数据弄坏了。
根因是普通PDF解析库对复杂表格的布局还原能力非常弱。解决方式分两层:对于简单表格,用pdfplumber的extract_tables直接取结构化数据,把每一行转换成一个独立的知识块;对于复杂合并单元格的表格,用ppstructure做表格识别,输出HTML格式的表格结构,再按行提取内容。每行作为chunk落到知识库后,产品参数类问题的准确率回升到了85%以上。
这个坑给我的教训是:凡是知识库里有表格,一定要单独在解析层做表格识别,不能指望通用解析一把梭。
4.2 中文场景下embedding模型的选型教训
项目第一版我图省事,直接沿用了网上教程里常见的通用英文embedding模型。结果一测试,中文问句还能召回一些内容,但一旦文档里是中英混排、或者同义词比较多,Top5命中率就跌得很难看。比如文档里写“故障恢复时间”,用户问“宕机多久能恢复”,向量检索完全匹配不上。
那个阶段的我一度以为问题出在分块策略上,反复调chunk大小和overlap,收效甚微。直到我把一个同义改写测试用例单独拎出来,对比不同模型的向量相似度分数,才意识到是embedding模型本身的语言覆盖能力不够。
换成BGE-M3之后,这类同义改写场景的召回效果立刻好转。验证方法也留下了:准备一组“同一意思不同说法”的测试问题集,专门用来对比embedding模型的鲁棒性,而不是只看总命中率。这套测试集后来也被复用到了模型升级的评估里。
4.3 检索到了但还是胡编:幻觉问题的根因不只在模型
上线前一晚,我用测试集跑了一遍,突然发现一个非常诡异的现象:文档里明明有正确答案,检索Top5里也确实召回到了正确片段,但模型给的答案还是错的。这就说明问题不在检索而在生成环节。
排查链路是这样的:先打印出每次问答实际召回的前五个chunk,发现正确片段排在第一位,但内容被截断了——因为分块时overlap设置太小,这个片段缺少了回答问题所需的完整上下文。比如“保修期为一年”这句话在chunk里,但“保修期自签收之日起计算”这个条件在另一个chunk里,模型单独看第一个片段,就给了错误理解。
解决方式分两步:一是把overlap从64调整到96,尽量保证关键条件不落在块边缘;二是修改提示词,要求模型只能基于片段回答,如果片段中的信息不足以完整作答,必须输出“资料中未找到完整说明”。第二个改动立竿见影,从产品名到参数之间的张冠李戴急剧减少。
这让我认识到一个关键点:RAG的幻觉不都是模型“编造”,有很大一部分是召回片段不完整导致的“误读”。先查召回内容再调提示词,顺序不能反。
4.4 多人同时用的第一周就卡死
内部测试第一天,十几个同事同时提问,系统直接假死。直观上以为是大模型推理扛不住并发,但看了监控发现GPU利用率并不高,反而是后端服务大量超时。
排查看了一圈:当时Gunicorn起了2个worker,每个worker同时只能处理一个同步请求。embedding请求是同步调用模型服务的,当一个worker在处理embedding时,所有其他排队的问答请求都被堵住。再加上Milvus的连接池默认参数非常保守,并发一上来连接就被占满。这个链路排查起来比较费劲,因为表面现象是“整体变慢”,实际瓶颈在两个不起眼的地方。
修复方案是三层配合:Gunicorn的worker数调到2*CPU核数+1;Milvus客户端连接池上限从10调到40;embedding推理改成独立异步队列,前端请求不再同步等待。改完之后,20人同时提问也能稳定响应,首token延迟大约在1.5秒左右。
这个坑提醒我,私有化RAG的性能瓶颈往往不在大模型推理,而在外部依赖的并发配置上。第一周打压力测试非常有必要,别等上线当天才暴露。
4.5 文档更新后老版本内容还在“历劫”
上线之后第一次更新文档,我们只做了“新增”:新文档的chunk向量化后插入向量库。结果立刻出现了一个尴尬问题——新的产品手册发布后,用户提问时还是经常被召回老版本的参数,新旧数据混在一起,答案前后矛盾。
排查时才发现,Milvus里的删除操作不是立刻生效的,旧chunk如果还带着原来的doc_id,就会在下一次检索时继续被召回。解决方式是在写入阶段就给每条向量record里存了doc_id,更新流程变成:按doc_id先删除全部旧chunk,再插入新chunk,最后重建关键词索引。删除完成后还要等几秒做一致性检查,确保旧向量不再被召回。
这里踩下的最重要经验就是:知识库的“更新”和“新增”要当成两个完全不同的操作来设计。如果没有在第一次入库时就规划好doc_id和metadata,后面想清理旧数据会非常痛苦。
5. 从能用走向好用:检索效果调优的一线经验
5.1 先给项目建立评估集,不然调优全靠感觉
第二周调优一开始几乎没法做,因为“感觉变好了”和“实际变好了”完全是两回事。后来我花了半天,从真实工单和同事提问里收集了100个问题,逐条标注答案所在的文档和页码,形成一个最小可用的评估集。
有了评估集,就定义了两个核心指标。第一个是Hit Rate,即正确答案所在文档片段是否进入最终送给模型的前5个片Recall中。第二个是Answer Accuracy,即人工判断模型给出的答案是否正确。Hit Rate解决“有没有找对资料”的问题,Answer Accuracy解决“最终答得对不对”的问题。没有这套评估集,我后面所有的调优手段都无从验证。
5.2 调优顺序比调优手段更重要
调优过程中我踩了一个效率大坑:一开始先调提示词,模型答案质量有所提升,但很快到了一个平台期。后来回头检查才发现,召回阶段就没把正确答案捞上来,提示词再努力也没用。
之后我把调优顺序固定成了:文档清洗→分块策略→embedding模型→检索融合→重排序→提示词。顺序背后的逻辑很简单:前一层决定后一层效果的上限。解析出来的文本是乱的,分块再合理也白搭;分块不合理,embedding效果再好也召不回完整语义;召回内容不完整,提示词写得再好模型也巧妇难为无米之炊。
这个顺序帮我大幅减少了无效工作。比如最后调提示词时,我可以放心地在“召回内容已经正确”的前提下进行,一旦效果不佳,问题肯定出在生成环节,不用再回头怀疑检索。
5.3 三个立竿见影的检索增强手段
评估集建立后的第一个手段是query扩展。对于问题比较绕的情况,我先让生成模型把原问题改写成一个更完整的检索式,再拿扩展后的query做召回。比如用户问“数据存在哪最安全”,扩展成“数据库存储方案中哪种方式安全性最高”,召回效果明显提升。
第二个手段是元数据过滤。加上权限隔离后,我同步把update_time也作为过滤条件,问答时默认只检索最近一年内的文档。这个看似简单的改动,让大量过期内容从候选集里消失,Hit Rate稳步上升。
第三个手段是关键词兜底。向量召回对同义词友好,但对精确编号和缩写经常力不从心。BM25的关键词召回正好补齐这个短板,两路结果合并后再交给reranker做最终排序。实测数据里,关键词兜底对“型号、编号、人名”这类问题的贡献最大,几乎占了正确答案来源的一半。
5.4 调优前后的效果对比
下面是这个项目调优前后的核心指标对比,都是基于那100道评估题的实测数据:
| 指标 | 初始版本 | 调优后 | 提升幅度 |
|---|---|---|---|
| Hit Rate(召回命中) | 58% | 91% | +33个百分点 |
| Answer Accuracy(答案正确) | 62% | 85% | +23个百分点 |
| 平均首token延迟 | 3.1秒 | 1.6秒 | 快了一倍 |
| 20人并发成功率 | 约70% | 98% | 明显改善 |
Hit Rate从58%到91%这段,靠的是表格解析、embedding模型替换和rerank引入;Answer Accuracy从62%到85%,主要是overlap调整和提示词约束的功劳。当然85%的准确率距离“完全可用”还有距离,目前生产环境里我们把“找不到明确依据时明确说明”的情况也算作正确,因为它避免了更严重的误导。
我个人这次最大的感受是:私有化RAG的难点从来不在大模型本身,而在数据治理、检索细节和工程化的耐心。两周时间搭起来一个Demo并不难,难的是把准确率从“能跑”推到“敢用”。如果你也在做类似项目,建议在动手前就想清楚三个问题——文档有多乱、权限多复杂、发版后怎么更新,这三件事决定了你后面要踩多少坑。