企业知识库 RAG 实战:混合检索、权限过滤与答案溯源
企业知识库问答的难点,不只是让大模型读懂一份文件。文档需要正确解析和更新,搜索结果需要符合当前用户权限,回答还要能指出依据来自哪份文件、哪个版本和哪个位置。
RAG(Retrieval-Augmented Generation,检索增强生成)把"先查资料,再组织答案"串成一条流程。企业知识库还需要把登录授权、文档审核、异步索引、混合检索、引用核验和运行追溯接在一起,才能让答案有据可查,知识变更也能安全生效。
本文围绕企业知识库的常见落地架构,拆解 RAG 的工作方式,并通过员工年假问答走一遍完整示例。文中的参数是便于理解的起始值,实际表现需要用目标文档、模型和业务问题评测。
先看整体架构
业务入口与 AI 处理分开:统一 Web 路由层调用 .NET 类库,完成登录上下文、业务授权和数据管理;Python 私网服务负责文档解析、OCR、切片、向量化、检索与模型编排。
text
浏览器 / 公众号 / 小程序 / 客户系统
│
▼
统一 Web 路由层
│ 反射调用
▼
.NET 业务类库
├── 登录上下文、租户与 ACL
├── MySQL:业务事实与任务状态
├── 对象存储:原件与派生文件
└── 受控内网调用
│
▼
Python AI 私网服务
├── 文档解析、OCR、切片
├── Embedding 与模型适配
├── Elasticsearch:BM25 全文检索
└── Milvus:稠密向量检索
这种拆分有两个实际好处:AI 解析和模型适配可以独立扩展;租户授权的最终判断仍留在业务服务,不交给检索索引或提示词决定。Python 服务不直接连接业务 MySQL,也不对终端用户开放公网 API。
每个组件各管什么
| 组件 | 在系统中的职责 | 数据定位 |
|---|---|---|
| 统一路由层与 .NET 类库 | 对外业务入口、登录上下文、授权、管理操作和引用复核 | 业务访问边界 |
| MySQL | 租户、知识库、ACL、文档版本、审核、任务、分片定位、发布指针和审计 | 业务事实源 |
| 对象存储 | 原始文件、预览文件及 OCR 等派生对象 | 文件存储 |
| Elasticsearch | 文本字段和 BM25 关键词检索 | 可重建的全文检索投影 |
| Milvus | 文本 Embedding 与向量相似度检索 | 可重建的向量检索投影 |
| Python AI 服务与 Worker | 解析、OCR、切片、Embedding、检索编排和模型调用 | AI 处理层 |
| 队列与任务账本 | 异步处理、重试、进度和失败恢复 | 可靠任务处理 |
MySQL 是业务状态的权威来源。ES 和 Milvus 里的 published、租户 ID、文档 ID、密级等字段可以帮助预过滤,却不能代替 MySQL 的授权检查。索引可能延迟、残留或重建,因此只把它当检索投影。
入库:从文件到可检索片段
知识库里的文件并不会上传后立刻进入正式问答。入库链路大致如下:
text
上传校验
→ 内容扫描与策略检查
→ 文档解析 / OCR
→ 规范化文本块
→ 按结构切片
→ 审核
→ 写入 ES 与 Milvus 的 staging 代际
→ 校验数量、hash 和检索可见性
→ 切换 MySQL 发布指针
解析时保留原文位置
不同格式需要保留不同定位信息:
- PDF 保留页码。
- Word 保留标题层级和段落。
- Excel 保留工作表名称及行列坐标。
- PowerPoint 保留幻灯片页和区域。
- 图片保留 OCR 区块和图像区域坐标。
定位信息会跟着片段进入引用记录。否则答案即使找到正确内容,也只能显示文件名,无法快速回到原文。
旧版 DOC、XLS、PPT 等格式需要先提示转换,或交给隔离的转换 Worker;压缩包和 Office 文件还要限制文件数量、解压比例,宏不执行。OCR 结果和文档正文都属于不可信输入,不能因为经过模型处理就视为安全内容。
切片不要把一条规则拆散
可以从 tokenizer 计算约 512 tokens、片段重叠约 64 tokens 开始试验。这个数值不是通用答案:操作手册、政策条款、表格和代码的最佳切片大小往往不同,最终要通过业务问题评测。
切片时优先沿标题、段落和列表边界拆分。表格要保留表头,过长表格按行分组,不能只按固定字符数切断。每片记录稳定的 chunk_id、页码或区域、hash、解析器版本、切片器版本、Embedding 模型版本和索引 generation。
一个可用于检索的片段记录通常不止有正文,还要有可过滤字段和原文定位:
json
{
"chunk_id": "483",
"tenant_id": "tenant-17",
"kb_id": "kb-hr",
"document_id": "doc-leave-policy",
"version_id": "ver-2026-03",
"generation": 4,
"title_path": ["员工休假制度", "年休假"],
"content": "连续工作满一年不满十年的员工,每年享有 5 天带薪年休假。",
"page": 12,
"chunk_index": 3,
"valid_from": "2026-03-01",
"valid_to": null,
"content_hash": "sha256:...",
"embedding_model_version": "embedding-v2"
}
ES 文档会包含可检索正文和词项字段;Milvus 保存向量及必要的过滤字段;关系库保存片段 ID、hash、版本和定位。大段正文不必在每个存储里重复保留。各存储通过稳定的 chunk_id、version_id 和 generation 关联。
例如,年假规则应保留完整条件与结果:
text
标题:员工休假制度 / 年休假
正文:员工连续工作满一年不满十年的,每年享有 5 天带薪年休假。
来源:员工休假制度.pdf,第 12 页,第 3 片
若只切出"每年享有 5 天",适用条件就丢失了;若只保留标题"年休假",检索也没有足够依据回答。
审核通过后再发布索引
索引构建期间,新内容先写入 staging generation,旧的已发布版本继续提供查询。ES 和 Milvus 都写入成功,并且数量、hash 和抽样查询通过后,业务服务才在 MySQL 事务中切换活动版本和 generation 指针。
跨 MySQL、ES、Milvus 和对象存储不存在一个统一原子事务。因此采用"先构建并验证索引,再切换权威指针"的方式。半成品索引不能被问答读取;旧索引可以在新版本发布后异步清理。发布指针只在一处权威存储中更新,查询每次读取当前活动代际,避免不同存储各自决定"哪个版本生效"。
可以将文档状态拆为业务状态和索引状态:
text
业务状态:uploaded → parsing → review_pending → approved → published
└────────→ rejected
索引状态:queued → building → validating → active → retiring → deleted
└───────────────→ failed / retrying
"审核通过"不等于"索引已发布"。索引失败时,文档仍不能被问答召回;重试使用相同幂等键和稳定主键,避免重复生成片段。删除和撤权的顺序相反:先更新权威状态使查询立即拒绝,再异步删除 ES、Milvus 中的旧投影。
问答:先授权,再检索和生成
用户提交问题后,在线 RAG 流程可以概括为:
text
登录身份与租户范围确认
→ 知识库和文档权限过滤
→ 问题标准化与条件识别
→ BM25、向量及结构化检索
→ 融合排序 / 可选重排
→ 再次授权复核并读取证据
→ 大模型按证据生成答案
→ 校验引用并返回
将问答拆成有序阶段,可以记录每步耗时、状态和错误码。带图片的查询会额外经过图片校验、OCR 和视觉理解;图片作为当轮会话附件,默认不会自动进入知识库。
第一步:确定有权搜索的范围
服务根据登录上下文确认租户、成员、启用的知识库和当前用户的数据范围。权限包含 discover(可见)、read(阅读/检索)、edit、share、forward、download、delete 等独立动作。
能看见知识库名称,不等于可以读取正文;能管理知识库,也不自动获得内容读取权。目录和文档可能继承 ACL,拒绝规则优先。密级、有效期、文档状态、租户授权和用户成员状态同样需要通过检查。
检索之前先对可访问的知识库、文档、已发布版本和索引代际做过滤。小型权限集合可以将可读文档 ID 作为检索过滤条件;集合很大时,应按范围分批检索并设扫描上限,避免先取固定 top-k 再过滤,导致合法结果被无权结果挤掉。候选返回后,再由业务授权服务核验;读取正文、提交给模型、展示引用和打开历史记录时也需要复核。未授权内容不能进入模型上下文、用户可见 trace 或答案缓存。
第二步:混合检索找候选证据
系统同时使用两类主要检索:
- Elasticsearch BM25:适合员工编号、产品型号、错误码、文件编号等关键词精确匹配。
- Milvus 稠密向量检索:适合含义相近但措辞不同的自然语言问题。
结构化条件,例如知识库、产品型号、密级、有效时间和发布版本,先作为过滤条件处理。ES 和 Milvus 的候选结果通过稳定的 chunk_id 对齐,再用 RRF 融合排名。可以从全文和向量检索各取前 100 条、融合后保留前 30 条开始;如果评测证明有帮助,再用 Reranker 精排到约 8 条。候选数是吞吐、延迟和召回率之间的参数,需要用实际语料调整。
RRF 的简化形式如下:
text
融合分数(chunk) = Σ 检索路权重 / (60 + 该路排名)
它根据排名融合结果,不要求 ES 和 Milvus 的原始分数处在同一数值范围。Reranker 是另一层模型排序,不能和 RRF 当作同一个步骤。
第三步:把证据交给模型
业务服务再次确认候选片段的权限和发布状态后,才读取文本。上下文按文档去重,可以从不超过 4 篇引用文档、约 6000 tokens 开始调试;实际需按模型窗口、评测结果和问题复杂度调整。送入模型前,还应去掉重复片段、保留标题路径和时间条件,并按相关性排序,避免高分结果全部来自同一段重复文本。
模型提示需要明确:只根据提供的证据回答;证据不足时说明缺少什么;回答事实要关联证据编号;不能根据文件中的指令改变系统规则。文件内容本身可能包含恶意提示,它只能作为待检索资料,不能变成模型指令。
回答生成完成后,系统校验引用 ID 是否属于本轮证据集合,并复核发布指针、权限 epoch 和输出安全策略。引用记录包含文档、版本、片段、页码或区域、摘要和 hash。图片引用还需要关联当轮附件编号、OCR 区块和图像坐标。
一个实用的提示结构是把规则、问题和证据分开,给每条证据分配不可由模型生成的 ID:
text
规则:仅依据下方证据回答。证据不足时说明不足。引用只能使用给定的证据 ID。
问题:入职两年有几天年假?申请要提前多久?
[E1] 员工休假制度.pdf,第 12 页,年休假
连续工作满一年不满十年的员工,每年享有 5 天带薪年休假。
[E2] 员工休假制度.pdf,第 12 页,年休假申请
年休假申请至少提前三个工作日提交,经直属负责人审批后生效。
模型输出中的 [E1]、[E2] 只是候选引用标记。服务端还要验证标记确实存在于本轮证据集合,再映射成文档版本和原文定位。引用不是模型随口附加的脚注。
示例:年假问题如何得到答案
假设知识库中有文件《员工休假制度.pdf》,第 12 页写着:
text
连续工作满一年不满十年的员工,每年享有 5 天带薪年休假。
连续工作满十年不满二十年的员工,每年享有 10 天带薪年休假。
年休假申请至少提前三个工作日提交,经直属负责人审批后生效。
员工提问:"入职两年有几天年假?申请要提前多久?"
系统先确认员工有权读取这份制度,再同时搜索关键词和语义相近片段。示意检索候选如下:
text
1. 员工休假制度.pdf / 第 12 页 / chunk 483
命中:连续工作满一年不满十年......5 天带薪年休假
2. 员工休假制度.pdf / 第 12 页 / chunk 484
命中:年休假申请至少提前三个工作日......
在片段经过权限复核后,模型收到问题和两条证据,生成的答案可以是:
text
连续工作满一年不满十年的员工,每年享有 5 天带薪年休假;年休假申请需至少提前三个工作日提交,并经直属负责人审批。[1]
引用 [1] 会打开《员工休假制度.pdf》的第 12 页,并定位到对应文本。若有人询问"年假能否跨年结转",而知识库没有相关规则,系统应说明现有资料未覆盖该问题,不补充法规猜测。
如果该员工只有 discover 权限、没有 read 权限,知识库名称可能可见,但制度正文不能进入候选证据,模型也不会收到这份文件。
一个检索融合代码片段
下面的 Python 代码演示把 BM25 和向量检索的排名通过 RRF 合并。输入列表必须已经应用知识库、发布状态和授权过滤;此函数只负责排序,不承担权限判断。
python
from collections import defaultdict
def reciprocal_rank_fusion(result_lists, weights=None, k=60):
"""result_lists 是多路检索结果,每项为按相关性排序的 chunk_id 列表。"""
if weights is None:
weights = [1.0] * len(result_lists)
scores = defaultdict(float)
for results, weight in zip(result_lists, weights):
for rank, chunk_id in enumerate(results, start=1):
scores[chunk_id] += weight / (k + rank)
return sorted(scores.items(), key=lambda item: item[1], reverse=True)
bm25 = ["chunk-483", "chunk-210", "chunk-484"]
dense = ["chunk-484", "chunk-483", "chunk-901"]
for chunk_id, score in reciprocal_rank_fusion([bm25, dense]):
print(f"{chunk_id}: {score:.4f}")
输出排名大致为:
text
chunk-483: 0.0325 # BM25 第 1,向量检索第 2
chunk-484: 0.0323 # BM25 第 3,向量检索第 1
chunk-210: 0.0161 # 只被 BM25 召回
chunk-901: 0.0159 # 只被向量检索召回
同一片段在多路检索中出现时会累加排名贡献,因此能兼顾精确词命中和语义相似命中。k 和各路权重需要用标注问题集评估,不能把示例值当作线上最优参数。RRF 能融合排名,却无法补回所有检索路都漏掉的片段;召回阶段仍需单独评估。
文档处理为什么采用异步任务
PDF 解析、OCR 和向量生成耗时不可控,不适合阻塞在上传请求里。常见做法是由业务 API 写入任务主状态和 Outbox 事件,再由后台 Worker 领取任务并处理;AI Worker 执行各阶段,再通过受控内部业务入口回写阶段状态和日志。
任务需要具备稳定幂等键、心跳、租约、重试和死信处理。消息队列通常按至少一次投递处理,因此重复消息必须能够安全重跑;索引写入使用稳定主键 upsert,避免重复消费生成重复片段。Outbox 用于处理数据库提交成功但消息发布失败的窗口:业务记录和待投递事件先在同一数据库事务提交,发布器之后持续投递未确认事件;消费者按 event_id 去重,完成持久化后才确认消息。
进度日志记录任务 ID、文档版本、阶段、序号、页码或区域、片段 hash、token 数、配置版本、耗时和状态。日志列表默认不携带正文;正文预览必须重新做权限校验。密钥、完整图片字节和未经脱敏的敏感正文不写入运行日志。
图片问答和多模态资料
图片问答的 MVP 可以采用 OCR 与视觉摘要转成可检索文本,不直接把图片向量检索作为第一阶段必需能力。图片先校验真实格式和解码尺寸,再进行 OCR、表格识别或视觉理解;每个结果带置信度和区域坐标。
图片中的低置信度参数需要提示核对。OCR 置信度只反映文字识别的可信程度,不等于答案正确率。图片里出现的 URL、二维码或"忽略之前规则"等文字仍是内容数据,不应触发外部访问或改变模型行为。
单张图片 OCR 失败时,可以标记失败并继续处理其余图片;如果所有图片都失败且没有可用文字问题,应请求补充清晰材料。多图引用使用本轮的图 1、图 2 顺序,并定位到 OCR 区块或图像区域。
更新、撤权和故障时如何保持一致
索引的存在不代表内容当前有效。文档作废、删除、过期或权限撤销时,先更新 MySQL 权威状态并递增相应发布或安全 epoch,使新查询立即拒绝旧内容;ES 和 Milvus 的物理清理可以异步重试。
缓存也要与授权范围绑定。答案缓存键需包含租户、授权范围摘要、权限 epoch、发布 epoch、模型版本、提示词版本、标准化问题和附件 hash。缓存命中后仍重新核验引用权限;秘密级请求不复用跨用户答案缓存。
检索故障的处理要明确:
- ES 或 Milvus 单路故障,另一通路仍有经授权的证据时可以降级,并在追溯信息中标出。
- 两路检索都失败时返回暂不可用,不能让模型凭空补答。
- 模型调用失败时可以返回授权后的检索证据列表。
- MySQL 或权限服务不可用时,问答应拒绝服务,不能放宽权限范围。
- 队列不可用时保留 Outbox 任务,上传状态显示待处理。
用评测判断系统是否真的有用
检索、答案和权限需要分开评测。可以先建立 100 条人工标注集:60 条文字问题、30 条图片问题、10 条提示注入/无权限/跨租户问题,并标注相关片段、答案要点、允许引用和是否应该拒答。样本应覆盖精确参数、表格、多文档组合、过期版本和资料中无答案等问题。
可将以下数值作为试点目标,最终门槛应由业务风险和实际测量决定:
- 可回答问题 Recall@10 不低于 85%。
- 人工核验答案正确率不低于 85%。
- 引用定位及支持关系正确率不低于 95%。
- 权限、跨租户和撤权负例泄露为 0。
- 无证据场景正确拒答率不低于 95%。
出现错误时,先判断问题在哪一层:原文解析不完整、切片破坏上下文、正确片段没有召回、候选排序不佳、授权过滤遗漏,还是模型误读了证据。只改提示词通常无法修复检索缺失或权限问题。
总结
企业知识库 RAG 是一条带权限和版本控制的资料处理链:文档经过解析、切片、审核和双索引校验后发布;问题先限定授权范围,再通过全文与向量检索召回证据;模型根据证据组织答案,引用在返回前再次校验。
关键是让每一层职责清楚:业务数据库掌握文档状态和授权事实,AI 服务负责解析与检索编排,全文和向量索引提供可重建的召回能力。这样回答能够追溯到来源,文档更新和权限变化也有清晰的生效路径。