Java 8老系统企业RAG实战:权限、引用、不确定比向量库更重要
为什么要写这个系列
做Java后端十年,我接触过不少企业的核心系统。
金融、电商、政务——行业不同,但底层的现状惊人地相似:生产系统还在Java 8,框架停在Spring Boot 2.x甚至更早,代码跑了很多年,没人敢轻易动。
去年开始,几乎每个项目都在谈接AI。
但真正动手的时候,团队就卡住了。
不是因为不懂大模型,而是老系统本身接不住。JDK版本不够,Spring AI引不进来;依赖树牵一发动全身,升级一个包怕带崩一片;生产流量压着,不敢拿主流程赌一个AI试点。
更危险的是硬塞。我见过团队在老系统的Service层直接new一个HttpClient调模型API,Prompt拼在业务代码里,超时没配、降级没有。模型响应慢的时候,老系统的订单查询线程池被占满,主流程跟着卡住。还有团队把用户手机号、身份证原样送进Prompt,过了两个月才被安全审计发现。
这种事见多了,我就开始想一个问题:老系统不具备直接接入AI的条件,这是不是大多数企业的常态?
答案是肯定的。而且这不应该成为接不了AI的理由。
核心思路其实就三条:
老系统少改 —— 不升级 JDK,不引入 Spring AI 依赖
AI 能力旁路 —— 独立部署,老系统通过 HTTP 或 MQ 调用
企业边界先行 —— 脱敏、审计、降级、幂等比模型调用更重要
这个系列就是把这三条线展开成10讲。
从AI Gateway到MCP工具中心,从SQL Agent到RAG知识库,从工单Agent到多Agent研发团队——每一讲都围绕同一个前提:
你的老系统还在跑Java 8,你不能为了AI去赌它的稳定性。
每讲配套一个可运行的Maven Lab,不讲空架构,不写Hello World。你跑得通Demo,看得到边界,拿得到代码。
这就是我做这个系列的原因。
Java 8老系统企业RAG实战:权限、引用、不确定比向量库更重要
很多人第一次做企业知识库,会把重点放在向量库上:
用哪个 embedding
用哪个 vector database
chunk 多大
topK 设多少
这些当然重要,但不是企业RAG最先要解决的问题。
企业真正担心的是:
敏感文档能不能被普通角色检索到
回答有没有引用来源
没有证据时会不会胡说
不同租户、部门、角色看到的知识是否一致
所以第6讲不先卷向量库,而是先做一个可运行的Java 8 Stub RAG,把企业边界跑通。
最终效果
代码目录:
code/spring-ai-enterprise-lab/labs/chapter06-rag-knowledge-base
运行:
.\compile-and-run.ps1
Demo会读取:
sample-data/docs/order-status.md
sample-data/docs/pricing-policy.md
然后演示三种结果:
- developer查询
DELAYED,命中订单状态文档,返回带引用的回答。 - developer查询定价策略,返回"不确定"(角色无权访问)。
- finance查询定价策略,可以命中财务受限文档。
当前Demo用的是关键词检索,不是向量检索
这一点必须说清楚。
代码里有KeywordRetriever,没有EmbeddingIndexService。这是有意为之的设计:先跑通RAG管道,再替换检索层。
真实项目的检索路径:
EmbeddingIndexService ← 用向量模型把文档向量化
Vector Store ← 存储向量(Milvus / Chroma / PGVector)
Semantic Retriever ← 基于语义相似度检索
当前Demo用关键词匹配跑通的路径:
DocumentIngestionService ← 文档清洗和切块
KeywordRetriever ← 关键词检索(无需外部依赖)
RoleDocumentPolicy ← 角色权限过滤
RagAnswerService ← 生成回答 + citation
两层接口兼容:检索器替换成向量版本后,上层代码不需要改。
所以KeywordRetriever在本讲里不是"低配版向量库",而是把RAG主链路固定下来:
同一批文档
同一套角色过滤
同一种 citation 输出
↓
先用关键词检索验证边界
↓
再替换成语义检索验证效果提升
这是企业项目更稳的演进顺序:先证明权限、引用和拒答是对的,再引入embedding和向量库。
代码结构
src/main/java/com/ynzz/lab/chapter06
├── common
│ ├── KnowledgeAskRequest
│ └── KnowledgeAnswer
├── ingestion
│ ├── DocumentChunk
│ └── DocumentIngestionService
└── rag
├── Citation
├── KeywordRetriever
├── RagAnswerService
└── RoleDocumentPolicy
DocumentIngestionService:读取Markdown、清洗、切块和标记角色权限RoleDocumentPolicy:按角色过滤文档,检索之前先决定当前角色能看到哪些chunkKeywordRetriever:在角色过滤后的可见chunk里打分,取topK=3RagAnswerService:生成回答和citation,evidence为空则拒答
RoleDocumentPolicy先过滤权限
当前Demo的权限规则很简单,但位置很关键——过滤发生在检索前,不是检索后。
先决定能看什么,再在可见范围内检索;先过滤再打分,而不是先打分再剔除。如果反过来做,模型可能已经看过敏感chunk,再从回答里擦除为时已晚。
文档入库时,DocumentIngestionService.rolesFor(file)给每个chunk打上可读角色:
| 文件 | allowedRoles |
|---|---|
pricing-policy.md |
finance |
| 其他文档 | developer、support、ops |
所以pricing-policy.md不是检索到以后再隐藏,而是在检索前就被过滤掉。
判断逻辑:
chunk.allowedRoles 包含 request.role
→ 可以参与检索
chunk.allowedRoles 不包含 request.role
→ 直接跳过,不进入打分
这条边界非常重要。如果先检索、再过滤,就可能出现模型已经看过敏感chunk的情况。企业RAG应该反过来:先决定用户能看什么,再在可见范围里找证据。
KeywordRetriever怎么打分
KeywordRetriever当前不是向量检索,而是一组显式规则:
for chunk in chunks:
if !RoleDocumentPolicy.canRead(role, chunk):
continue
score = score(question, chunk.content)
if score > 0:
加入候选
候选按 score 降序排序
最多返回 topK=3
打分规则:
| 问题命中 | 文档命中 | 加分 |
|---|---|---|
DELAYED |
DELAYED |
+10 |
订单状态 |
订单 |
+2 |
定价 |
定价 |
+10 |
策略 |
策略 |
+3 |
退款 |
REFUND |
+5 |
developer提问"订单状态 DELAYED 代表什么?",会命中order-status.md:
DELAYED 命中 +10
订单状态 / 订单 命中 +2
总分 12
developer提问"公司明年的秘密定价策略是什么?“,即使问题里有"定价"和"策略”,pricing-policy.md会先被角色过滤掉,返回evidence=[]。
finance提问同样的问题,过滤结果不同,可以命中定价文档。
这就是本讲最核心的逻辑:同一个问题,不同角色看到的候选证据集合不一样。
RagAnswerService怎么生成回答
RagAnswerService.ask的核心逻辑只有两个分支:
分支一:evidence为空 → 拒答
检索结果为空,意味着当前角色在可见范围内没有找到任何匹配证据。直接返回uncertain=true,citations为空:
{
"answer": "不确定:知识库中没有找到当前角色可引用的证据。",
"uncertain": true,
"filters": ["tenant=demo", "role=developer", "topK=3"],
"citations": []
}
分支二:evidence不为空 → 基于最佳证据回答
取evidence[0]作为最佳证据,用它的content拼出回答,把所有evidence转成citation列表:
best = evidence[0]
answer = "根据知识库,{best.content} 回答已基于检索证据生成。"
citations = evidence.map(chunk -> {source, chunkId})
uncertain = false
企业RAG最该先跑通的三件事:
权限过滤是否生效
证据为空是否拒答
证据不为空是否带 citation
回答必须带引用
developer查询"订单状态 DELAYED 代表什么?",系统会返回:
{
"answer": "根据知识库,DELAYED:订单发货延迟,需要客服或运营介入确认原因。回答已基于检索证据生成。",
"uncertain": false,
"citations": [
{
"source": "order-status.md",
"chunkId": "order-status.md#3"
}
]
}
企业用户不只需要一个答案,还需要知道:
答案来自哪个文件
来自哪个片段
是否可以复核
如果没有引用,RAG很容易变成"看起来很像内部知识库,其实仍然是模型自由发挥"。
没有证据时必须不确定
很多RAG项目的失败,不是因为检索不到,而是因为检索不到还要硬答。
本讲把这个边界写得很直接:
没有当前角色可引用证据
→ 回答"不确定"
→ citations 为空
这个规则比"让模型更聪明"更重要。企业知识库宁可少答,也不要乱答。
企业避坑
第一,不要把所有文档都塞给模型。 应该先按租户、角色、部门过滤,再检索。
第二,不要只看召回率。 企业RAG还要看引用准确性、权限正确性、拒答正确性。
第三,不要把"不确定"当失败。 在企业场景里,能诚实地说"不确定",反而是可靠系统的表现。
第四,不要忽略文档入库前的脱敏。 真实项目里,文档入库前还需要处理身份证、邮箱、客户姓名、合同编号等显式敏感信息。
第五,不要跳过检索层验证。 向量检索上线前,应该对比关键词检索的准确率和召回率,确认向量化的收益是否大于引入的复杂度。
从Demo到落地,还差什么
向量检索替换:保持上层接口不变,只替换检索器内部实现。替换前用关键词检索,能命中DELAYED、订单号、状态码这类显式词;替换后用语义检索,应该能命中"发货慢了怎么办""订单迟迟没到"这类同义表达。对比维度:相同权限过滤和citation规则下,语义检索的召回是否更高、误召回是否变多、拒答是否仍然可靠。
文档入库脱敏流水线:在DocumentIngestionService里加入SensitiveTextMasker,处理手机号、身份证、邮箱等显式敏感字段,支持正则和NER识别。
权限粒度细化:当前是文档级别的角色过滤。更细粒度的实现可以是chunk级别权限,或者基于标签的动态过滤。
Chunk策略优化:老项目需要根据文档类型选择策略——接口文档按端点切、数据库字典按表切、运维手册按操作步骤切。
知识更新机制:老文档更新后,向量库需要同步更新,不然会出现"文档改了,AI还在答旧内容"。
RAG效果评估:需要建立评估集(已知答案的问答对),定期测准确率、引用率和拒答率。
本讲的RAG基础管道,也是第7讲工单助手的核心依赖——工单进来先查知识库,找不到答案再走分类和派单流程。
小结
企业RAG的主线不是:
文档 + 向量库 + 模型
而是:
文档清洗 / 脱敏
↓
权限过滤
↓
检索(关键词 or 向量)
↓
引用回答
↓
无证据拒答
这条边界跑通以后,再替换真实embedding、向量库和Spring AI,才比较稳。
企业知识库宁可少答,也不要乱答。权限、引用、不确定,比向量库更重要。
更多推荐
所有评论(0)