ACP大模型应用开发工程师-通过构建RAG应用程序

我是一名从 Java 后端转型 AI 应用开发的工程师。这篇文章是我把 ACP 课程里 RAG 章节吃透后,按「可复习、可落地、可扩展」整理出来的技术分享稿。
本文全部用 Java + Spring AI + Spring AI Alibaba 来实现。


一、我为什么要学 RAG(Retrieval Augmented Generation)

我在做企业 AI 应用时,最先遇到的现实问题不是“模型不聪明”,而是“模型不知道我公司的内部知识”。
比如制度文档、流程规范、项目手册,模型预训练里并没有。

所以我理解 RAG 的核心价值是:

  • 不是让模型“背更多知识”,而是让它在回答前“先查资料再作答”;
  • 这本质上是 上下文工程(Context Engineering)
  • 目标是把“最相关、最可信、最精简”的上下文喂给模型。

术语白话解释(先看这个再往下读)

  • RAG(检索增强生成):先“查资料”,再“按资料回答”。
  • 上下文(Context):当前这次问答里,模型能看到的信息。
  • 上下文工程(Context Engineering):不是乱塞信息,而是挑“刚刚好、有用”的信息给模型。
  • Embedding(向量化):把一句话变成一串数字,方便机器比较“语义像不像”。
  • Vector Store(向量库):专门存这些“数字向量”的数据库,支持快速相似检索。
  • Chunk(切块):把长文拆成小段,便于召回。
  • Overlap(重叠):相邻切块保留一部分重复内容,避免语义被硬切断。
  • TopK:从候选结果里取前 K 条最相关内容。
  • Cross-Encoder(交叉编码器):把“问题+文档”一起判断相关度,比只看向量距离更准,但更耗时。
  • 重排(Rerank):对初次召回结果再做一次更精细排序,提升答案质量。

二、RAG 工作流(我自己的工程化理解)

我把 RAG 分成两个阶段:索引构建检索生成

2.1 索引构建(离线/准实时)

  1. 文档解析:PDF/Word/Markdown/HTML -> Document
  2. 文本切块:长文本切成 chunks(支持 overlap)
  3. 向量化:chunk -> embedding vector
  4. 索引落库:写入 Vector Store(Milvus/PGVector/Elasticsearch 等)

白话理解:索引构建就像“提前做目录和标签”,问问题时就不用每次翻整本书。

2.2 检索生成(在线)

  1. 用户问题入参
  2. 多路召回(向量 + 关键词)
  3. Cross-Encoder 二阶段重排
  4. 组装 Prompt(问题 + TopK 证据)
  5. 大模型生成答案(可附 citation)

白话理解:在线阶段就是“临场开卷考试”。
先找资料(检索),再组织语言回答(生成)。


三、文本切块:我在 Spring AI 里的实战策略

你提到的点非常关键:
Spring AI 内置 4 种标准切块策略,且都支持 overlap 思路(通过参数控制分块重叠)。

我在项目中通常这样选:

  • 结构化文档(制度、规范)优先:按标题/段落边界切
  • 非结构化长文:TokenTextSplitter(按 token)
  • FAQ/短段知识:较小 chunk + 适度 overlap
  • 代码/配置类文档:按语义段落切,减少跨段噪声

经验参数(可作为起点):

  • chunkSize: 500~1000 tokens
  • overlap: 50~150 tokens
  • topK: 8~20(看语料质量)

白话理解参数:

  • chunkSize 太小:信息碎片化,回答容易断;
  • chunkSize 太大:噪声多,检索不准;
  • overlap 太小:上下文断裂;太大:重复内容过多、成本上升。

四、Java 版 RAG 的实现

下面我给出可直接迁移的 Java 版本示例(Spring Boot 风格)。

4.1 Maven 依赖(Spring AI + Alibaba)

<dependencies>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-openai</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-starter-vector-store-milvus</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
</dependencies>

4.1.1 文档解析:我认为更成熟的依赖组合

只靠单一解析器在企业场景里通常不够。我现在更常用的是 Tika + 格式专用库 + OCR 兜底 的分层方案:

  • Apache Tika:统一入口,覆盖 PDF/Word/Excel/PPT/HTML/RTF 等;
  • PDFBox:处理复杂 PDF(页码、版面、加密、抽图);
  • Apache POI:Office 文档的细粒度解析(表格、批注、sheet);
  • Tess4J/Tesseract(可选):扫描件、图片型 PDF 的 OCR;
  • JSoup(可选):HTML 清洗,去脚本和噪声标签;
  • Jackson/JSONPath(可选):结构化文档(JSON/JSONL)解析。

对应依赖可以这样加(按需启用,不必一次全上):

<dependencies>
    <!-- Spring AI 文档读取基础 -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-core</artifactId>
    </dependency>

    <!-- 通用文档解析 -->
    <dependency>
        <groupId>org.apache.tika</groupId>
        <artifactId>tika-core</artifactId>
    </dependency>
    <dependency>
        <groupId>org.apache.tika</groupId>
        <artifactId>tika-parsers-standard-package</artifactId>
    </dependency>

    <!-- PDF 深度处理 -->
    <dependency>
        <groupId>org.apache.pdfbox</groupId>
        <artifactId>pdfbox</artifactId>
    </dependency>

    <!-- Office 深度处理 -->
    <dependency>
        <groupId>org.apache.poi</groupId>
        <artifactId>poi-ooxml</artifactId>
    </dependency>

    <!-- HTML 清洗(可选) -->
    <dependency>
        <groupId>org.jsoup</groupId>
        <artifactId>jsoup</artifactId>
    </dependency>
</dependencies>

简单选型建议:

  • 刚起步:Tika + PDFBox 足够;
  • Office 文档很多:再加 POI
  • 扫描件很多:再加 OCR
  • 网页知识多:再加 JSoup

4.2 配置(application.yml)

spring:
  ai:
    model:
      chat: openai
      embedding: dashscope
    openai:
      api-key: ${OPENAI_API_KEY:}
      base-url: ${OPENAI_BASE_URL:https://dashscope.aliyuncs.com/compatible-mode}
      chat:
        options:
          model: qwen-plus
    dashscope:
      api-key: ${DASHSCOPE_API_KEY:}
      embedding:
        enabled: true
        options:
          model: text-embedding-v3

4.3 建索引( from_documents

@Service
public class RagIndexService {

    private final VectorStore vectorStore;
    private final DocumentReader documentReader;

    public RagIndexService(VectorStore vectorStore) {
        this.vectorStore = vectorStore;
        this.documentReader = new TikaDocumentReader(new FileSystemResource("docs/company_policy.pdf"));
    }

    public void indexing() {
        // 解析后的原始文档集合(通常一篇长文档也可能被 reader 拆成多个 Document)
        List<Document> docs = documentReader.get();

        // 1) 切块(示例:TokenTextSplitter)
        // chunkSize: 目标分块大小(越大上下文越完整,但召回噪声可能增加)
        // minChunkSizeChars: 最小字符阈值,避免碎片块过小
        // minChunkLengthToEmbed: 过短文本不做向量化,降低无效向量
        // maxNumChunks: 单文档最大切块上限,防止异常文档导致切块失控
        // keepSeparator: 保留分隔符(如换行/段落边界),提升语义连续性
        TokenTextSplitter splitter = TokenTextSplitter.builder()
                .withChunkSize(800)
                .withMinChunkSizeChars(200)
                .withMinChunkLengthToEmbed(10)
                .withMaxNumChunks(10_000)
                .withKeepSeparator(true)
                .build();

        // 将原始 docs 按切块规则展开为可向量化的小片段
        List<Document> chunks = splitter.apply(docs);

        // 2) 写入向量库(内部会调用 EmbeddingModel 把 chunk 文本转向量后存储)
        vectorStore.add(chunks);
    }
}

说明:实际项目里我会做多文档目录扫描(PDF/MD/DOCX),并在 metadata 里打上 source/file/page/title,后续做引用和过滤会非常有用。

4.3.1 文档解析增强版(多格式 + 元数据 + OCR 兜底)

下面这个示例是我在生产里常用的“可扩展解析管线”思路:先按扩展名走专用解析器,失败再回退 Tika,最后可接 OCR。

@Service
public class EnterpriseDocumentParseService {

    public List<Document> parse(Path path) {
        String name = path.getFileName().toString().toLowerCase();
        if (name.endsWith(".pdf")) {
            return parsePdf(path);
        }
        if (name.endsWith(".docx") || name.endsWith(".doc") || name.endsWith(".xlsx") || name.endsWith(".pptx")) {
            return parseOfficeByTika(path);
        }
        if (name.endsWith(".md") || name.endsWith(".txt")) {
            return List.of(new Document(readUtf8(path), Map.of("source", path.toString(), "type", "text")));
        }
        if (name.endsWith(".html") || name.endsWith(".htm")) {
            String clean = org.jsoup.Jsoup.parse(readUtf8(path)).text();
            return List.of(new Document(clean, Map.of("source", path.toString(), "type", "html")));
        }
        return parseByTikaFallback(path);
    }

    private List<Document> parsePdf(Path path) {
        try (PDDocument pdf = PDDocument.load(path.toFile())) {
            PDFTextStripper stripper = new PDFTextStripper();
            List<Document> out = new ArrayList<>();
            for (int page = 1; page <= pdf.getNumberOfPages(); page++) {
                stripper.setStartPage(page);
                stripper.setEndPage(page);
                String text = stripper.getText(pdf);
                if (text == null || text.isBlank()) {
                    // 这里可以接 OCR:把该页渲染成图片后识别
                    text = "[OCR占位] 当前页无可提取文本,可接入 Tess4J/云OCR";
                }
                out.add(new Document(text, Map.of(
                        "source", path.toString(),
                        "page", String.valueOf(page),
                        "type", "pdf"
                )));
            }
            return out;
        } catch (Exception e) {
            return parseByTikaFallback(path);
        }
    }

    private List<Document> parseOfficeByTika(Path path) {
        return parseByTikaFallback(path);
    }

    private List<Document> parseByTikaFallback(Path path) {
        Resource resource = new FileSystemResource(path);
        TikaDocumentReader reader = new TikaDocumentReader(resource);
        return reader.get().stream()
                .map(doc -> new Document(doc.getText(), merge(doc.getMetadata(),
                        Map.of("source", path.toString(), "type", "tika-fallback"))))
                .toList();
    }

    private static Map<String, Object> merge(Map<String, Object> a, Map<String, Object> b) {
        Map<String, Object> m = new HashMap<>(a == null ? Map.of() : a);
        m.putAll(b);
        return m;
    }

    private static String readUtf8(Path path) {
        try {
            return Files.readString(path, StandardCharsets.UTF_8);
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }
}

我通常会把这个解析服务接到 RagIndexService#indexing() 之前,再统一走 split + embedding + vectorStore。

4.3.2 结合宝妈 AI 平台源码:建立索引四步完整落地

下面这段是把你给的教学四步,映射到我们项目里的真实实现(module-rag):

  1. 文档解析:RagIngestApplicationService#ingestBinary -> KnowledgeDocumentParsePort#parse(...)
  2. 文本分段:TextChunker.chunk(...)(两阶段:父子切块 + 递归切块)
  3. 文本向量化:vectorStore.add(toEmbed)(Spring AI 在内部调用 embedding 模型)
  4. 存储索引:向量写 Milvus + 对账数据写 MySQL(rag_document / rag_chunk,并维护 embedding_status

对应核心代码示例如下(节选自当前项目):

@Service
public class RagIngestApplicationService {

    @Transactional(rollbackFor = Exception.class)
    public long ingestBinary(byte[] bytes, String filename, RagIngestTextCommand metadata) {
        // 1) 文档解析(pdf/docx/xlsx/pptx/html/md/json 等走统一 parse 路由)
        ParsedKnowledgeDocument parsed = knowledgeDocumentParsePort.parse(
                bytes, filename, metadata.sourceUrl(), metadata.category());

        RagDocumentDO doc = new RagDocumentDO();
        doc.setTitle(metadata.title());
        doc.setSourceType("binary");
        ragDocumentMapper.insert(doc);

        List<Document> toEmbed = new ArrayList<>();
        int idx = 0;

        for (ParsedKnowledgeSegment segment : parsed.segments()) {
            if (segment == null || segment.text() == null || segment.text().isBlank()) continue;

            // 2) 文本分段(项目内的两阶段分块)
            List<String> pieces = TextChunker.chunk(
                    segment.text(),
                    ragProperties.getChunkSize(),
                    ragProperties.getChunkOverlap());

            for (String piece : pieces) {
                RagChunkDO row = new RagChunkDO();
                row.setDocumentId(doc.getId());
                row.setChunkIndex(idx++);
                row.setContentText(piece);
                row.setEmbeddingStatus("PENDING");
                ragChunkMapper.insert(row); // 对账表先落库

                Map<String, Object> meta = new HashMap<>();
                meta.put("chunkId", row.getId());
                meta.put("documentId", doc.getId());
                meta.put("file", filename);
                toEmbed.add(Document.builder().text(piece).metadata(meta).build());
            }
        }

        // 3) 文本向量化 + 4) 存储索引(VectorStore -> Milvus)
        if (!toEmbed.isEmpty()) {
            vectorStore.add(toEmbed);
            for (Document d : toEmbed) {
                Object cid = d.getMetadata().get("chunkId");
                if (cid instanceof Number n) {
                    ragChunkMapper.updateEmbeddingDone(n.longValue()); // PENDING -> EMBEDDED
                }
            }
        }
        return doc.getId();
    }
}

分段器 TextChunker 也不是简单 substring,而是更适合知识库的“先结构后 token”策略:

public final class TextChunker {
    public static List<String> chunk(String text, int chunkSize, int overlap) {
        List<String> paragraphBlocks = splitByParagraphOrHeading(text); // 标题/段落边界优先
        TokenTextSplitter splitter = TokenTextSplitter.builder()
                .withChunkSize(chunkSize)
                .withMinChunkSizeChars(Math.max(50, chunkSize - overlap))
                .withKeepSeparator(true)
                .build();
        // 超长段再二次切分,避免语义断裂和超长 chunk
        // ... 省略拼接逻辑
    }
}

4.4 查询问答

@Service
public class RagQueryService {

    private final ChatClient chatClient;
    private final VectorStore vectorStore;

    public RagQueryService(ChatClient.Builder chatClientBuilder, VectorStore vectorStore) {
        this.chatClient = chatClientBuilder.build();
        this.vectorStore = vectorStore;
    }

    public String ask(String question) {
        // 1) 检索
        SearchRequest request = SearchRequest.builder()
                .query(question)
                .topK(10)
                .similarityThreshold(0.65)
                .build();
        List<Document> recalled = vectorStore.similaritySearch(request);

        // 2) 组装上下文
        String context = recalled.stream()
                .map(Document::getText)
                .reduce((a, b) -> a + "\n\n---\n\n" + b)
                .orElse("无可用知识片段");

        // 3) 生成
        String prompt = """
                请仅基于以下知识片段回答问题;如果知识不足,请明确说“知识库未覆盖”。

                【知识片段】
                %s

                【用户问题】
                %s
                """.formatted(context, question);

        return chatClient.prompt().user(prompt).call().content();
    }
}

五、索引持久化:我在 Java 里的做法

Java 里我更建议直接持久化到真正的向量库(Milvus/PGVector),这样天然具备可重启、可扩容、可多实例访问能力。

如果一定要做本地缓存,我会:

  • 原文切块结果落本地 JSONL(可复算)
  • 向量索引落 Milvus(在线查询)
  • 增量更新基于 doc_hash 做幂等写入

术语补充:

  • 幂等:同一份文档重复导入多次,结果不应该重复脏写。
  • doc_hash:文档指纹(内容摘要),常用于判断“内容是否变化”。

六、RAG 多轮对话:我如何做“问题改写”

课程里提到 CondenseQuestionChatEngine,这个思想非常对:
第二轮问“他的主管是谁?”时,必须改写成“张三的主管是谁?”再检索。

Java 里我会拆成两个链路:

  1. Rewrite Chain:历史 + 当前问题 -> 独立问题
  2. RAG Chain:独立问题 -> 检索 -> 回答

白话理解:先把“他说的这个/那个”改写成完整句,再去检索,准确率会明显提升。

@Service
public class QuestionRewriteService {

    private final ChatClient chatClient;

    public QuestionRewriteService(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    public String rewrite(List<String> history, String currentQuestion) {
        String historyText = String.join("\n", history);
        String prompt = """
                你是检索优化助手。请将后续问题改写为独立问题,保留必要上下文。

                对话历史:
                %s

                后续问题:
                %s

                只输出改写后的问题,不要解释。
                """.formatted(historyText, currentQuestion);

        return chatClient.prompt().user(prompt).call().content();
    }
}

七、Cross-Encoder 重排

你提到“向量 + 关键词多路召回后,用 Cross-Encoder 做二阶段重排”,这是成熟方案,我非常认同。

我在工程里的标准流程:

  1. 向量召回 topN(比如 30)
  2. 关键词召回 topN(比如 30)
  3. 去重合并
  4. Cross-Encoder 对 (query, doc) 打分
  5. 取 topK(比如 8)进入最终 Prompt

白话理解:第一轮召回像“海选”,第二轮重排像“复试”。
海选保证不漏,复试保证更准。

7.1 重排接口设计(Java)

public interface RerankService {
    List<DocumentScore> rerank(String query, List<Document> candidates, int topK);

    record DocumentScore(Document document, double score) {}
}

7.2 一个可落地的实现建议

  • 若你用阿里云生态,可优先看是否有可直接调用的 rerank 能力;
  • 若暂时没有,先用“轻量打分模型 + 规则融合”过渡;
  • 最终目标是独立 Cross-Encoder 服务化,便于 A/B。

八、我会怎么做一套“更成熟”的 Java RAG 架构

如果从“课程 Demo”升级到“生产可用”,我会加这 7 件事:

  1. 多路召回:Vector + BM25 + Metadata Filter
  2. 二阶段重排:Cross-Encoder
  3. 引用溯源:答案携带 source/page
  4. 查询改写:多轮上下文消歧
  5. 缓存层:Query Cache + Embedding Cache
  6. 可观测性:trace 每一步耗时与得分
  7. 安全治理:敏感词、越权文档过滤、提示注入防护

这些词到底在做什么(极简版)

  • Metadata Filter:先按部门/权限/时间筛一遍,再做语义检索。
  • Citation(引用溯源):回答后附“证据来自哪一页哪一段”。
  • Faithfulness(忠实度):回答是否真的基于检索证据,而不是模型“编”出来。
  • Prompt 注入:用户故意诱导模型忽略规则(例如“忽略以上所有限制”)。
  • 可观测性(Observability):能看到每一步耗时、命中率、失败点,便于定位问题。

九、给同样是 Java 转型 AI 的我自己的复盘清单

每次做 RAG,我都按这张清单复习:

  • 文档是否高质量解析(结构没丢)
  • 切块参数是否按语料调优(不是固定模板)
  • 召回是否做了多路 + 重排
  • Prompt 是否明确“仅基于证据回答”
  • 多轮是否做问题改写
  • 输出是否可追溯(引用来源)
  • 是否有离线评测集而不是只看主观效果

十、总结:我从 ACP 学到的真正关键

对我这个 Java 工程师来说,ACP 的价值不只是“跑通一个 RAG Demo”,而是建立一套方法论:

  • RAG 是上下文工程,不是“万能外挂”;
  • 检索质量决定上限,生成只是放大器;
  • 生产级 RAG 一定是“召回 + 重排 + 约束生成 + 可观测”。

如果你和我一样在做 Java 向 AI 应用开发转型,我建议先把这篇里的 Java 骨架跑通,再逐步加重排、评测和治理。

这样你会从“会调模型”走向“能交付 AI 系统”。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容