为什么要写这个系列

做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

然后演示三种结果:

  1. developer查询DELAYED,命中订单状态文档,返回带引用的回答。
  2. developer查询定价策略,返回"不确定"(角色无权访问)。
  3. 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:按角色过滤文档,检索之前先决定当前角色能看到哪些chunk
  • KeywordRetriever:在角色过滤后的可见chunk里打分,取topK=3
  • RagAnswerService:生成回答和citation,evidence为空则拒答

RoleDocumentPolicy先过滤权限

当前Demo的权限规则很简单,但位置很关键——过滤发生在检索前,不是检索后

先决定能看什么,再在可见范围内检索;先过滤再打分,而不是先打分再剔除。如果反过来做,模型可能已经看过敏感chunk,再从回答里擦除为时已晚。

文档入库时,DocumentIngestionService.rolesFor(file)给每个chunk打上可读角色:

文件 allowedRoles
pricing-policy.md finance
其他文档 developersupportops

所以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,才比较稳。

企业知识库宁可少答,也不要乱答。权限、引用、不确定,比向量库更重要。

更多推荐