Spring AI 实战 RAG:文档解析、混合检索与权限控制

0 阅读

为什么大模型不知道公司的报销制度

很多团队在引入大模型后的第一周都会遇到同一个尴尬场景:业务方兴致勃勃地问“出差住宿标准是多少”,模型却顾左右而言他,或者编造一个看似合理但完全错误的数字。

这不是模型不够聪明,而是它的知识边界止步于训练数据。你公司的内部制度、最新的产品参数、历史工单记录,从来不在它的训练集里,也不应该在里面。

要让模型掌握“你的知识”,工程上通常有三条路:微调(Fine-tuning)、长上下文全塞、以及检索增强生成(RAG)。

微调常被误解为注入知识的手段。实际上,微调改变的是模型的“行为倾向”和“语气风格”,让它更像你的员工说话,但它很难准确记住并复述一条具体的、随时可能变化的事实。一旦制度更新,你需要重新训练,成本高昂且滞后。

长上下文全塞看起来简单直接,把所有文档扔进提示词。但这面临三个硬伤:每次请求都按全部 Token 计费,成本极高;上下文越长,模型对中间内容的注意力越稀释,容易遗漏关键信息;输入越长,首字生成延迟越高。

RAG 则是目前性价比最高的选择。它先把文档切块存入向量库,提问时只检索最相关的几段内容塞给模型。这种方式实时生效,修改文档即可同步更新;每次只支付少量 Token 费用;且每一段回答都能追溯到原文出处。在企业场景里,“能告诉用户答案在第几页”的价值,往往比单纯提高准确率更重要,因为这意味着业务人员可以自行核验。

RAG 的核心逻辑与常见误区

RAG 的原理听起来很简单:先检索,再生成。但在工程落地中,它包含四个独立环节,每个环节都有各自的失败模式。

首先是查询处理。如果用户用口语问“怎么报销”,而文档里写的是“费用核销流程”,语义匹配可能失效。

其次是检索阶段。这是决定 RAG 效果上限的关键。如果检索没召回正确文档,或者召回了用户无权查看的文档,后续步骤再完美也无济于事。

接着是重排与筛选。原始检索结果可能包含噪声,需要通过重排序模型(Rerank)或阈值截断来提纯。

最后是生成阶段。模型需要严格依据提供的片段回答,避免幻觉。

很多开发者调试 RAG 时喜欢直接改提示词,比如加强语气要求“请严格根据资料回答”。但如果检索回来的片段本身就是错的,改提示词毫无意义。正确的排查思路是“分段验证”:先把检索出来的片段打印出来看。如果片段里没有答案,问题出在检索侧(解析、分块、嵌入模型);如果片段里有答案但模型没用好,问题才出在生成侧。

一个重要的判断准则是:RAG 的效果八成由检索决定,模型只负责表达。不要试图通过更换更强的生成模型来解决“检索不到”的问题。

核心组件选型与策略

Embedding:语义的坐标化

Embedding 将文本映射为多维空间中的向量坐标。语义相近的文本,其坐标距离更近。选型时需注意三点:

第一,不同模型的向量空间不兼容。更换嵌入模型意味着全量重新嵌入,这是一次性的高成本操作,选型需谨慎。

第二,相似度阈值比 Top-K 更重要。如果用户的问题在知识库中无答案,检索仍会返回“最接近”的几条无关内容。设置合理的相似度阈值,让系统在无法确认时坦诚回答“不知道”,比强行编造答案更安全。

第三,中文语料需实测。不同模型对中文专业术语、缩写的理解差异较大。建议用 30-50 条真实问句构建小测试集,对比候选模型的召回表现。

分块(Chunking):被低估的关键环节

文档太长无法整体向量化,必须切分。分块策略直接影响检索精度。

推荐从“按结构切分 + 定长兜底”开始。利用文档原有的标题层级进行切分,能保证块内语义完整。对于过长的章节,再进行固定长度切分并设置重叠(Overlap),防止答案被切断。

表格处理是难点。按行切分会导致表头与数据分离,模型无法理解数值含义。正确做法是将整张表作为一个块,并在块首附加一句由模型生成的“表意摘要”,说明这张表的内容。

每块数据必须携带元数据:来源文件、页码、章节路径、文档版本以及权限标签。这些元数据是后续过滤和引用回传的基础。

向量库选型:复用现有设施

选型原则是“先看已有的是什么”。

如果团队已使用 PostgreSQL,首选 pgvector 扩展。它能在一个 SQL 中同时完成元数据过滤和向量检索,事务与备份体系成熟,适合百万级以下数据量。

若已使用 Redis 且对延迟敏感,可选用 Redis 向量搜索功能,但需评估内存成本。

如果已在用 Elasticsearch 且需要强大的混合检索能力,ES 是自然选择,尽管其向量性能略逊于专用库。

只有当数据量达到千万级或对向量性能有极致要求时,才考虑引入 Milvus 等专用向量数据库。选型时需重点验证其对“带过滤条件的向量检索”的支持效率,这直接关系到权限控制的实现难度。

混合检索:中文场景的必需品

纯向量检索对精确字符串不敏感。例如用户搜索文档编号“XX-2026-003”或具体产品型号,向量检索往往失效,因为这些字符串缺乏语义特征。

混合检索结合向量检索(管语义)和关键词检索(如 BM25,管精确匹配)。两路召回后,由于分数体系不同(余弦相似度 vs BM25 分数),不能直接相加。最佳实践是将两路候选合并去重后,交给重排序模型(Rerank)统一打分排序。

Rerank 虽然增加了一次模型调用延迟,但能显著提升“召回多但排序差”场景下的准确率。它只在少量候选集中精挑细选,性价比高。

工程落地:从入库到问答

文档解析与入库

“垃圾进,垃圾出”在 RAG 中体现得淋漓尽致。PDF 双栏排版错乱、扫描件无文本层、Excel 表格拍平,都会导致后续环节失效。

解析环节需针对不同类型处理:文字版 PDF 需纠正行序并剔除页眉页脚;扫描件必须经过 OCR 并抽查错误率;Word 文档需保留标题层级以辅助分块。

入库代码示例如下:

@Service
public class KbIngestService {
    private final VectorStore vectorStore;
    private final TokenTextSplitter splitter;

    public void ingest(KbDoc doc, List<KbSection> sections) {
        List<Document> docs = new ArrayList<>();
        for (KbSection s : sections) {
            // 结构切分后,过长部分再定长切分
            List<String> pieces = s.text().length() > 800
                    ? splitter.split(s.text())
                    : List.of(s.text());
            
            for (int i = 0; i < pieces.size(); i++) {
                docs.add(new Document(pieces.get(i), Map.of(
                        "doc_id", doc.id(),
                        "chunk_no", i,
                        "source_name", doc.fileName(),
                        "page_from", s.pageFrom(),
                        "section_path", s.path(),
                        "doc_version", doc.version(),
                        "acl_tags", String.join(",", doc.aclTags()) // 权限标签
                )));
            }
        }
        vectorStore.add(docs); // 批量写入
    }
}

注意元数据在入库时即打全,尤其是 acl_tags 权限标签,这是后续安全过滤的物理基础。

检索与权限控制

检索代码需显式包含权限过滤表达式:

public List<Document> search(String question, UserCtx user) {
    SearchRequest req = SearchRequest.builder()
            .query(question)
            .topK(20) // 多取候选,供后续重排
            .similarityThreshold(0.45) // 阈值标定
            .filterExpression("acl_tags in [" + user.aclTagExpr() + "]") // 检索层过滤
            .build();
    return vectorStore.similaritySearch(req);
}

权限过滤必须在检索层完成,绝不能依赖提示词。 如果在提示词中要求模型“不要回答无权查看的内容”,敏感数据已经进入了提示词上下文,可能出现在日志、调试信息或链路追踪中,造成数据泄露。这就好比先把机密文件发给外人,再嘱咐他保密,逻辑上是荒谬的。

此外,过滤表达式应同时应用于向量检索和关键词检索两路,避免通过关键词搜索绕过权限控制。

提示词约束与引用回传

系统提示词需包含三条硬约束:

  1. 只依据提供的资料片段回答,无信息则拒答。
  2. 每条结论标注来源编号,如 [1]。
  3. 若片段冲突,明确指出而非自行取舍。

引用回传时,需确保前端展示的引用编号与注入模型的片段顺序严格一致。代码如下:

private List<Citation> toCitations(List<Document> docs) {
    List<Citation> list = new ArrayList<>();
    for (int i = 0; i < docs.size(); i++) {
        Document d = docs.get(i);
        list.add(new Citation(
                "[" + (i + 1) + "]",
                (String) d.getMetadata().get("source_name"),
                (Integer) d.getMetadata().get("page_from"),
                (String) d.getMetadata().get("section_path"),
                abbreviate(d.getText(), 120)
        ));
    }
    return list;
}

评估与运维

分离式评估指标

RAG 测试需将“检索命中率”与“答案正确率”分开评估。

  • 检索命中率:正确文档是否出现在 Top-K 中。反映分块、嵌入模型和检索策略的效果。
  • 答案正确率:生成结果是否准确且引用正确。反映提示词和模型能力。
  • 越权率:必须为 0。通过构造低权限用户查询高权限文档进行测试。
  • 拒答正确率:对无答案问题是否正确拒答。

若检索命中率高但答案正确率低,优化提示词;若检索命中率低,优化分块和检索策略。

文档更新与版本管理

文档更新时,应按 doc_id 删除旧版本所有块,再入库新版本,避免新旧版本共存导致冲突。定期比对文档系统清单与向量库数据,清理“孤儿块”,防止已删除文档仍可被检索。

成本控制

RAG 的主要成本在于生成阶段的输入 Token。优化方向包括:控制注入片段数量(如 Top-3 而非 Top-10),限制每段长度,以及对简单问题分流至低价模型。检索和嵌入成本相对固定,非主要优化点。

总结

构建企业级 RAG 系统,核心不在于堆砌最先进的模型,而在于扎实的工程细节。文档解析的质量、分块的策略、混合检索的实现、以及严格的检索层权限控制,共同决定了系统的可用性与安全性。

记住五个核心判断:

  1. 检索决定上限,生成只决定表达。
  2. 相似度阈值比 Top-K 更重要,敢于拒答。
  3. 中文场景必需混合检索,解决专有名词匹配问题。
  4. 权限过滤必须在检索层实现,提示词不可靠。
  5. 文档解析是隐性天花板,上线前务必抽样验证。

通过分离式评估和线上反馈闭环,持续迭代检索策略,才能让 RAG 系统真正服务于业务,成为可靠的知识助手。