RAG(一)离线阶段

RAG (一)离线阶段

github:【https://github.com/Jack-txf/ai-engineering】

在有了前文的基础之后:https://mp.weixin.qq.com/s/xdBy5Sav_gc66NBnTby2yQ

现在来研究一下一个可用的 RAG(Retrieval-Augmented Generation)系统,绝对不是简单的“向量检索 + LLM”拼接。根据ChatGPT的流程图,我们可以知道大致步骤如下:

                 ┌──────────────┐
                 │  数据源接入   │
                 │ (文档/DB/API) │
                 └──────┬───────┘
                        ↓
               ┌──────────────┐
               │ 数据清洗处理  │
               │ 去噪/结构化   │
               └──────┬───────┘
                      ↓
               ┌──────────────┐
               │ 文档切分Chunk │
               │ (语义切分)    │
               └──────┬───────┘
                      ↓
          ┌───────────────────────┐
          │ 向量化 Embedding       │
          │ (BGE/OpenAI/E5等)     │
          └──────┬────────────────┘
                 ↓
      ┌────────────────────────────┐
      │ 向量数据库 Vector DB         │
      │ (Milvus / FAISS / ES)      │
      └────────┬───────────────────┘
               ↓
      ┌────────────────────────────┐
      │ 检索层 Retrieval            │
      │ (向量 + BM25 + 重排)        │
      └────────┬───────────────────┘
               ↓
      ┌────────────────────────────┐
      │ Prompt构建 / 上下文拼接      │
      └────────┬───────────────────┘
               ↓
      ┌────────────────────────────┐
      │ LLM推理 (GPT / 本地模型)     │
      └────────┬───────────────────┘
               ↓
      ┌────────────────────────────┐
      │ 后处理 + 评估 + 审核         │
      └────────┬───────────────────┘
               ↓
      ┌────────────────────────────┐
      │ 返回用户结果 + 日志监控       │
      └────────────────────────────┘

环境准备。

Redis6及以上;MySQL8.0及以上;Java21;Milvus2.6.x;

最后一个向量数据库为了方便我们就直接采用docker-compose一键安装,然后单机启动了。

1. 准备阶段

1.1 安装Milvus

安装就直接参考官网的命令了。https://milvus.io/docs/install_standalone-docker-compose.md

首先确保docker、和docker-compose是正常的,可以正常拉取镜像。

然后到你的linux里面去,创建一个milvus目录,到这个目录里面执行命令,将官网的docker-compose.yml下载下来,如下图所示:

如果碰到下载不下来的情况,可以考虑到浏览器里面复制这个地址下载到你的本地,然后上传到你的服务器上就行了。

https://github.com/milvus-io/milvus/releases/download/v2.6.12/milvus-standalone-docker-compose.yml -O docker-compose.yml

实在不行,下面给出文件内容吧 docker-compose.yml:【下面对容器内存进行了限制,还加了attu可视化界面】

# version: '3.5'

services:
  etcd:
    container_name: milvus-etcd
    image: quay.io/coreos/etcd:v3.5.25
    restart: always
    deploy:
      resources:
        limits:
          memory: 256M
    environment:
      - ETCD_AUTO_COMPACTION_MODE=revision
      - ETCD_AUTO_COMPACTION_RETENTION=1000
      - ETCD_QUOTA_BACKEND_BYTES=4294967296
      - ETCD_SNAPSHOT_COUNT=50000
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/etcd:/etcd
    command: etcd -advertise-client-urls=http://etcd:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd
    healthcheck:
      test: ["CMD", "etcdctl", "endpoint", "health"]
      interval: 30s
      timeout: 20s
      retries: 3

  minio:
    container_name: milvus-minio
    image: minio/minio:RELEASE.2024-12-18T13-15-44Z
    restart: always
    deploy:
      resources:
        limits:
          memory: 256M
    environment:
      MINIO_ACCESS_KEY: minioadmin
      MINIO_SECRET_KEY: minioadmin
    ports:
      - "9001:9001"
      - "9000:9000"
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/minio:/minio_data
    command: minio server /minio_data --console-address ":9001"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
      interval: 30s
      timeout: 20s
      retries: 3

  standalone:
    container_name: milvus-standalone
    image: milvusdb/milvus:v2.6.12
    restart: always
    deploy:
      resources:
        limits:
          memory: 1G
    command: ["milvus", "run", "standalone"]
    security_opt:
    - seccomp:unconfined
    environment:
      ETCD_ENDPOINTS: etcd:2379
      MINIO_ADDRESS: minio:9000
      MQ_TYPE: woodpecker
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9091/healthz"]
      interval: 30s
      start_period: 90s
      timeout: 20s
      retries: 3
    ports:
      - "19530:19530"
      - "9091:9091"
    depends_on:
      - "etcd"
      - "minio"

  attu:
    container_name: milvus-attu
    image: zilliz/attu:v2.6.3
    environment:
      MILVUS_URL: milvus-standalone:19530
    ports:
      - "3000:3000"
    depends_on:
      - standalone
    restart: always
    deploy:
      resources:
        limits:
          memory: 256M

networks:
  default:
    name: milvus

这个yml文件弄好之后,到这里直接执行命令就行了:【我这里是Ubuntu系统,当前登录的用户没有docker权限,要加个sudo;如果是centos系统,不用这个sudo】

sudo docker-compose -f 文件名 up -d

然后等待即可。上图中没有attu,是截的之前版本的图,前面给的docker-compose.yml的内容是真实可直接用的,本人测试过。

如果想要外界访问,记得开放端口。上面给出的yml文件中,3000端口是attu可视化界面的,可以webui查看当前向量数据库的状态。打开访问地址后,直接点击 连接 按钮即可登录。ip:3000

能打开这个界面就ok了,刚开始肯定是没有数据的,上面我这个是有数据的。


还有必要简单介绍一下这个Milvus的:

Milvus 是一款为向量相似度检索而设计的云原生数据库。它的核心能力是处理非结构化数据(如文本、图像、音频、视频等)的向量化表示,并通过向量索引近似最近邻搜索算法,在海量数据中实现毫秒级的检索。

为了更直观,我们把 Milvus 和 MySQL 的术语并排放在一起看:

概念层次 Milvus MySQL 说明与类比
第一层 Collection Table (数据表) 最顶层的组织单元。一个Collection对应一张MySQL表,用于存放一组相关的实体。
第二层 Entity Row (行) 数据的基本单位。每个Entity代表一个具体的对象(如一张图片、一段文本)。
第三层 Field Column (列) 定义数据的属性。但Milvus的Field有特殊分类,比MySQL的列更复杂。

同时从实际应用角度来说:

概念层次 Milvus 官方数据模型 实际应用视角 (RAG/LLM框架) 说明
最高层 Collection (集合/数据表) Index / Knowledge Base (索引/知识库) 用于存放同一类数据的最大单元,如“公司制度知识库”。
数据行 Entity (实体/数据行) Document (文档/文本块) 这是关键对应。在 RAG 应用中,你存入 Milvus 的每一个“文档”(或文本块),在 Milvus 内部就是一个 Entity。
数据列 Field (字段/列) Field (字段) 用于描述一个 Entity 的不同属性,如 idtext_contentvectormetadata 等。

1.2 整体流程

一个完整的流程可以如下图所示,大致分为离线存储、在线检索两个阶段

离线流水线负责把原始文档变成可检索的结构化知识;在线流水线负责把用户的问题变成精准答案。

1.3 模型管理

本文涉及到的模型有三类:对话、嵌入和重排序三种模型,选用的同一厂商旗下的【Qwen】。平台就用硅基流动的吧。

配置结构大概如下:

# 模型配置
ai-model:
  providers:
    siliconflow:
      api-key: asdsaddas
      base-url: https://api.siliconflow.cn/v1
      chat-model:
        - Qwen/Qwen3.5-122B-A10B
        - Qwen/Qwen3.5-35B-A3B
      embed-model:
        - Qwen/Qwen3-Embedding-4B
        - Qwen/Qwen3-Embedding-8B
      rerank-model:
        - Qwen/Qwen3-Reranker-4B
        - Qwen/Qwen3-Reranker-0.6B

对应到配置类就是:

@Data
@Configuration
@ConfigurationProperties(prefix = "ai-model")
public class GlobalModelProperties {
    // 模型提供商
    private Map<String, ProviderConfig> providers = new HashMap<>();

    @Data
    public static class ProviderConfig {
        /**
         * 提供商基础 URL
         */
        private String baseUrl;

        /**
         * API 密钥
         */
        private String apiKey;

        // 三种模型名字
        private List<String> chatModel;
        private List<String> embedModel;
        private List<String> rerankModel;
    }
}

数组是为了备用的。假如第一个模型不行了,可以备用第二个,以此类推(这个功能好像忘记做了)。

见仓库的【all-rag】model包

本文仅到离线阶段。

2. 知识库构建与管理【离线】

这是 RAG 系统质量的根基,决定了"能检索到什么"。下面的每一步仅给出简单的处理过程,想要做成一个完整的系统功能,还是要耗费相当大的精力的,本文仅对整体大致流程作讲解。

2.1 数据源的接入

RAG 的数据源有很多种类:文档类(pdf,word,excel,ppt,md等等);结构化数据(数据库、csv等等);还可能会有网页内容、多媒体内容(视频、音频、图片)、甚至代码仓库也会有(github等等)。每类的解析挑战差异很大——有些是格式问题,有些是结构问题,有些是权限问题。

本文就做简单一点,采用Tika,Apache Tika 是一个非常强大的开源项目,被誉为内容的“数字瑞士军刀”。它的核心任务只有一件事:从各种不同格式的文档中自动检测并提取元数据和文本内容。

Tika 的工作流程主要分为两个关键步骤:

  • 类型检测 (Type Detection): 识别文件的 MIME 类型(例如 application/pdf)。它不是简单看后缀名,而是通过文件的“魔数”(Magic Bytes)来判断。
  • 内容提取 (Content Extraction): 调用相应的解析库(如 POI 解析 Office,PDFBox 解析 PDF),统一输出为标准的文本或 XHTML 格式。

Tika 本身并不“发明”解析技术,它更像是一个聚合器。它将市面上几十个零散的解析库封装在统一的接口下,让你不需要为每种格式都写一套代码。

  1. Detector (检测器): 负责识别文件格式。
  2. Parser (解析器): 负责具体格式的解析。Tika 拥有一个 AutoDetectParser,它可以自动根据检测到的类型选择最合适的解析器。
  3. ContentHandler (内容处理器): 决定提取出来的文本往哪儿放(比如存入字符串、写入文件)。
  4. Metadata (元数据): 提取文档的属性,如作者、创建日期、分辨率等。

接口设计:

@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Timed(value = "rag.api.upload", description = "文件上传解析接口耗时")
public ResponseEntity<ApiResponse<DocumentParseResult>> upload(
    @RequestPart("file") MultipartFile file,
    @RequestParam(value = "sourceId", required = false) String sourceId) {

    log.info("[Controller] 文件上传接口调用: fileName={}, sourceId={}",
             file.getOriginalFilename(), sourceId);

    DataSourceRequest request = new DataSourceRequest();
    request.setSourceType(DataSourceRequest.SourceType.FILE_UPLOAD);
    request.setFile(file);
    request.setSourceId(sourceId);
	// 使用统一数据源接入服务的ingest方法
    DocumentParseResult result = ingestionService.ingest(request);
    return ResponseEntity.ok(ApiResponse.success(result));
}

数据源接入Service层:

@Service
public class DataSourceIngestionService {

    // 文档解析service
    private final DocumentParserService parserService;
    ...
    /**
     * 文档数据源接入主入口。
     */
    public DocumentParseResult ingest(DataSourceRequest request) {
        log.info("[IngestionService] 接收数据源接入请求: sourceType={}, sourceId={}",
            request.getSourceType(), request.getSourceId());

        return switch (request.getSourceType()) {
            case FILE_UPLOAD -> ingestFile(request.getFile(), request.getSourceId());
            case URL         -> ingestUrl(request.getUrl(), request.getSourceId());
            case RAW_TEXT    -> ingestRawText(request.getRawText(),
                                    request.getRawTextFileName(), request.getSourceId());
        };
    }

}

文档解析service接口设计:

public interface DocumentParser {
    /**
     * 解析文档,提取纯文本内容和元数据。
     */
    DocumentParseResult parse(InputStream inputStream, String fileName, String sourceId);
}

//============== 实现类
public class TikaDocumentParser implements DocumentParser {
    @Override
    public DocumentParseResult parse(InputStream inputStream, String fileName, String sourceId) {
        .....
        try {
            // ── 步骤1:构建 ParseContext ──────────────────────────────
            ParseContext parseContext = buildParseContext();
            // ── 步骤2:准备 Metadata 和 ContentHandler ────────────────
            Metadata metadata = new Metadata();
            // 提前设置文件名,帮助 Tika 在 MIME 检测失败时作为辅助判断依据
            metadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, fileName);

            int maxLen = properties.getParser().getMaxContentLength();
            BodyContentHandler contentHandler = new BodyContentHandler(maxLen);

            // ── 步骤3:【执行解析】────────────────────────────────────
            // TikaInputStream tikaInputStream = TikaInputStream.get(inputStream);
            // 注入一个tika解析器
            autoDetectParser.parse(inputStream, contentHandler, metadata, parseContext);

            // ── 步骤4:提取文本内容 ───────────────────────────────────
            String rawContent = contentHandler.toString();
            // ── 步骤5:文本清洗 ───────────────────────────────────────
            String cleanedContent = TextCleanupUtils.clean(rawContent);
            // ── 步骤6:提取元数据 ─────────────────────────────────────
            Map<String, String> metadataMap = extractMetadata(metadata);
            // ── 步骤7:计算耗时并上报指标 ────────────────────────────
			....
        } 
}

本文的文档解析做的挺简单的。

测试出来可以解析的类型有:pptx,html,doc,docx,json,md,xlsx,csv,xml,pdf,txt,xls

访问接口测试一下,如上图所示,上传了一个ppt文件。其他类型文件读者可自行测试。

2.2 清洗

Tika 是一个格式解析器,它的职责是"把文件里的字节翻译成文字",而不是"把文字整理成干净可用的知识"。这两件事之间存在巨大的鸿沟。理解这个问题最直接的方式是看真实案例。一份普通的企业 PDF 报告,Tika 解析后原始输出大概是这样的:

第 1 页,共 48 页       ← 页码信息,每页都有,完全是噪声
─────────────────────── ← 装饰性分隔线
版权所有 © 2024 某某公司 保留所有权利    ← 页脚,每页重复
公司内部文件 请勿外传                    ← 水印文字,OCR 识别后混入正文

         第三章   市场分析
     (内容缩进混乱,空格来自 PDF 排版坐标)

本章  摘  要                ← 字间距导致的空格插入
市场规模预计达到 1 2 3 亿元   ← "123" 被拆成 "1 2 3"(PDF 字符坐标问题)

图 3-1:市场份额分布图      ← 图片标题,图片内容却没有(图片无法提取)
[图片内容无法提取]           ← Tika 占位符

    表 3-1
┌──────┬──────┬──────┐    ← 表格线条变成了乱码字符
│ 地区 │ 2022 │ 2023 │
├──────┼──────┼──────┤
│ 华东 │  45% │  48% │
└──────┴──────┴──────┘
华东452022482023         ← 更糟的情况:表格内容完全错位拼接

                         ← 10 个以上的连续空行
第 2 页,共 48 页        ← 又是页码
─────────────────────── ← 又是分隔线
版权所有 © 2024 某某公司 保留所有权利    ← 又是页脚

这种文本直接送入 Embedding 模型会发生什么?向量空间里,"版权所有 © 2024 某某公司"这句话会被编码成一个语义向量,而这句话在文档的每一页都会重复出现,导致向量库里充斥着语义相同但无价值的噪声 chunk。一份 50 页的企业 PDF 报告,Tika 解析出约 8 万字符。其中版权声明每页 1 行、页码每页 1 行、分隔线每页 1 行、空白行若干,这些噪声内容大约占总字符数的 15%~30%

如果不清洗直接 Chunking,假设每个 chunk 512 token,50 页文档产生约 80 个 chunk。其中大约有 12~24 个 chunk 的主要内容是噪声(版权声明、页码拼接出的奇怪文本)。这些噪声 chunk 会被向量化并存入向量库,检索时产生干扰,最终导致用户问真实业务问题时,返回的 Top-K 里混进这些无意义内容,LLM 生成答案时被噪声带偏,出现答非所问或幻觉。

不同格式的文档,噪声的来源和形态各不相同。

首先是PDF 的噪声特别多,因为 PDF 本质是打印指令而非语义文档。每个字符都有独立的坐标,Tika 按坐标顺序重组文字,遇到多栏排版、表格、文字环绕图片时,重组顺序很容易乱掉。页眉页脚是每页固定位置的文字块,Tika 无法区分它们和正文,全部平铺输出。

Word 文档相对好一些,但有零宽字符问题。Word 的内部 XML 里大量使用零宽连字符(\u200B)、零宽非连接符(\u200C)等不可见字符来控制排版。这些字符肉眼看不到,但会破坏分词器的处理,让"人工智能"变成"人工智能"(中间有个不可见字符),导致向量计算偏差。

PPT 幻灯片的文字是碎片化的,每个文本框独立提取,Tika 输出时按文本框顺序拼接,结果可能是标题→备注→副标题→内容的混乱顺序,还会混入大量"点击此处添加标题"这类模板占位符。

网页/HTML的噪声来自导航栏、侧边栏、广告位、Cookie 提示、"相关推荐"区块。这些 DOM 节点里的文字和正文在 HTML 里是平等的,Tika 全部提取出来。

剩下的略去....

应该如何清洗呢?

第一层:确定性规则清洗。这层处理所有有确定模式的噪声,用正则表达式批量处理;

第二层:统计指标过滤。规则清洗之后,再对每个切分后的 chunk 做快速质量评分,低质量的直接丢弃,不进入 Embedding 环节(Embedding 是成本最高的环节)。

第三层:LLM 语义级过滤。前两层解决不了的语义噪声(比如模板占位文字"本节内容待补充"、完全跑题的附录免责声明、或者整段都是没有意义的示例数据),才用 LLM 做二次判断。这层成本高,只在文档价值高的场景开启。

Tika 解析
    ↓
【第一层清洗】字符级 + 格式噪声    ← 文档粒度,全量执行
    ↓
Chunking(文档切分)
    ↓
【第二层过滤】统计指标质量评分     ← Chunk 粒度,全量执行
    ↓
(可选)【第三层过滤】LLM 语义判断 ← Chunk 粒度,仅高价值文档
    ↓
Embedding(向量化)

2.3 切片Chunking

首先要知道的事, Embedding 模型有输入长度限制。主流的向量模型输入通常是有上限的 token。一份 50 页的 PDF 有几万 token,根本无法整体编码成一个向量。必须切成小块,每块单独编码。

同时呢,LLM 的上下文窗口是宝贵资源。即便检索到了相关内容,也不能把整个文档塞进 Prompt,那会超出 上下文窗口,就算未来的模型token是无上限的,整篇文档会充斥大量无关内容会干扰 LLM 生成。Chunking 让你只把最相关的那几段送进去。

在 RAG中,正常做法不是把整篇文档都一股脑儿给模型,而是先检索出最相关的几段文本,然后只把这几段最相关的给它。这样模型拿到的就是精准、干净的了,同时还节省token(相比于整篇文档),回答质量就很ok了。

所以,我们就需要chunking了,但是做好chunking也是一门大学问。


在进入具体chunking策略之前,需要理解一个贯穿所有策略的核心矛盾:

chunk 越小 → 语义越精准,但上下文越少,LLM 生成时信息不足,容易产生幻觉。

chunk 越大 → 上下文越完整,但语义越模糊,检索相似度被稀释,召回率下降。

没有一个"正确的" chunk 大小,只有适合你的文档类型和查询模式的大小。下面五种策略,从简单到复杂,覆盖了生产中 90% 的场景。

固定长度 + 滑动窗口重叠

最简单的策略。按 token 数量切分,相邻 chunk 之间保留一定比例的重叠内容。重叠内容它是为了防止一句完整的话被切断在两个 chunk 的边界处,导致两个 chunk 都无法单独理解这句话。

结构化边界切分

按段落、句子等自然语义单元切分,再按大小合并或拆分,保证每个 chunk 的边界都是语义完整的位置。这是大多数通用文档的首选策略,因为它在实现复杂度和语义质量之间取得了最好的平衡。

层级切分

这是企业级 RAG 最推荐的策略,核心思路是按文档的标题层级切分,构建父子 chunk 树。企业文档(技术手册、产品规格书、操作规程)天然是有层级结构的:章→节→段落。这个层级本身就是作者对内容的语义划分。顺着它切,比任何算法都准确。

父子 chunk 的检索策略:用小 chunk(段落级)做检索(语义精准),召回后扩展到父 chunk(章节级)送给 LLM(上下文完整)。这解决了"检索精度"和"生成质量"的核心矛盾。

命题切分

将文档分解为最小的原子事实单元,每个 chunk 只表达一个独立命题。这是精度最高的策略,代价是需要 LLM 辅助,成本较高。

Late Chunking

前四种策略都有一个共同问题:切分发生在 Embedding 之前,导致每个 chunk 的向量只能看到自己内部的文字,看不到文档的整体语境。

Late Chunking 反转了这个顺序:先对整篇文档做 Embedding,再切分

传统方案:先切分 → 再 Embedding(每个 chunk 只看自己)

Late Chunking:先 Embedding → 再切分(每个 chunk 能感知全文语境)

总结这五种策略:

2.4 demo1

到此处,我们已经有了这些基础:

Tika解析文档提取文本 --> 清洗去除噪声【分块前去噪声】--> 内容分块 --> 再清洗【分块后去噪声】的部分。

此时可以完成一个小案例了。见分支【demo1

Tika解析文档可以看DataSourceController里面的接口一,读者可以自行测试一下。主要就是DocumentParseResult result = ingestionService.ingest(request);

public DocumentParseResult ingest(DataSourceRequest request) {
    log.info("[IngestionService] 接收数据源接入请求: sourceType={}, sourceId={}",
             request.getSourceType(), request.getSourceId());

    return switch (request.getSourceType()) {
            // Java 21 switch expression:更简洁,编译器强制覆盖所有 case
        case FILE_UPLOAD -> ingestFile(request.getFile(), request.getSourceId());
        case URL         -> ingestUrl(request.getUrl(), request.getSourceId());
        case RAW_TEXT    -> ingestRawText(request.getRawText(),
                                          request.getRawTextFileName(), request.getSourceId());
    };
}
private DocumentParseResult ingestFile(MultipartFile file, String sourceId) {
        .....
        try {
            byte[] bytes = file.getBytes();
            return parserService.parseBytes(bytes, fileName, sourceId);
        } catch (IOException e) {
            log.error("[IngestionService] 读取上传文件失败: fileName={}", fileName, e);
            throw new DocumentParseException("FILE_READ_ERROR",
                "读取上传文件失败: " + e.getMessage(), 500, e);
        }
    }

最后就是到TikaDocumentParse里面的parse方法:

public DocumentParseResult parse(InputStream inputStream, String fileName, String sourceId) {
    Instant startTime = Instant.now();
    List<String> parseErrors = new ArrayList<>();

    // Micrometer Timer:记录解析耗时,自动上报 Prometheus
    Timer.Sample sample = Timer.start(meterRegistry);

    try {
        // ── 步骤1:构建 ParseContext ──────────────────────────────
        ParseContext parseContext = buildParseContext();
        // ── 步骤2:准备 Metadata 和 ContentHandler ────────────────
        Metadata metadata = new Metadata();
        // 提前设置文件名,帮助 Tika 在 MIME 检测失败时作为辅助判断依据
        metadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, fileName);

        int maxLen = properties.getParser().getMaxContentLength();
        BodyContentHandler contentHandler = new BodyContentHandler(maxLen);

        // ── 步骤3:执行解析 ───────────────────────────────────────
        /*
             * AutoDetectParser.parse() 是阻塞调用,对于大 PDF 可能耗时数十秒。
             * 超时控制在上层 DocumentParserService 中通过 Future.get(timeout) 实现,
             * 此处无需重复处理超时。
             */
        // TikaInputStream tikaInputStream = TikaInputStream.get(inputStream);
        autoDetectParser.parse(inputStream, contentHandler, metadata, parseContext);

        // ── 步骤4:提取文本内容 ───────────────────────────────────
        String rawContent = contentHandler.toString();
        // ── 步骤5:文本清洗 ─────────────────────清洗第一层:确定性规则清洗──────
        String cleanedContent = TextCleanupUtils.clean(rawContent);
        // ── 步骤6:提取元数据 ─────────────────────────────────────
        Map<String, String> metadataMap = extractMetadata(metadata);
        // ── 步骤7:计算耗时并上报指标 ────────────────────────────
        Instant endTime = Instant.now();
        long durationMs = endTime.toEpochMilli() - startTime.toEpochMilli();
        .....
        // ── 步骤8:构建结果 ───────────────────────────────────────
        DocumentParseResult.ParseStatus status = DocumentParseResult.ParseStatus.SUCCESS;
        return xxxxxxxxxxx
    }
}

到这里就是Tika解析,然后初步清洗的步骤了。接下来就是chunk,再分块的步骤了:

public interface ChunkingStrategy {

    /**
     * 执行文档分块
     *
     * @param document 解析后的文档结果
     * @param options  分块选项
     * @return 分块结果(包含分块列表和统计信息)
     */
    ChunkingResult chunk(DocumentParseResult document, ChunkingOptions options);
}

@Slf4j
public abstract class AbstractChunkingStrategy implements ChunkingStrategy {
    @Override
    public ChunkingResult chunk(DocumentParseResult document, ChunkingOptions options) {
        ....
        try {
            // 执行实际分块逻辑(由子类实现)
            List<Chunk> chunks = doChunk(content, docId, docName, options);
            // 后处理:链接相邻分块、计算质量分、过滤等
            chunks = postProcessChunks(chunks, content, options);
            ...
            return result;
        } catch (Exception e) {
            log.error("[{}] 分块失败: document={}", getStrategyName(), docName, e);
            return ChunkingResult.failed(docId, docName, e.getMessage());
        }
    }
}

由模板方法的设计模式来实现,具体的分块策略在子类里面完成。postProcessChunks方法就相当于再清洗步骤了。

然后调试这个接口就可以看到效果:

@RequestMapping("/v1/chunk")
@RequiredArgsConstructor
public class ChunkController {

    private final ChunkingService chunkingService;
    private final DataSourceIngestionService ingestionService;
    
    /**
     * 上传一个文件,并对文件进行分块 -- 测试方法
     */
    @PostMapping("/file")
    @Timed(value = "rag.api.chunk.file", description = "文件上传分块接口耗时")
    public R chunkFile(@RequestPart("file") MultipartFile file,
                       @RequestParam(value = "strategy", required = false) String strategy,
                       @RequestParam(value = "sourceId", required = false) String sourceId) {
        // 1. 文件解析
        DataSourceRequest request = new DataSourceRequest();
        request.setSourceType(DataSourceRequest.SourceType.FILE_UPLOAD);
        request.setFile(file);
        request.setSourceId(sourceId);
        DocumentParseResult result = ingestionService.ingest(request);
        // 2.测试分块
        ChunkingResult result1 = chunkingService.chunk(result, strategy);
        return R.ok().add("result", result1);
    }
}

2.5 向量化与存储

在构建 RAG(检索增强生成)系统时,我们经历了文档解析(Tika)、噪声清洗和分块(Chunking)。现在,我们来到了最关键的临门一脚:将处理好的文本块(Chunks)转化为向量,并持久化到向量数据库(Milvus)中。

在这一步,我们需要解决两个核心问题:

  1. Embedding(向量化):将自然语言映射到高维空间。
  2. 持久化到Milvus:将向量存储起来,在数百万个向量中实现毫秒级的近似最近邻搜索(ANN)。

2.5.1 Embedding(向量化)

计算机擅长处理数字,但不擅长直接理解图片、文字、音频的含义。向量化就是把一张图片、一段文本或一段语音,通过深度学习模型(也叫 Embedding 模型),转换成一组固定长度的浮点数列表,这个过程也叫“嵌入”(Embedding)。

比如说你有一张猫的图片。把它输入一个图像模型(比如 ResNet、CLIP 等),模型输出一个 512 维的向量,可能像这样:[0.12, -0.34, 0.56, ..., 0.78]。这个向量就是这个图片的数学表示。它在高维空间里占据一个点。如果两张图片的向量在空间中距离很近,就说明它们在语义上很相似。

有了向量之后,我们的需求就变成了:给定一个查询向量,从海量向量中快速找出与之最相似的那些向量。这就是向量检索,也叫相似性搜索。

检索的话不能直接用两个向量“等于”来评判,因为两个不同图片的向量几乎不可能完全相等。通常用距离函数来衡量:余弦相似度(常用):夹角越小越相似,范围 [-1, 1]。欧氏距离:空间直线距离,越小越相似。内积:适合某些模型。

说到这里,应该差不多能理解是啥意思了,剩下来的找的过程就是遍历一遍数据库里面的向量,然后一个个计算对比呗,嘻嘻嘻,学会暴力搜索了!!虽然结果最准,但时间复杂度 O(N),当 N 达到百万、亿级时,耗时太长,无法在生产环境使用。

这里要说的是近似最近邻检索(ANN),为了在“准”和“快”之间取得平衡,向量数据库(包括 Milvus)使用了近似最近邻搜索算法。它们不追求 100% 精确的结果,而是允许一点点误差,换来几个数量级的加速。

这些算法的核心思想是预先建立索引,把高维空间划分为若干区域,或者构建一个图结构,让搜索时只需要访问一小部分数据。

常见的索引类型:

  • IVF(倒排文件索引):把向量空间用聚类算法分成若干个“桶”。搜索时先找到距离查询向量最近的几个桶,然后只在这些桶内部进行暴力搜索。
    比喻:你要在全国找一个人,不是挨家挨户敲门,而是先确定他在哪个省,再到那个省去找。
  • HNSW(分层可导航小世界图):在向量之间构建一张多层图,每个向量是一个节点,节点之间按距离连接。搜索时从顶层开始,沿着最接近查询向量的方向“跳跃”,逐层细化,快速抵达目标区域。
    比喻:在高速公路上先走主干道(顶层),下了高速再走小路(底层),快速到达目的地。
  • DiskANN:专门为存储在 SSD 上的海量数据设计,把索引结构部分放在磁盘,平衡内存和磁盘 I/O,支持百亿级规模的检索。

一个检索大致过程如下:以 Milvus 为例,一次向量检索的步骤是:

  1. 生成查询向量:用户输入一张图片或一段文本,使用同样的 Embedding 模型,将其转化为向量(一定要用相同的Embedding模型)。
  2. 发送搜索请求:带上这个查询向量,以及检索参数(如 Top-K = 10,即想要返回最相似的 10 条结果,以及可能需要的标量过滤条件,如“价格小于 100”)。
  3. 索引加速:Milvus 根据 Collection 上已构建的索引(比如 HNSW),快速定位到查询向量附近的候选区域。
  4. 计算距离并排序:在候选集里精确计算距离,选出距离最小的 K 个结果,返回给用户。
  5. 后处理(如有需要):用户拿到返回的实体 ID 和相似度分数后,再去关系数据库(如 MySQL)中取出完整信息(如商品详情、图片 URL 等)展示。

接下来看看示例:

// -------------------------------------------- Embedding部分 -------------------------
@Override
public EmbeddingResponse embedding(List<String> text) {
    ...
    log.info("开始调用硅基流动[embedding]...");
    // 1.构建请求
    String jsonData = buildEmbeddingBodyJson(text);
    RequestBody body = RequestBody.create(jsonData, MediaType.get("application/json"));
    Request request = new Request.Builder()
        .url(providerConfig.getBaseUrl() + EMBED_URL)
        .header("Content-Type", "application/json")
        .header("Authorization", "Bearer " + providerConfig.getApiKey())
        .post(body)
        .build();
    Call call = siliconfowClient.newCall(request);
    try {
        Response response = call.execute();
        if (!response.isSuccessful()) {
            log.error(" SilvaFlow Embedding not success: {}", response.body().string());
            return EmbeddingResponse.builder()
                .errorMsg(" SilvaFlow Embedding Error: " + response.body().string())
                .build();
        }
        return buildEmbeddingResponse(response.body().string());
    } catch (IOException e) {
        log.error(" SilvaFlow Embedding Error: {}", e.getMessage());
        throw new RuntimeException(e);
    }
}

// 测试类
@Test
public void testEmbedding() {
    EmbeddingResponse response = modelFactory.getModel(SiliconfowModel.SILICONFLOW)
        .embedding(List.of("hello world", "峻神、建神!!"));
}

调用Embedding模型生成的向量如下图所示:

【没有截到的信息】,"model":"Qwen/Qwen3-Embedding-4B","usage":{"prompt_tokens":10,"completion_tokens":0,"total_tokens":10}}

{
  "object": [
    "list"
  ],
  "model": "<string>",
  "data": [
    {
      "object": "embedding",
      "embedding": [
        123
      ],
      "index": 123
    }
  ],
  "usage": {
    "prompt_tokens": 123,
    "completion_tokens": 123,
    "total_tokens": 123
  }
}

2.5.2 持久化Milvus

这一节主要就是集成Milvus客户端了,也没有什么要特别说明的。

/*
上传文件,分块,然后向量化,存入milvus
*/
@PostMapping("/upload")
public R uploadFile(@RequestPart("file") MultipartFile file) {
    log.info("[VectorController] 上传文件: {}", file.getOriginalFilename());
    String org_id = "org_id123456";
    return milvusService.tackleFile(file, org_id);
}

向量化与持久化的过程:

// 4. 分批向量化并存储(每批 30 条)
int batchSize = 30;
int totalInserted = 0;

for (int i = 0; i < chunks.size(); i += batchSize) {
    List<Chunk> batch = chunks.subList(i, Math.min(i + batchSize, chunks.size()));
    // 4.1 获取 embedding
    List<String> texts = batch.stream()
        .map(Chunk::getContent)
        .toList();
    EmbeddingResponse embeddingRes = modelFactory
        .getModel(SiliconfowModel.SILICONFLOW).embedding(texts);
    if (embeddingRes.getData() == null || embeddingRes.getData().isEmpty()) {
        log.warn("[tackleFile] 第 {} 批向量化失败,跳过", i / batchSize);
        continue;
    }
    // 4.2 构建 Milvus 数据 (使用 JsonObject)
    List<JsonObject> dataList = new ArrayList<>();
    for (int j = 0; j < batch.size(); j++) {
        Chunk chunk = batch.get(j);
        String docId = UUID.randomUUID().toString();
        // 元数据
        JsonObject metadata = new JsonObject();
        metadata.addProperty("source", result.getFileName());
        metadata.addProperty("mimeType", result.getMimeType());
        metadata.addProperty("chunkIndex", chunk.getChunkIndex());
        metadata.addProperty("documentId", chunk.getDocumentId());
        metadata.addProperty("startOffset", chunk.getStartOffset());
        metadata.addProperty("endOffset", chunk.getEndOffset());
        // 构建向量字段
        List<Float> vector = embeddingRes.getData().get(j).getEmbedding();
        com.google.gson.JsonArray vectorArray = new com.google.gson.JsonArray();
        for (Float v : vector) {
            vectorArray.add(v);
        }

        JsonObject row = new JsonObject();
        row.addProperty(properties.getCollection().getIdField(), docId);
        row.add(properties.getCollection().getVectorField(), vectorArray);
        row.addProperty(properties.getCollection().getContentField(), chunk.getContent());
        row.add(properties.getCollection().getMetadataField(), metadata);
        row.addProperty("chunk_index", chunk.getChunkIndex());
        row.addProperty("org_id", org_id);
        dataList.add(row);
    }

    // 4.3 插入 Milvus
    InsertReq insertReq = InsertReq.builder()
        .collectionName(collectionName)
        .data(dataList)
        .build();
    milvusClient.insert(insertReq);
    totalInserted += dataList.size();
    log.info("[tackleFile] 第 {} 批插入成功,数量: {}", (i / batchSize) + 1, dataList.size());
}

测试接口结果如上图所示。

受篇幅限制,本文暂且到这里了,接下来的检索阶段见后续文章。

截止到此:【见分支dev-v2-vector

代码肯定是不完美的,仅仅是供读者学习思路。【同时感谢claude code、gemini】

posted @ 2026-03-29 13:34  别来无恙✲  阅读(19)  评论(0)    收藏  举报