别再调 prompt 了!RAG 的天花板,在文档进向量库那刻就焊死了

别再调 prompt 了!RAG 的天花板,在文档进向量库那刻就焊死了

离线阶段(知识入库)从使用到工业级全解:解析、切分、Embedding、存储,一个坑都不放过
本文是 RAG 系列第二篇。第一篇《RAG 技术全景》帮你建了全局认知地图;这一篇,我们钻进**离线阶段(知识入库)**这条最容易被忽视、却最决定生死的链路,从"怎么跑通"打到"工业级怎么扛住"。


写在前面:一个让我赚了口碑、也踩透坑的故事

先讲一个 RAG 圈最典型的翻车剧本(说明:以下为脱敏后的复合案例,数字为典型区间,用于讲清原理):

某团队 RAG 召回率长期卡在 30% 出头 。他们花三周换了三个模型、调了无数 prompt、甚至还上了 rerank------没用

最后根因?入库时把 PDF 按 500 字符硬切,表格被切成碎片,标题层级全丢,扫描合同还没做 OCR。 改完解析和切分策略,召回直接到 80%+,模型一行没动。

一句话真相:RAG 是"垃圾进、垃圾出"的典型。检索的天花板,在文档进向量库那一刻就焊死了。线上怎么调,都救不回一个烂掉的入库。

这就是为什么今天这篇文章值得你收藏------它覆盖了离线阶段从入门到工业级的全部知识点、注意事项和可落地的工程做法。(文中工业案例的具体数字均为脱敏后的典型行业范式,技术选型与做法真实可靠。)


目录

  • 〇、开篇:离线阶段的三大红线
  • 一、离线阶段整体架构与数据流
  • [二、环节一 · 数据接入与解析](#二、环节一 · 数据接入与解析 "#%E4%BA%8C%E7%8E%AF%E8%8A%82%E4%B8%80--%E6%95%B0%E6%8D%AE%E6%8E%A5%E5%85%A5%E4%B8%8E%E8%A7%A3%E6%9E%90loading--parsing")
  • [三、环节二 · 清洗与结构化](#三、环节二 · 清洗与结构化 "#%E4%B8%89%E7%8E%AF%E8%8A%82%E4%BA%8C--%E6%B8%85%E6%B4%97%E4%B8%8E%E7%BB%93%E6%9E%84%E5%8C%96cleaning--structuring")
  • [四、环节三 · 切分策略(检索效果的命门)](#四、环节三 · 切分策略(检索效果的命门) "#%E5%9B%9B%E7%8E%AF%E8%8A%82%E4%B8%89--%E5%88%87%E5%88%86%E7%AD%96%E7%95%A5%E6%A3%80%E7%B4%A2%E6%95%88%E6%9E%9C%E7%9A%84%E5%91%BD%E9%97%A8")
  • [五、环节四 · 向量化(Embedding)](#五、环节四 · 向量化(Embedding) "#%E4%BA%94%E7%8E%AF%E8%8A%82%E5%9B%9B--%E5%90%91%E9%87%8F%E5%8C%96embedding")
  • [六、环节五 · 向量存储与索引(含完整入库 & 在线检索服务)](#六、环节五 · 向量存储与索引(含完整入库 & 在线检索服务) "#%E5%85%AD%E7%8E%AF%E8%8A%82%E4%BA%94--%E5%90%91%E9%87%8F%E5%AD%98%E5%82%A8%E4%B8%8E%E7%B4%A2%E5%BC%95vector-store--index")
  • [七、进阶专题 · 离线阶段高阶工程](#七、进阶专题 · 离线阶段高阶工程 "#%E4%B8%83%E8%BF%9B%E9%98%B6%E4%B8%93%E9%A2%98--%E7%A6%BB%E7%BA%BF%E9%98%B6%E6%AE%B5%E9%AB%98%E9%98%B6%E5%B7%A5%E7%A8%8B")
  • 八、工业级端到端串讲
  • [九、离线阶段验收 Checklist(可照抄)](#九、离线阶段验收 Checklist(可照抄) "#%E4%B9%9D%E7%A6%BB%E7%BA%BF%E9%98%B6%E6%AE%B5%E9%AA%8C%E6%94%B6-checklist%E5%8F%AF%E7%85%A7%E6%8A%84")
  • 十、总结与下篇预告
  • [十一、工程骨架与运行(pom / 配置 / 启动类)](#十一、工程骨架与运行(pom / 配置 / 启动类) "#%E5%8D%81%E4%B8%80%E5%B7%A5%E7%A8%8B%E9%AA%A8%E6%9E%B6%E4%B8%8E%E8%BF%90%E8%A1%8Cpom--%E9%85%8D%E7%BD%AE--%E5%90%AF%E5%8A%A8%E7%B1%BB")

〇、开篇:离线阶段的三大红线

在动手前,先立三条贯穿全文的红线------质量、安全、合规。很多项目上线即翻车,不是技术不行,是这三条没在入库阶段就卡死。

红线 含义 翻车后果
质量 解析完整、切分合理、向量可信 召回低、答非所问
安全 防提示注入、防恶意文档 模型被"带歪"、数据泄露
合规 脱敏、数据驻留、审计可追溯 违规、吃罚单、下架

记住:离线阶段是"一次性把地基打牢"的环节。地基歪了,上面的楼怎么装修都白搭。
🧰 本文代码示例技术栈 :Java 17 + Spring AI 1.0.3 + Spring Cloud Alibaba AI(Spring AI Alibaba)1.0.0.3 ,向量用 PostgreSQL + pgvector,模型统一走阿里云百炼 / DashScope(Model RAG)。代码按章节内联:2.9 节为完整脱敏工具、第六章末尾(6.13 / 6.14)为完整离线入库服务与在线检索生成服务,文末第十一节仅保留工程骨架(pom / 配置 / 启动类)。


一、离线阶段整体架构与数据流

1.1 五大环节全景

markdown 复制代码
源数据 ──► ①解析 ──► ②清洗结构化 ──► ③切分 ──► ④向量化 ──► ⑤存储索引
            Loader     Cleaner        Splitter   Embedder    VectorStore

五个角色各司其职,后面逐章拆解。

1.2 离线 vs 在线:职责边界

放离线(一次性/周期) 放在线(每次请求)
解析、清洗、切分、Embedding、建索引 查询重写、检索、重排、生成
重、慢、可批量 轻、快、低延迟

铁律:能离线算的,绝不放线上。 否则每次问答都重算 Embedding,成本爆炸。

1.3 核心质量指标预览

  • 召回基线(Recall@K):相关内容有没有被检索出来------这是入库质量的"总开关"。
  • Chunk 覆盖率:文档关键信息有没有被切成可检索的块。
  • 入库时延:全量/增量更新的耗时,决定知识新鲜度。

1.4 数据血缘与可观测性

从"源文档 → chunk → 向量"要能反向追溯:用户拿到一个答案,能一路追到它出自哪份文档的第几段。这是排障和审计的基础。

1.5 Pipeline 编排:调度、幂等、重试

工业级入库不是跑个脚本,而是一条可调度、幂等、可重试的管道(Airflow / Dagster / 自研)。

java 复制代码
// 幂等入库关键:以 chunk 内容 SHA-256 作为 Document id,先删后插,重跑不产生冗余
public void ingest(String pdfPath, String department) throws Exception {
    List<Document> pages = new PagePdfDocumentReader(new FileSystemResource(pdfPath)).get();
    List<Document> chunks = splitter.split(pages);          // 解析 + 切分

    MessageDigest md = MessageDigest.getInstance("SHA-256");
    List<Document> toAdd = new ArrayList<>(chunks.size());
    for (Document chunk : chunks) {
        String clean = PiiMasker.mask(chunk.getText());      // 脱敏
        Map<String, Object> meta = new HashMap<>(chunk.getMetadata());
        meta.put("department", department);
        meta.put("source", pdfPath);
        String id = bytesToHex(md.digest(clean.getBytes(UTF_8)));  // 内容指纹 = 幂等键
        toAdd.add(new Document(id, clean, meta));
        vectorStore.delete(List.of(id));                     // 先删旧版(不存在则 no-op)
    }
    vectorStore.add(toAdd);                                  // 原子写入(Embedding 由 PgVectorStore 内部完成)
}

⚠️ 注意事项:管道必须幂等。否则一次失败重跑,可能把同一份文档写两遍,向量库炸出重复噪声。


二、环节一 · 数据接入与解析(Loading & Parsing)

2.1 数据源全景

PDF、Word、网页、Markdown、数据库、飞书/Confluence/Notion、对象存储(S3/OSS)、图片、邮件......数据源越多,解析越不统一,这是工业级第一道坎。

关键在于:没有"一个解析器通吃所有格式"这回事 。每种格式背后是不同的技术栈------PDF 要拆版面、Word 要解 OOXML、网页要洗 boilerplate、数据库要跑 ETL。下面 2.3.1 会按格式把"可选解析方式"铺开,你会发现每个格式都至少有 2~3 种选法,选错直接焊死入库质量的下限。

2.2 PDF 类型细分(新手最容易踩的坑)

PDF 不是一种"东西",它是个容器。按"有没有文本层、版面多复杂"至少分四类,每类解法完全不同:

类型 特征 处理方式 代表工具
原生文本型(软件生成) 文字可选中、含字体/结构信息 直接文本提取,保结构 pdfminer.six / PyMuPDF / Spring AI PagePdfDocumentReader(PDFBox+Tika)
Word/PPT 导出的文本型 文字可选中,但常带冗余版式(页眉页脚、分栏) 提取后再做版面清洗(去页眉页脚) 同上 + 版面规则清洗
含表单/填表的文本型 AcroForm/XFA 字段 用表单 API 抽字段,而非当正文读 pdfminer fields / PDFBox PDField
扫描/图像型(无文本层) 本质是图片,无可选文字 必须 OCR PaddleOCR / Tesseract / 阿里云·百度·腾讯云 OCR

⚠️ 注意事项 :拿处理"文本型"的思路去打"扫描合同",提取出来的全是空------这是 80% 新手项目的第一个坑。更隐蔽的是"混合型 ":文档前半本有文本层、后半本是扫描件,必须两种解析器级联才不漏。

2.3 解析难点与知识点

解析的难点不是"读字",而是"还原结构"。每一项难点都对应一类技术,选错技术链路质量直接塌:

难点 说明 对应技术手段
版式还原 多栏、图文混排、浮动元素,坐标 ≠ 阅读序 版面分析模型(LayoutLMv3 / PP-Structure / Unstructured hi_res)
表格 合并单元格、跨页表、无边框表 表格抽取(camelot / tabula / pp-structure)
公式 LaTeX/数学符号,纯文本会失真 Mathpix / Nougat / 直接保留 LaTeX 源码
多栏排版 左栏接右栏,按坐标读会乱序 阅读顺序模型 + 版面分析
扫描 OCR 无文本层,必须识别 PaddleOCR / Tesseract / 云 OCR
编码/字体 GBK/UTF-8 混用、字体缺失出豆腐块 统一转 UTF-8、OCR 前补字体

核心认知:解析的目标不是"拿到文字",而是"拿到带结构的文字"。 一份被压成纯文本流的 PDF,标题层级、表格关系、代码块全都丢,后面切分再聪明也救不回。

下面给出已验证的 Spring AI PDF 文本型解析 写法(本文工程 mvn compile 通过);其它格式与更复杂的版面,见 2.3.1 的解析方式全景。

java 复制代码
// Spring AI 自带 PDF 解析器:PagePdfDocumentReader 按页读取,保留版面与分页结构
// 依赖:spring-ai-pdf-document-reader(底层基于 Apache PDFBox / Apache Tika)
@Autowired
private PagePdfDocumentReader reader;   // 或由 Spring 自动装配

public List<Document> parsePdf(String pdfPath) {
    List<Document> pages = new PagePdfDocumentReader(
            new FileSystemResource(pdfPath)).get();   // 返回每页一个 Document
    for (Document page : pages) {
        String text = page.getText();
        Map<String, Object> meta = page.getMetadata();  // 含页码、来源等元数据
        // 复杂版面(表格/公式/多栏)建议叠加 Unstructured 的 hi_res 管线或商业 OCR
    }
    return pages;
}

💡 想保留表格结构:PagePdfDocumentReader 默认按页;需要"表格级"结构化时,工业级通常再接 Unstructured(hi_res + infer_table_structure=True)或商业 OCR 做二次解析。Spring AI 负责接入与编排,解析内核可按版面复杂度替换。

2.3.1 解析方式全景:按格式,每种都至少 2~3 种选法

下面这张表是本章的"选解析器地图"。重点不是背工具名,而是建立"格式 → 技术族 → 取舍"的直觉:简单格式用通用 Reader 即可;复杂版面要上版面模型或商业 OCR;同一格式在不同复杂度下,选法完全不同。所有列出的工具均为业界真实存在的方案。

文档格式 可选解析方式(按复杂度递增) 代表工具(Python / Java) 何时选哪种
PDF(文本型) ① 通用文本提取 ② 带结构提取(标题/段落) ③ 版面感知提取 ① pdfminer.six / PyMuPDF ② Spring AI PagePdfDocumentReader(PDFBox+Tika) ③ Unstructured hi_res 纯正文用①;要保标题层级用②;多栏/图表混排用③
PDF(扫描型) ① 开源 OCR ② 商业 OCR(高精度/含版面) ① PaddleOCR / Tesseract ② 阿里云·百度·腾讯云 OCR 预算紧/数据可出域用①;金融票据/合同要高精度用②
版面 + 表格(PDF) ① 表格专用抽取 ② 版面模型一体抽取 ① camelot / tabula-py ② PP-Structure / Unstructured(infer_table) 规则表格用①;复杂合并单元格用②
Word(.docx) ① OOXML 解析 ② 通用文档解析 ① python-docx / Apache POI(Java) ② Apache Tika 要抽段落/样式用①;要统一多格式入口用②
网页 / HTML ① DOM 解析 ② 正文提取(去 boilerplate) ① BeautifulSoup / Jsoup(Java) ② trafilatura / newspaper3k / readabilipy 抓特定节点用①;只要干净正文用②
Markdown ① 直接读取 ② AST 解析(保标题树) ① 文件直读 ② markdown-it / commonmark 要保标题层级做切分用②
数据库 ① JDBC/SQL 抽取 ② ETL 管道 ① JDBC(Java)/ SQLAlchemy ② Airbyte / Fivetran 一次性用①;持续同步用②
对象存储 ① SDK 拉取 ② 事件触发 ① boto3(S3) / OSS SDK(Java) ② S3 Event / OSS 触发器 批量用①;近实时用②
协作平台 ① 官方 API 拉取 ② Webhook ① 飞书/Lark API、Confluence API、Notion API ② 平台 Webhook 周期同步用①;变更即更新用②
图片 / 多模态 ① OCR 取文字 ② 多模态 caption(图→文) ① PaddleOCR ② BLIP / LLaVA / 多模态大模型 图表用①;示意图/照片用②
邮件 ① RFC822 解析 ② 附件递归解析 ① Python email / JavaMail ② 递归解析附件 PDF/Word 客服工单/邮件知识库用①

💡 Spring AI 侧怎么落地 :Spring AI 把"解析"抽象成 DocumentReader 家族------PDF 用 PagePdfDocumentReader,其它格式有对应的 Reader(Tika / Markdown / HTML 等)。复杂版面(表格/公式/多栏)Spring AI 负责"接入与编排",解析内核可按上表替换 (封装成自定义 DocumentReader),不绑死某一种。文中示例只用已验证的 PagePdfDocumentReader,其它工具按需接入即可。

2.4 阅读顺序(Reading Order)

多栏论文、图文混排的文档,按坐标"从左到右"读会变成乱序。阅读顺序还原是解析质量的分水岭------错序的内容喂给切分,语义直接断裂。

  • 工具:版面分析模型(PP-Structure、Unstructured hi_res、LayoutLMv3)可直接输出带阅读顺序的区块;
  • 兜底:纯文本场景可用"按版面坐标重排 + 行列归并"规则,但复杂图文混排仍建议上版面模型。

2.5 表格抽取专题

表格是 RAG 里最难的载体:单元格跨页、合并单元格、无边框表......建议:

  • 能保留为 HTML/CSV 就别转纯文本(转了就丢了行列关系);
  • 大宽表考虑整表作为一个 chunk,避免被切碎后检索失效;
  • 带公式的表(如财报)保留公式源码,别只留计算结果。

按表格类型选抽取方式

表格类型 推荐方式 代表工具
规则有线表格 PDF 内表格提取 camelot / tabula-py / pdfplumber
复杂合并单元格 / 无边框 版面模型一体抽 PP-Structure / Unstructured(infer_table)
网页表格 DOM 定位 <table> BeautifulSoup / Jsoup
公式/科研表 保留 LaTeX Mathpix / Nougat

2.6 选型决策指南(按版面复杂度)

解析器不是"挑最好的",是"按复杂度匹配":

  • 简单格式(纯文本 / 规整 Markdown / 单栏 PDF) :直接用通用 Reader 即可------Spring AI PagePdfDocumentReader / Tika / 文件直读,开箱即用、零额外依赖。
  • 中等复杂度(多栏 / 含表格 / 网页 boilerplate) :上版面/正文抽取层------Unstructured(hi_res)trafilatura (网页)、camelot/tabula (表格),Java 侧封装成自定义 DocumentReader
  • 高复杂度(扫描件 / 印章 / 骑缝章 / 手写批注 / 强版面) :OCR + 版面模型 + 自研 Pipeline ------银行业的印章、骑缝章识别只能自研;高精度合同用商业 OCR(阿里云/百度/腾讯云)。

经验法则:先用通用 Reader 跑通,再用质量门禁(见 3.8)量化缺口,按需升级到版面模型或商业 OCR------别一上来就上最重的方案,也别卡在轻方案硬扛重版面。

2.7 连接器与调度:定时 / Webhook / CDC

知识库不是一成不变的。工业级要有增量接入

  • 定时拉取(cron,适合静态文档);
  • Webhook(源系统变更即触发);
  • CDC(数据库 binlog,近实时)。

2.8 大文件与流式解析

GB 级 PDF 一次性读进内存直接 OOM。流式解析分块读取,是扛大文件的标配。

⚠️ 注意事项 :永远别 read() 一个未知大小的文档进内存。先查页数/大小,超阈值走流式。

2.9 PII 识别与脱敏(合规前置)

身份证、手机号、合同金额------在入库前就脱敏,比上线后补救成本低 100 倍。

下面是完整的 PiiMasker 工具类 (已随文末工程通过 mvn compile 验证),入库前的切分循环里直接调用 PiiMasker.mask(...) 即可:

java 复制代码
package com.example.rag.util;

import java.util.regex.Pattern;

/** 入库前 PII 脱敏:手机号、身份证号打码。生产环境建议叠加 NER 实体识别。 */
public final class PiiMasker {
    private static final Pattern PHONE   = Pattern.compile("(\\d{3})\\d{4}(\\d{4})"); // 手机号:留前3后4
    private static final Pattern ID_CARD = Pattern.compile("(\\d{14})(\\d{4})");     // 身份证:留后4

    private PiiMasker() {}

    public static String mask(String text) {
        if (text == null) return null;
        String s = PHONE.matcher(text).replaceAll("$1****$2");
        return ID_CARD.matcher(s).replaceAll("**************$2");
    }
}

💡 进阶:正则打码只是第一道闸。金融/医疗等强合规场景,建议在入库侧再叠一层 NER 实体识别(如识别姓名、银行卡、诊断),脱敏更全;同时把"已脱敏"标记写进元数据,便于审计与回溯(见 7.9 合规与审计)。

2.10 ⚠️ 注意事项清单

  • 编码乱码(GBK/UTF-8 混用)→ 统一转 UTF-8。
  • 字体缺失 → 解析出现豆腐块,OCR 前先补字体。
  • 加密 PDF → 先解密再解析,别硬刚。
  • 超大文件 → 流式,别贪心一次读。

2.11 🏭 工业级案例①:银行合同库

背景 :数万份历史合同,90% 是扫描件,含印章、骑缝章、手写批注。

做法 :扫描件 → 商业 OCR(高精度)→ 版面还原(定位条款区块)→ 印章/批注单独标注 → PII 脱敏(金额、账号打码)→ 条款级元数据抽取。

收益:条款级检索准确率从 40% 提到 92%,合规审计可一键追溯出处。


三、环节二 · 清洗与结构化(Cleaning & Structuring)

3.1 去噪

页眉页脚、页码、水印、目录、广告块------这些是检索噪声,必须剔。去噪有三类方法,复杂度递增:

  • 规则法:正则 + 版面坐标(如"第 N 页/M"、固定水印区)。最快、最可控,适合版式固定的文档(见下方案例代码)。
  • ML 法 :boilerplate 移除模型,如网页场景的 jusText / trafilatura 的正文清洗、基于 DOM 的噪声块分类。适合来源杂、版式不固定的网页/HTML。
  • LLM 法:复杂版面用大模型做"语义去噪"(保留免责声明、删导航块)。成本最高,仅对高价值文档用。

取舍:规则法能解决 80% 噪声就别上 ML/LLM;去噪是性价比杠杆,不是越重越好

java 复制代码
// 清洗示意(纯 Java,按实际版面规则定制;真实项目多为正则 + 版面规则组合)
// 这一层发生在"切分之前",属于离线入库管道的第一道闸
String clean(String raw) {
    return raw
        .replaceAll("(?m)^\\s*第\\s*\\d+\\s*页\\s*/\\s*\\d+\\s*$", "") // 去"第 N 页 / M"
        .replaceAll("(?i)confidential", " ")                          // 去水印词
        .replaceAll("[\\u0000-\\u001F\\uFFFD]", "")                    // 去控制字符与替换符
        .strip();
}

注意:清洗过度会丢上下文(比如把"注:"开头的免责声明整段删了),把握"去噪声、留语义"的度。

3.2 去重:精确 / 语义

  • 精确去重 :同文档重复上传,(doc_id, hash) 拦截。
  • 语义去重:不同表述讲同一件事(如两版 FAQ),用 Embedding 相似度聚类去冗余,避免向量库被"近重复"污染。

怎么落地语义去重 :近重复检测常用 MinHash + LSH (如 datasketch),把文档投影成签名集、相似则分桶;或直接计算 chunk 间余弦相似度,高于阈值(如 0.95)合并。注意:相似度阈值要按业务调,太高漏重、太低误删。

3.3 元数据抽取

来源、时间、作者、权限、章节层级------元数据是后续过滤和权限控制的命脉。按"抽取来源"分四种做法:

  • 解析时提取:PDF/Word 自带属性(作者、创建时间)、版面位置(标题层级来自目录树)。
  • 正则/模板:从正文抽固定模式(如"发布日期:2023-05-01"、合同编号)。
  • 协作平台 API:飞书/Confluence/Notion 直接返回空间、作者、更新时间等结构化元数据。
  • LLM 抽取:非结构化文档用大模型做结构化抽取(输出 JSON:部门/标签/生效日期),适合高价值文档。

关键:哪些字段要用来过滤(如 departmentdatepermission),就必须建索引(见 6.8),否则抽了也用不上。

3.4 Metadata Schema 设计

这是工程重点,却最常被跳过。 先想清楚:哪些字段要用来过滤(如 departmentdatepermission)?它们必须建索引。

json 复制代码
{
  "doc_id": "contract-2023-001",
  "source": "s3://contracts/",
  "department": "legal",
  "date": "2023-05-01",
  "permission": "internal",
  "chunk_index": 3,
  "title_path": "第一章/第二节"
}

3.5 语言检测与中文分词

中文按"字"还是"词"切,直接影响 chunk 边界和 Embedding 效果。中文场景优先用语义/分词感知的切分,别拿英文的空格逻辑硬套。

  • 语言检测 :跨语种库先用 langdetect / fastText 判语种,再选对应分词器与 Embedding 模型( multilingual 模型可跳过这步)。
  • 中文分词工具jieba (轻量通用)、HanLP (多粒度+实体)、LAC(百度,分词+词性+实体一体)。分词结果可用于"按词边界切分",减少跨词截断。
  • 注意 :若用 TokenTextSplitter(按 tokenizer 切,见 4.1),分词器差异已被模型 tokenizer 吸收,无需额外接中文分词器;分词主要用于"结构感知切分"和"关键词检索"场景。

3.6 结构保留

标题层级、表格、代码块、图片占位------保住结构,检索召回和答案可读性都会上一个台阶。按载体分做法

  • 标题层级 :保留 Markdown AST / HTML 标题树(<h1>/<h2>),切分时把"父标题"作为上下文传播(见 4.11 small-to-big 变体)。
  • 表格:保留为 HTML/CSV 片段,别压成纯文本(见 2.5)。
  • 代码块:用围栏标记(```)原样保留,不切散;切分时按函数/类切(见 4.12)。
  • 图片:用 caption / 多模态描述占位(见 7.2 多模态入库),检索时可作为独立 chunk 或挂到相邻文本。

3.7 文档版本管理

同一文档 v1/v2/v3,更新时要定位并淘汰旧版本 chunk,否则新旧混库,答出过期内容。

落地做法 :给每个 chunk 打 doc_id + version 元数据;更新时先按 doc_id 删旧 version 的全部 chunk(向量库按元数据批量删),再写入新版本。配合内容哈希 (见 1.5 幂等)可只更新"真变了"的 chunk,省 Embedding 成本。检索时强制带 version 过滤(见 6.3),保证答当前版。

3.8 入库质量门禁(Quality Gate)

scss 复制代码
解析为空? ──► 拦截
chunk 过短(<20字)? ──► 合并或丢弃
敏感信息未脱敏? ──► 拦截

自动质检,bad doc 不进库,比事后救火强。

3.9 ⚠️ 注意事项

  • 清洗过度:把关键上下文(如"详见附录"的附录)一并删了,答不出。
  • 元数据缺失:上线才发现没法按部门过滤,回炉重洗全库。

3.10 🏭 工业级案例②:技术文档站点

背景 :内部技术文档站,Markdown + 代码块 + 多版本(v2/v3 并存)。 做法 :保留 Markdown 标题树 → 代码块原样保留(不切散)→ 注入 version 元数据 → 版本切换时按 version 过滤。 收益:开发者问"v3 的 API 怎么用",绝不会答出 v2 的过期签名。

3.11 💡 清洗 / 结构化策略场景对照

场景 推荐做法 成本 注意
版式固定噪声(页眉页脚 / 水印) 规则法:正则 + 版面坐标 极低 能解决 80% 就别上 ML / LLM------去噪是性价比杠杆
来源杂 / 网页 boilerplate ML 法:trafilatura / jusText / boilerplate 移除模型 中等 比 LLM 快 10~100 倍,适合批量网页清洗
高价值复杂版面 LLM 语义去噪:大模型判断保留 / 删除 最高 仅对高价值文档使用(如核心法律文件),成本最高
重复上传(同一份文档) 精确去重(doc_id, hash) 拦截 幂等入库基础(见 1.5 Pipeline 编排)
近重复 / 多版 FAQ 语义去重:MinHash + LSH 或余弦聚类 阈值要按业务调:太高漏重、太低误删
元数据字段需过滤 建索引:department / date / permission 必须建索引 无额外成本 抽了字段却没建索引 = 白抽

⭐ 推荐顺序 :规则法(80%)→ ML 法(复杂来源)→ LLM 法(高价值)。别一上来就上 LLM 去噪------成本高且可能误删免责声明等关键内容。去噪是"去噪声、留语义",过度清洗会丢上下文。


四、环节三 · 切分策略(检索效果的命门)

这是全文最关键的章节。 切分决定了"检索时能命中什么"。切错了,后面全白干。

4.1 固定长度切分 & 递归字符切分

入门首选按 token 切分 (Java 侧 Spring AI 提供 TokenTextSplitter,底层按模型 tokenizer 划块,避免跨模型字符/token 差异)。递归字符切分(按 \n\n\n 递归切,尽量不打断语义)在 Python 侧常用 RecursiveCharacterTextSplitter;Spring AI 1.0 标准库以 TokenTextSplitter 为主,下面给出验证过的写法。

java 复制代码
// Spring AI TokenTextSplitter:按 token 切,避免跨模型 tokenizer 差异
// 注意:TokenTextSplitter 原生不带 overlap 参数;需要重叠块时,可在切分后自行拼接相邻块边界
@Autowired
private TokenTextSplitter splitter;   // 也可在字段上直接 builder 构造

// 字段构造示例(已在 Demo 中编译验证):
// private final TokenTextSplitter splitter = TokenTextSplitter.builder()
//         .withChunkSize(500).withMaxNumChunks(10000).withKeepSeparator(true).build();

public List<Document> split(List<Document> pages) {
    return splitter.split(pages);    // 输入 List<Document>,返回切好的子块 List<Document>
}

4.2 语义切分

按句子/段落边界切,比固定长度更"像人说话"。适合叙述类文档。两种落地方式:

  • 规则边界 :用句子分割器(NLTK sent_tokenize / spaCy)或标点(。!?)切,成本低;
  • 模型边界:用模型判断"相邻句是否同主题",主题切换处才切(更准,但需推理)。

注意:纯语义切分容易切出过碎的块,工业上常和"固定上限 + 语义边界"结合------超过 chunk_size 就在最近的句子边界断。

4.3 结构感知切分

Markdown 按标题、JSON 按字段、代码按函数------尊重原文结构,是质量跃迁的关键。按格式选切分器:

  • Markdown :按标题层级切(如 ## 为界),保留标题路径作为上下文------LangChain MarkdownTextSplitter,或自研按 # 层级递归;
  • JSON:按 key/对象切,避免把一个对象拆两半;
  • 代码:按语法树(AST)切,不切断函数/类------可用 tree-sitter 解析 AST,或按函数边界正则;
  • HTML :按标签块(<section>/<div>)切,保留标签语义。

Spring AI 标准库以 TokenTextSplitter 为主(见 4.1),结构感知切分需自行实现或用对应生态的 Splitter;核心是把"结构边界"作为切分依据,而非纯长度

4.4 父子文档(子块检索、父块返回)

小块(如 100 字)用于精准检索,命中后返回其父大块(如 1000 字)给模型------兼顾召回精度与上下文完整

落地做法(Spring AI 1.0.3 标准库无内置父子切分器,需自行实现):

  1. 入库时:父块原样存(可带 embedding,也可仅存文本),子块切出后带 parent_id 元数据 + 自身 embedding 入库;
  2. 检索时:用子块 embedding 做相似度检索,命中后按 parent_id 回查父块文本返回给模型;
  3. 元数据过滤(6.3)作用于子块,保证权限/租户不串。

4.5 重叠(overlap)策略

块间留 10%~20% 重叠,避免"答案被切在两块边界,谁都不完整"。

4.6 参数怎么定(经验值)

文档类型 chunk_size overlap
通用中文 300~500 字 10%~20%
代码 按函数/512 token
法条/合同 按条款 + 父子

经验值只是起点,必须靠评测调,没有银弹。

4.7 命题式切分(Proposition-based / Dense X Retrieval)

源自 Meta 的 Dense X Retrieval:把文档拆成自包含命题(每句都能独立回答一个问题),检索命中率显著高于整段切分。

4.8 Late Chunking 延迟分块(Jina)

先用长上下文模型对整文档 Embedding,再池化到各 chunk------chunk 带着全文语境,解决"局部切块丢失全局意思"的顽疾。新项目强烈建议试。

4.9 按 token 切分 vs 按字符切分

不同 tokenizer 下,"500 字符"可能是 200 也可能是 800 token。以 token 计更准,尤其跨模型时。

4.10 Embedding 上下文窗口限制

chunk 必须小于 Embedding 模型的最大输入(如 512/8192 token),超了会被截断,尾部信息直接丢失

4.11 标题/上下文传播(small-to-big 变体)

把父标题拼到每个子块前:"【第三章 退款政策】用户申请退款需......"------补回被切掉的上下文。

4.12 代码 AST 切分 & 表格切分

  • 代码按**语法树(AST)**切,不切断函数;
  • 表格整表或按行切,保留行列关系。

4.13 ⚠️ 常见误区

  • 切太碎:一个句子一块,语义破碎,检索像大海捞针。
  • 切太整:一整章一块,命中后喂给模型的噪声太多。
  • 跨句截断:在句子中间切,答案"半身不遂"。

4.14 🏭 工业级案例③:法务问答

背景 :上万条长法律条款,用户问"试用期离职要提前几天?" 做法 :按条款切分 → 父子文档(子块 150 字检索 / 父块 800 字返回)→ 命题切分提取关键句 → 条款级元数据(law_idarticle)。 收益:精准命中具体条款,答案可引用到"第 N 条",律师可直接核验。

4.15 💡 切分策略选型决策指南:什么场景用什么

这是本章最关键的"选型表"。没有银弹,只有按文档类型匹配。

文档场景 推荐切分策略 关键参数建议 为什么
通用叙述类(博客、新闻、报告) 递归字符 / 语义切分 chunk_size=300~500 字,overlap=10%~20%,按句边界断 叙述有自然段落,递归切保留语义完整性
代码 / 技术文档 结构感知切分(AST) 按函数/类/模块切,不切散代码块;Markdown 按 # 层级 函数/类是语义原子,跨函数截断导致上下文断裂
长条款 / 法律合同 父子文档 + 命题切分 子块 100~150 字检索,父块 800~1200 字返回;命题提取关键句 条款长且近似度高,小粒度检索准、父块返回保完整
跨句检索丢失严重(答案常被切成两半) Late Chunking(延迟分块) 先整文档 Embedding 再池化到 chunk chunk 带着全文语境,解决局部切块丢失全局意思
多格式混合(含表格+正文+代码) 组合策略 正文用递归切,表格整表或按行,代码 AST,图片 caption 占位 单一策略无法覆盖所有载体
超长单段落(无自然断点) 固定长度 + 大 overlap chunk_size=500~800,overlap=20%+,强制在标点处断 无自然边界时固定长度兜底,overlap 防止信息丢失

⭐ 默认推荐路线

scss 复制代码
先判断文档类型
  ├─ 有明确结构(标题/函数/条款)? → 结构感知切分(最优先)
  │    ├─ 法律/合同 → 叠加 父子文档 + 命题切分
  │    └─ 代码 → 按 AST 切
  ├─ 纯叙述文本? → 递归/语义切分(默认)
  ├─ 跨句丢失严重? → 试 Late Chunking
  └─ 超长无断点? → 固定长度 + 大 overlap 兜底

⚠️ Spring AI 1.0.3 注意 :标准库以 TokenTextSplitter(按 token 切)为主,不支持 withChunkOverlap 参数。需要 overlap 时自行拼接相邻块边界;结构感知 / 父子 / Late Chunking 需自行实现(Spring AI 不内置这些 Splitter)。详见 4.1~4.8 各节。


五、环节四 · 向量化(Embedding)

5.1 原理一句话

把文本映射成高维向量,语义相近的文本,向量在空间里也相近------检索就是算"距离最近"。

5.2 模型选型

Embedding 模型没有"唯一答案",按"开源/商业、语种、维度、是否含稀疏"来选:

模型 特点 适用
BGE-M3 稠密+稀疏+多语言一体,1024 维 中文为主、要混合检索、私有化
BGE-large-zh / bge-base-zh 中文强、纯稠密 纯中文、轻量私有化
m3e / bge-m3 早期版 中文通用基座 老项目兼容
gte-large / gte-qwen 阿里系,中英文均衡 阿里生态、中英文
multilingual-e5 跨语言统一空间,固定 768 维 跨境/多语言检索
OpenAI text-embedding-3 small/large 两档,支持 MRL 截断 省运维、英文为主
智谱 / 文心 / 阿里云百炼 国内商业 API(百炼即本文 DashScope) 国内合规、免运维
Cohere embed multilingual 强 出海业务

中文优先 BGE-M3(稠密+稀疏+多语言一体)或百炼 text-embedding 系列(本文 Model RAG 统一走 DashScope)。维度、是否含稀疏、是否支持 MRL 截断,直接决定后面存储与混合检索的写法。

5.3 中文场景与领域适配

通用模型在医疗/法律术语上容易"向量漂移"。领域适配有几种力度,从轻到重:

  • 检索式增强(不微调):用领域词典/同义词做查询扩展、或用领域语料建 BM25 兜底(见 5.7 混合检索)------成本最低,先试这个;
  • 继续预训练(CPT):在领域语料上继续 MLM,让模型吸收术语分布;
  • 对比微调(Contrastive) :用 (query, 相关文档, 不相关文档) 三元组做对比学习,直接优化"检索空间"------效果最对症;
  • LoRA 轻量微调:只训低秩适配,显存省、迭代快,适合中小团队。

建议顺序:先混合检索 + 调 chunk,再上对比微调;别一上来就全量微调,数据工程和评测成本都不小。

5.4 维度、归一化、量化

  • 维度越高表征越强,但存储/算力越贵;
  • 归一化后可用点积代替余弦,提速;
  • 向量量化压缩存储,按压缩比与精度损失分三档:
量化方式 做法 压缩比 精度损失 适用
SQ(Scalar Quantization) float32 → int8 ~4× 几乎无损 大多数场景首选
PQ(Product Quantization) 向量分块 + 码本 8~32× 略有损 超大规模、内存紧
Binary 二值化(±1) ~32× 较明显 大库粗排/召回兜底

取舍:SQ 是性价比之王,先上;PQ/Binary 留给"亿级向量还要省钱"的场景,但要接受召回微降。量化与索引算法(6.2)常组合使用(如 HNSW + SQ)。

5.5 余弦 vs 点积

向量已归一化 → 用点积 (快);未归一化 → 用余弦 (准)。统一标准,别混用,否则召回排序错乱。

5.6 Matryoshka 表征学习(MRL)

OpenAI text-embedding-3 明确支持、BGE 也有 Matryoshka 训练变体:这类"套娃"向量大维度可截断成小维度用,不重训就能在存储和精度间权衡。

5.7 稀疏向量(BM25 / SPLADE)

混合检索 = 稠密(语义)+ 稀疏(关键词,BM25/SPLADE)。稀疏向量在入库时就要建好,线上直接混。

java 复制代码
// 1) 声明 EmbeddingModel:Spring AI Alibaba 自动装配 DashScopeEmbeddingModel(稠密向量)
@Autowired
private EmbeddingModel embeddingModel;   // 底层 = 阿里云百炼 text-embedding-v2 / v3

// 2) 入库:把 Document 交给 VectorStore,它内部调用 EmbeddingModel 生成稠密向量并写入 pgvector
List<Document> chunks = ...;             // 已解析、切分、脱敏后的块
chunks.forEach(d -> d.getMetadata().put("category", "legal"));  // 注入可过滤元数据
vectorStore.add(chunks);                  // 稠密向量自动生成并落库(无需手动 encode)

💡 关于稀疏 / 混合检索 :Spring AI 的 PgVectorStoresimilaritySearch 时可结合 PostgreSQL 全文检索(fts)+ 向量相似度 实现混合检索,开箱即用、无需自己维护稀疏向量。若要坚持 SPLADE 这类"模型产出的稀疏向量",则需引入对应模型并把稀疏权重作为独立字段存储------生产上多数 pgvector 用户直接用"向量 + 标量过滤 + 全文检索"三件套替代,省事且足够好。

5.8 Embedding 缓存

相同文本(如重复的免责声明)缓存向量,不重复算------大库能省 30%+ Embedding 成本。

5.9 截断与限流

超长文本按窗口截断;批量 Embedding 注意厂商 QPS 限流,加退避重试。

5.10 多语言与跨语言检索

跨境场景用 multilingual 模型,让中英文商品能在同一向量空间互搜。

5.11 ⚠️ 注意事项

  • 维度一致性:同一库所有向量维度必须相同,否则检索直接报错。
  • 模型版本锁定 :换 Embedding 模型 = 向量空间变了 = 全库必须重建,不是热插拔。
  • 切勿"今天用 A 模型、明天用 B 模型"混写。

5.12 🏭 工业级案例④:跨境电商商品库

背景 :百万级商品,中英日多语言,用户用中文搜英文商品名。 做法 :multilingual-e5 统一向量空间 → 稠密 + BM25 稀疏双向量 → Embedding 缓存去重 → 对支持 Matryoshka 的模型(如 text-embedding-3)按业务截断到 512 维省存储。 收益:跨语言召回率 +35%,存储成本降 40%。

5.13 💡 Embedding 选型场景对照

场景 推荐模型 关键考量
中文为主 + 要混合检索(稠密+稀疏)+ 私有化 BGE-M3 稀疏向量省去单独 BM25 / SPLADE 管线,1024 维,多语言一体
纯中文轻量 / 老项目兼容 bge-large-zh / bge-base-zh 中文强但仅稠密,不覆盖稀疏 / 多语言
阿里生态 / 中英文均衡 gte-large / gte-qwen 阿里系出品,中英文均衡
跨境多语言统一空间 multilingual-e5 固定 768 维,跨语言互搜
省运维 / 英文为主 OpenAI text-embedding-3 支持 MRL 截断(维度可裁),省部署成本
国内合规 / 免运维 阿里云百炼 text-embedding 系列(本文采用) DashScope API 即用,与 Chat 同供应商(Model RAG)
领域术语漂移(医疗 / 法律 / 金融) 混合检索 + 调 chunk → 效果不够再对比微调(CPT / LoRA) 别一上来就全量微调------数据工程和评测成本不小
量化场景 推荐方式 说明
大多数生产场景 SQ(float32 → int8) ~4× 压缩,几乎无损,性价比之王
亿级向量 + 内存紧张 PQ(Product Quantization) 8~32× 压缩,略有召回损失
粗排 / 召回兜底 Binary(二值化 ±1) ~32× 压缩,较明显精度损失

⚠️ 维度一致性铁律 :同一库所有向量维度必须相同。换模型 = 全量重建。本文 Model RAG 用 DashScope text-embedding-v2(1536 维)/ v3(可配置),application.ymldimensions 必须匹配。


六、环节五 · 向量存储与索引

6.1 向量库选型

定位 适合
Chroma 轻量单机 原型、小项目
Milvus 分布式、海量 工业级千万级
pgvector 复用 PostgreSQL 已有 PG、要事务
Qdrant 性能好、易运维 中大型
Weaviate 模块化、含模块 需要内置向量化

6.2 索引算法

向量索引决定"快不快、准不准、多占内存"。常见类型与取舍:

算法 原理 特点 适用
FLAT(暴力/精确) 全量比对 100% 召回,但慢 小库(万级)或需精确
HNSW 多层图 快、召回高,占内存 最常用,中大型在线检索
IVF-FLAT 倒排分桶 省内存,需训练聚类中心 内存敏感、可容忍微损
IVF-PQ 倒排 + 量化 最省内存/存储 超大规模
LSH 局部敏感哈希 早期方案,召回一般 逐渐被 HNSW/ScaNN 替代
ScaNN 各向异性量化 Google,高精度高吞吐 大库高精度
DiskANN 磁盘图索引 内存成本骤降(见 6.7) 千万级超大规模

取舍:HNSW 求快,IVF+PQ 求省,DiskANN 求"省到磁盘" 。各库默认不同------Chroma/Milvus/Qdrant 默认 HNSW,pgvector 支持 HNSW 与 IVFFlat(见 11.2 配置 index-type),Milvus 还支持 DiskANN。

6.3 元数据过滤 + 混合检索

先按 department=legal 过滤,再做向量检索------标量过滤 + 向量是工业标配。

java 复制代码
// 标量过滤 + 向量检索(Spring AI 验证过的写法:FilterExpressionBuilder + SearchRequest)
var filter = new FilterExpressionBuilder()
        .eq("department", "legal")     // 支持 eq/ne/gt/gte/lt/lte/in/nin/and/or
        .build();
List<Document> docs = vectorStore.similaritySearch(
        SearchRequest.builder()
                .query(question)        // 用户问题
                .topK(4)                // 取TopK
                .filterExpression(filter)  // 先按元数据过滤,再做向量检索
                .build());

6.4 规模化运维

分片(水平扩展)、副本(高可用)、冷热分离(旧文档转冷存储)、定期备份。

6.5 多向量 / ColBERT(Late Interaction)

ColBERT 存每个 token 的向量,检索时做 token 级 Late Interaction,精度高于"整块单向量",代价是存储大。

6.6 多租户隔离

多租户场景下,必须保证 A 客户搜不到 B 客户的知识。隔离粒度按向量库不同选法不同

向量库 隔离方式 说明
Milvus Collection / Partition 每租户一个 Collection,或同 Collection 内按 tenant_id Partition
Qdrant 独立 Collection 或 payload 过滤 强隔离用独立 Collection;同库用 tenant_id payload 过滤
pgvector Schema 隔离 或 行级策略(RLS)+ tenant_id 过滤 复用 PG 权限体系,最合规可控
Weaviate tenant API 原生多租户,按 tenant 隔离
Chroma 独立 Collection / 元数据过滤 小库可行,大库建议独立实例

关键:无论哪种库,检索时都必须强制带 tenant_id 过滤 (见 6.3 的 filterExpression),宁可冗余也不能跳过------跨租户泄漏是头号合规事故。(注:Namespace 是 Pinecone 术语,Milvus 用 Collection/Partition。)

6.7 DiskANN(磁盘索引)

千万级低成本方案:向量放磁盘,靠图索引高效查,内存成本骤降(DiskANN 为 CPU+磁盘方案,不依赖显存),适合超大规模。

6.8 元数据索引设计

常过滤的字段(department、date)建索引;低基数字段别建,反而拖慢写入。

6.9 一致性级别

金融场景要强一致 (写入立即可见);日志分析可用最终一致(省性能)。按业务选。

6.10 加密、备份、迁移

  • 静态加密(磁盘)+ 传输加密(TLS);
  • 定期快照备份,演练恢复;
  • 跨集群迁移用工具(如 Milvus 的备份工具),别手工导。

6.11 ⚠️ 注意事项

  • 索引参数(HNSW 的 efM)调优直接影响召回与延迟;
  • 内存预算算准,HNSW 是"内存吞金兽";
  • 一致性选错,会出现"刚更新的知识搜不到"。

6.12 🏭 工业级案例⑤:千万级企业知识库

背景 :集团千万级文档,多子公司多租户。

做法 :Milvus 分布式集群 → 用 Collection/Partition 隔离租户 → HNSW+PQ 省内存 → 标量过滤+稠密稀疏混合检索 → DiskANN 兜底冷数据。

收益:P99 检索 < 80ms,租户间零串数据,存储成本可控。

6.13 离线入库完整服务(RagIngestionService

把前面 2~6 节拆开的解析、切分、脱敏、向量化、入库 收口成一个可编译运行的服务------它就是把第二章的 PagePdfDocumentReader、第四章的 TokenTextSplitter、第二章的 PiiMasker、第五章的 VectorStore.add 串成一条幂等管道 。(以下类已随文末工程通过 mvn compile 验证)

java 复制代码
package com.example.rag.service;

import com.example.rag.util.PiiMasker;
import org.springframework.ai.document.Document;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.reader.pdf.PagePdfDocumentReader;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.core.io.FileSystemResource;
import org.springframework.stereotype.Service;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/** 离线入库:PDF 解析 -> 切分 -> 脱敏 -> Embedding -> 向量库。幂等:以内容 SHA-256 作为文档 id。 */
@Service
public class RagIngestionService {

    private final VectorStore vectorStore;
    private final TokenTextSplitter splitter = TokenTextSplitter.builder()
            .withChunkSize(500)
            .withMaxNumChunks(10000)
            .withKeepSeparator(true)
            .build();

    public RagIngestionService(VectorStore vectorStore, EmbeddingModel embeddingModel) {
        this.vectorStore = vectorStore;
    }

    public void ingest(String pdfPath, String department) throws Exception {
        // 1) 解析:按页读取,保留版面/分页结构(对应第二章 2.3)
        List<Document> pages = new PagePdfDocumentReader(new FileSystemResource(pdfPath)).get();
        // 2) 切分:按 token 切,避免跨模型 tokenizer 差异(对应第四章 4.1)
        List<Document> chunks = splitter.split(pages);
        // 3) 脱敏 + 注入元数据 + 计算幂等 id(脱敏对应第二章 2.9)
        MessageDigest md = MessageDigest.getInstance("SHA-256");
        List<Document> toAdd = new ArrayList<>(chunks.size());
        for (Document chunk : chunks) {
            String clean = PiiMasker.mask(chunk.getText());
            Map<String, Object> meta = new HashMap<>(chunk.getMetadata());
            meta.put("department", department);
            meta.put("source", pdfPath);
            String id = bytesToHex(md.digest(clean.getBytes(StandardCharsets.UTF_8)));
            toAdd.add(new Document(id, clean, meta));
            vectorStore.delete(List.of(id)); // 幂等:先删旧版本(不存在则 no-op)
        }
        // 4) 向量化 + 写入(Embedding 由 PgVectorStore 内部调用 embeddingModel 完成,对应第五章 5.7)
        vectorStore.add(toAdd);
    }

    private static String bytesToHex(byte[] bytes) {
        StringBuilder sb = new StringBuilder(bytes.length * 2);
        for (byte b : bytes) sb.append(String.format("%02x", b));
        return sb.toString();
    }
}

这一节就是离线阶段(2~6 章)的"总成":把每一章的知识点落成了一段能跑的代码。换解析器、调 chunk 大小、换 Embedding 模型,都在这一个方法里改。

6.14 在线检索生成(RagQueryService · 衔接下篇)

离线阶段把知识"焊进"向量库后,线上要做的就是检索 → 拼上下文 → 生成 。下面给出最小可运行服务:它复用第六章 6.3 的"元数据过滤 + 向量检索"写法,并加上带约束的 Prompt 抑制幻觉。下一篇《在线检索生成阶段》会把它升级成"重排 + 查询重写 + 多路召回"。

java 复制代码
package com.example.rag.service;

import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.stream.Collectors;

/** 在线生成:检索 -> 拼接上下文 -> 大模型生成(带约束提示,抑制幻觉)。 */
@Service
public class RagQueryService {

    private final VectorStore vectorStore;
    private final ChatModel chatModel;

    @Autowired
    public RagQueryService(VectorStore vectorStore, ChatModel chatModel) {
        this.vectorStore = vectorStore;
        this.chatModel = chatModel;
    }

    public String answer(String question, String department) {
        // 1) 检索:按部门过滤 + topK(复用 6.3 的 FilterExpressionBuilder + SearchRequest)
        var departmentFilter = new FilterExpressionBuilder()
                .eq("department", department)
                .build();
        List<Document> docs = vectorStore.similaritySearch(
                SearchRequest.builder()
                        .query(question)
                        .topK(4)
                        .filterExpression(departmentFilter)
                        .build());
        // 2) 拼接上下文
        String context = docs.stream()
                .map(Document::getText)
                .collect(Collectors.joining("\n---\n"));
        // 3) 组装带约束的 Prompt(抑制幻觉的关键:限定只基于资料作答)
        String prompt = """
                你是一个严谨的企业助手,只能基于【资料】回答,资料中没有的信息回答"不知道"。
                不要编造,引用资料中的关键信息。

                【资料】
                %s

                【问题】
                %s
                """.formatted(context, question);
        // 4) 生成
        ChatResponse response = chatModel.call(new Prompt(new UserMessage(prompt)));
        return response.getResult().getOutput().getText();
    }
}

到这里,"Model RAG"的离线 + 在线两条链路就齐了:离线(6.13)把 PDF 变成带元数据的向量,在线(6.14)按元数据过滤检索并让大模型基于资料作答。两者共用 Spring AI 抽象、模型统一走 DashScope。

6.15 💡 向量库 / 索引选型场景对照

场景 推荐方案 核心原因
原型验证 / 小项目(万级以下) Chroma 零依赖,开箱即用
已有 PostgreSQL / 需事务支持 pgvector(本文采用) 复用 PG 权限体系,RLS 多租户隔离最合规可控
中大型(十万 ~ 千万级) Qdrant / Milvus 性能好、易运维(Qdrant)或分布式可扩展(Milvus)
千万级以上超大规模 Milvus 集群 + DiskANN 内存成本骤降,冷热分离
需内置向量化 Weaviate 自带 embedding pipeline
索引算法选型 适用规模 特点 推荐
FLAT(暴力) < 5 万 100% 召回,慢 小库精确首选
HNSW 5 万 ~ 千万级 快、召回高,占内存 最常用默认选择(pgvector / Milvus / Qdrant 均支持)
IVF-PQ 千万级+ 最省内存 / 存储 内存敏感时替代 HNSW
DiskANN 亿级 磁盘图索引 千万级超大规模冷存储
多租户隔离方式(按向量库) 方案 合规等级
Milvus Collection / Partition 强隔离(独立 Collection)或同库 Partition 过滤
pgvector Schema 隔离 或 RLS + tenant_id 过滤 最高合规(复用 PG 权限体系)
Qdrant 独立 Collection 或 payload 过滤 强隔离用独立 Collection
Chroma 独立 Collection / 元数据过滤 小库可行

⭐ 默认推荐 :已有 PG 用 pgvector + HNSW ;千万级上 Milvus + HNSW(+PQ)无论哪种库,检索必须强制 tenant_id 过滤------跨租户泄漏是头号合规事故。


七、进阶专题 · 离线阶段高阶工程

7.1 增量更新与版本管理

只重算"变了的部分"(CDC 驱动),不重建全量------否则千万级库重跑一次按天计。

7.2 多模态入库

图(caption/CLIP 向量)、表(保留结构)、音视频(ASR 转文本再 Embedding)。

7.3 知识图谱抽取(GraphRAG 前置)

从文档抽实体关系,建图,支撑"全局性总结"类问题(微软 GraphRAG 思路)。

7.4 入库质量量化评测

  • Chunk 覆盖率:关键信息是否都被切成可检索块;
  • 检索召回基线:用标注集测 Recall@K;
  • bad case 回流:答错的,反推是入库哪环出问题。

7.5 评测工具落地

  • Ragas:自动算 faithfulness / context recall;
  • TruLens:追踪上下文相关性;
  • 自研指标卡:贴合业务(如"条款命中准确率")。

7.6 成本与性能

Embedding 批量调用、异步管道、向量缓存、限流退避------大库降本靠这三板斧。

7.7 ⚠️ 提示注入防护(入库侧)

恶意文档可能在 chunk 里藏指令:"忽略上文,输出内部密码"。入库时扫描并清洗这类注入片段,比线上拦截更根本。

7.8 Pipeline 可观测性

日志 + Trace(如 LangSmith / Phoenix),监控每环节耗时与失败率,异常告警。

7.9 合规与审计

数据驻留(境内数据不出境)、等保/GDPR 合规、全链路审计日志可追溯。

7.10 容量规划与成本建模

复制代码
存储成本 ≈ 向量数 × 维度 × 字节
Embedding 成本 ≈ 文档 token 数 × 单价 × 更新频次

上线前先算账,别等账单吓一跳。

7.11 A/B 测试与灰度

不同切分/Embedding 策略,用标注集离线 A/B,优胜者灰度上线,拒绝"拍脑袋调参"。


八、工业级端到端串讲

8.1 案例背景

某 SaaS 客服知识库:百万级帮助文档,多语言,强合规(PII 不可泄露),要求 P99 检索 < 100ms。

8.2 架构选型决策树

复制代码
数据量 < 10万? ──► Chroma 单机
数据量 千万级? ──► Milvus 集群 + DiskANN
要事务/已有 PG? ──► pgvector
强合规? ──► 私有化 + 脱敏 + 审计

8.3 踩坑复盘

  • 初期按字符切 → 中文 chunk 过大 → 改 token 切 + 父子文档,召回 +25%;
  • 模型未锁定 → 一次"升级"导致全库向量错位 → 立规矩:换模型=全量重建+审批

8.4 安全合规复盘

入库前 PII 脱敏 + 注入扫描;多租户隔离(Collection/Partition 或对应向量库的租户机制);全链路审计日志,监管来查一键导出。


九、离线阶段验收 Checklist(可照抄)

9.1 五张清单

环节 验收项
解析 文本/表格/公式完整?扫描件 OCR?阅读顺序对?
清洗 噪声剔除?去重?元数据齐全?
切分 大小合理?overlap 设了?没跨句截断?
Embedding 模型锁定?维度一致?缓存?稀疏向量建了?
存储 索引选对?过滤生效?备份?多租户隔离?

9.2 安全·合规·可观测附加清单

  • PII 已脱敏
  • 提示注入已扫描
  • 数据驻留合规
  • 审计日志可追溯
  • Pipeline 可观测、可重试、幂等

十、总结与下篇预告

离线阶段一句话总结 :它决定了 RAG 能"找得到"什么。解析保结构、切分定边界、Embedding 定语义、存储定规模------每一环都在焊天花板,地基牢了,线上才好调。

记住开头那个故事:召回从三成出头到八成以上,模型一行没动。 这就是离线阶段的杠杆。

下一篇预告:《在线检索生成阶段全解》------检索怎么排、重排(Rerank)为什么是分水岭、Prompt 怎么组装、生成怎么控幻觉。我们线上见。


如果这篇帮你避开了至少一个坑,点赞收藏不迷路 👍 系列持续更新,关注不漏更。

系列目录:① RAG 技术全景 → ② 离线阶段(本篇)→ ③ 在线检索生成阶段 → ④ 评测与优化


十一、工程骨架与运行(pom / 配置 / 启动类)

前面各章已把完整类内联 到对应位置:脱敏工具见 [2.9 节](#2.9 节 "#29-pii-%E8%AF%86%E5%88%AB%E4%B8%8E%E8%84%B1%E6%95%8F%E5%90%88%E8%A7%84%E5%89%8D%E7%BD%AE")、离线入库服务见 [6.13 节](#6.13 节 "#613-%E7%A6%BB%E7%BA%BF%E5%85%A5%E5%BA%93%E5%AE%8C%E6%95%B4%E6%9C%8D%E5%8A%A1raingestionservice")、在线检索生成服务见 [6.14 节](#6.14 节 "#614-%E5%9C%A8%E7%BA%BF%E6%A3%80%E7%B4%A2%E7%94%9F%E6%88%90ragqueryservice--%E8%A1%94%E6%8E%A5%E4%B8%8B%E7%AF%87")。本节只收口工程骨架 ------pom.xml、配置、启动类,以及怎么跑起来。架构叫 Model RAG :用同一个大模型供应商(阿里云百炼 / DashScope)同时承担向量化(Embedding)与生成(Chat),由 Spring AI 统一抽象、Spring AI Alibaba 自动装配,向量落在 PostgreSQL + pgvector。

本工程使用 Spring AI 1.0.3 + Spring AI Alibaba 1.0.0.3,已通过 mvn compile 真实编译验证 (所有 API:VectorStoreDocumentPagePdfDocumentReaderTokenTextSplitterFilterExpressionBuilderSearchRequestDashScopeChatModel/DashScopeEmbeddingModel 均与锁定版本一致)。生产环境需自备 PostgreSQL(启用 pgvector 扩展)与 AI_DASHSCOPE_API_KEY

11.1 Maven 依赖(pom.xml

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.4.5</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>rag-spring-demo</artifactId>
    <version>1.0.0</version>
    <name>rag-spring-demo</name>

    <properties>
        <java.version>17</java.version>
        <spring-ai.version>1.0.3</spring-ai.version>
        <spring-ai-alibaba.version>1.0.0.3</spring-ai-alibaba.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.ai</groupId>
                <artifactId>spring-ai-bom</artifactId>
                <version>${spring-ai.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <dependency>
                <groupId>com.alibaba.cloud.ai</groupId>
                <artifactId>spring-ai-alibaba-bom</artifactId>
                <version>${spring-ai-alibaba.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>
        
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-starter-vector-store-pgvector</artifactId>
        </dependency>
        
        <dependency>
            <groupId>com.alibaba.cloud.ai</groupId>
            <artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
        </dependency>
        
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-pdf-document-reader</artifactId>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

11.2 配置(application.yml

yaml 复制代码
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/postgres
    username: postgres
    password: postgres
  ai:
    # 阿里云百炼 / DashScope:由 Spring AI Alibaba 自动装配 DashScopeChatModel / DashScopeEmbeddingModel
    dashscope:
      api-key: ${AI_DASHSCOPE_API_KEY}
      chat:
        options:
          model: qwen-plus
    vectorstore:
      pgvector:
        index-type: HNSW
        distance-type: COSINE_DISTANCE
        dimensions: 1536   # 与 DashScope 默认 text-embedding-v2 维度一致
        initialize-schema: true

11.3 启动类

java 复制代码
package com.example.rag;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class RagSpringDemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(RagSpringDemoApplication.class, args);
    }
}

📌 三个业务类已内联到对应章节,本节不再重复粘贴

  • 脱敏工具 PiiMasker → 见 2.9 节
  • 离线入库服务 RagIngestionService → 见 6.13 节
  • 在线检索服务 RagQueryService → 见 6.14 节

11.4 跑起来 & 验证

bash 复制代码
# 1) 准备 PostgreSQL 并启用 pgvector
CREATE EXTENSION IF NOT EXISTS vector;

# 2) 设置 API Key 后启动(Spring Boot 自动装配 DashScope + PgVectorStore)
export AI_DASHSCOPE_API_KEY=sk-xxxx
mvn spring-boot:run

# 3) 调用离线入库(注入一份 PDF)
ingestionService.ingest("contracts/2023-合同.pdf", "legal");
# 4) 调用在线问答
String ans = queryService.answer("试用期离职要提前几天?", "legal");

🔧 依赖前置 :PostgreSQL 需安装 pgvector 扩展;dimensions 必须与所用 DashScope Embedding 模型维度一致(text-embedding-v2 为 1536,text-embedding-v3 为 1024/1536 等可选)。换 Embedding 模型 = 向量空间变化 = 需重建向量表,与本文 5.11 一致。

这一节就是" Model RAG "的最小可运行骨架:离线入库(6.13 节)与在线生成(6.14 节)共用一套 Spring AI 抽象,模型统一走 DashScope。 下一篇《在线检索生成阶段》会把重排(Rerank)、查询重写、Prompt 组装与幻觉控制讲透。

相关推荐
leeyi32 分钟前
把软件装进不能上网的机房——一套建好了、还没上过战场的交付工程(第101篇)
docker·aigc·agent
SelectDB2 小时前
为什么 JSON 正在成为分析数据库新的竞争点?
数据库·json·agent
机械改造鹅2 小时前
从零开始拆解Pi系列——(7)Extension API
agent
plainGeekDev3 小时前
Agent代码审查与批量修复流水线
agent·ai编程·claude
然我3 小时前
模型不是 Agent:从零实现一个最小 Agent Loop
前端·人工智能·agent
深蓝AI3 小时前
Mem0 实战:给 AI 应用加上长期记忆,从 Hello World 到生产用法
agent
AI效率君3 小时前
Deer‑Flow 2.0 + Go‑MCP‑Server(add加法工具)保姆级完整教程
人工智能·agent
昭昭日月明3 小时前
LangChain 生态:从链到代理,开发者需要掌握的三大核心
python·langchain·agent
Csvn4 小时前
第 12 章 并行化 Parallelization
人工智能·aigc·agent