Spring AI RAG 进阶:混合检索实战

0 阅读9分钟

Spring AI 实战系列 | 进阶篇 第 2 篇

企业知识库 RAG 管线:多格式 ETL + 混合检索

系列说明:本文为《Spring AI 实战系列 进阶篇》第 2 篇

前置知识:完成入门篇第 3 篇(VectorStore + RAG)和进阶篇第 1 篇(智能客服系统)

预计阅读时间:15 分钟


💻 开发环境说明

项目配置
电脑型号MagicBook Pro 14 2025,14.6 吋
CPUIntel Core Ultra 5(MTL Ultra5)
核显Intel UMA
内存24GB
硬盘1TB SSD
大模型智谱 GLM-4-Flash(云端 API)
向量数据库Chroma(本地 Docker)

📖 目录

  1. 入门篇 RAG 的局限性
  2. 企业级 RAG 管线全景
  3. 多格式 ETL:文档摄入管线
  4. 智能分块策略
  5. 混合检索:语义 + 关键词
  6. jieba 分词优化
  7. 完整项目实战

一、入门篇 RAG 的局限性

入门篇第 3 篇我们实现了一个基础的文档问答系统,能跑通 RAG 的完整流程。但放到真实企业场景里,会遇到一堆问题:

1.1 真实场景的挑战

问题入门篇的做法企业场景的现实
文档格式只支持 Markdown有 PDF、Word、Excel、HTML、代码文件
文档分块固定 Token 数切割切到一半把一段代码切断了,语义丢失
检索方式纯向量相似度搜"订单号 B-12345"这种精确词,向量检索完全找不到
关键词分词n-gram 分词噪音大,"退货流程"被切成"退货"、"货流"、"流程"

1.2 本篇要解决的问题

入门篇 RAG:
  文档 → 固定分块 → 向量存储 → 向量检索 → AI 回答

进阶篇 RAG:
  多格式文档 → 智能分块 → Chroma 向量库
                                    ↘
                                      混合检索(向量 + jieba关键词)→ AI 回答
                                    ↗
  jieba 关键词索引 ─────────────────────

二、企业级 RAG 管线全景

2.1 完整架构

┌─────────────────────────────────────────────────────────────────────┐
│                      企业知识库 RAG 管线                              │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  【摄入管线 - 离线/异步】                                             │
│                                                                     │
│  ┌──────────────────────────────────────────────────────────────┐  │
│  │  文档来源                                                      │  │
│  │  PDF ─┐                                                       │  │
│  │  HTML─┤→ DocumentReader → 语义分块 → Embedding → 双路存储      │  │
│  │  TXT ─┘                                                       │  │
│  └──────────────────────────────────────────────────────────────┘  │
│                              ↓                                      │
│  ┌──────────────────────────────────────────────────────────────┐  │
│  │  双路存储                                                      │  │
│  │  SimpleVectorStore(语义检索) + jieba 关键词索引(精确检索)   │  │
│  └──────────────────────────────────────────────────────────────┘  │
│                                                                     │
│  【查询管线 - 在线】                                                  │
│                                                                     │
│  ┌──────────────────────────────────────────────────────────────┐  │
│  │  用户问题 → 混合检索(RRF融合)→ AI 生成                        │  │
│  └──────────────────────────────────────────────────────────────┘  │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

2.2 核心组件

组件职责本文实现方式
DocumentReader解析多格式文档Spring AI 内置 Reader
SemanticChunkSplitter按段落语义分块自定义 Transformer
Chroma向量存储与语义检索本地 Docker 部署
KeywordIndex精确词匹配jieba 分词 + 内存索引
HybridSearchService融合两路检索结果RRF 算法

三、多格式 ETL:文档摄入管线

3.1 Spring AI 内置的 DocumentReader

Spring AI 提供了开箱即用的多格式读取器:

// PDF 读取(按段落分割,语义更完整)
ParagraphPdfDocumentReader pdfReader = new ParagraphPdfDocumentReader(resource);

// Markdown 读取
MarkdownDocumentReader mdReader = new MarkdownDocumentReader(resource,
    MarkdownDocumentReaderConfig.builder()
        .withHorizontalRuleCreateDocument(true)
        .withIncludeCodeBlock(true)
        .build());

// 纯文本读取
TextReader textReader = new TextReader(resource);

3.2 多格式文档加载器(核心代码)

@Component
public class MultiFormatDocumentLoader {

    public List<Document> load(Resource resource) {
        String filename = resource.getFilename();
        String ext = filename.toLowerCase();

        if (ext.endsWith(".pdf")) {
            return new ParagraphPdfDocumentReader(resource).read();
        } else if (ext.endsWith(".md")) {
            return new MarkdownDocumentReader(resource, config).read();
        } else if (ext.endsWith(".txt") || ext.endsWith(".html")) {
            return new TextReader(resource).read();
        }
        throw new UnsupportedOperationException("暂不支持的格式: " + ext);
    }
}

3.3 元数据注入

@Component
public class MetadataEnricher implements DocumentTransformer {

    @Override
    public List<Document> apply(List<Document> documents) {
        return documents.stream()
            .map(doc -> {
                Map<String, Object> meta = new HashMap<>(doc.getMetadata());
                meta.put("ingest_time", LocalDateTime.now().toString());
                meta.put("char_count", doc.getText().length());
                return new Document(doc.getText(), meta);
            })
            .toList();
    }
}

四、智能分块策略

4.1 为什么固定分块不够用?

原文:
  "退货流程如下:
   1. 联系客服提交申请
   2. 客服审核(1-3个工作日)
   3. 寄回商品"

固定 512 Token 切割后(在第 2 步中间切断):
  片段1"退货流程如下:1. 联系客服提交申请 2. 客服审核(1-3"
  片段2"个工作日)3. 寄回商品"

→ 两个片段都不完整,检索时语义丢失

4.2 语义分块器(按自然段落)

@Component
public class SemanticChunkSplitter implements DocumentTransformer {

    private static final int MAX_CHUNK_SIZE = 800;
    private static final int MIN_CHUNK_SIZE = 100;
    private static final int OVERLAP_SIZE = 80;

    @Override
    public List<Document> apply(List<Document> documents) {
        return documents.stream()
            .flatMap(doc -> split(doc).stream())
            .toList();
    }

    private List<Document> split(Document doc) {
        String text = doc.getText();
        if (text.length() <= MAX_CHUNK_SIZE) {
            return List.of(doc);
        }

        List<Document> chunks = new ArrayList<>();
        // 按自然段落分割(空行分隔)
        String[] paragraphs = text.split("\n\n+");
        StringBuilder currentChunk = new StringBuilder();
        int chunkIndex = 0;

        for (String paragraph : paragraphs) {
            if (currentChunk.length() + paragraph.length() > MAX_CHUNK_SIZE
                    && currentChunk.length() >= MIN_CHUNK_SIZE) {
                // 保存当前块
                chunks.add(createChunk(doc, currentChunk, chunkIndex++));
                // 保留最后一段作为重叠
                currentChunk = new StringBuilder();
                if (paragraph.length() <= OVERLAP_SIZE) {
                    currentChunk.append(paragraph).append("\n\n");
                }
            }
            currentChunk.append(paragraph).append("\n\n");
        }

        if (currentChunk.length() >= MIN_CHUNK_SIZE) {
            chunks.add(createChunk(doc, currentChunk, chunkIndex));
        }
        return chunks;
    }
}

4.3 分块策略选择指南

文档类型推荐策略原因
产品手册 / FAQ语义分块(按段落)每个问答是独立语义单元
技术文档Markdown Reader + 标题分割标题天然是语义边界
PDF 合同ParagraphPdfDocumentReader保留段落结构
新闻/文章TokenTextSplitter + overlap内容连续,需要重叠保留上下文

五、混合检索:语义 + 关键词

5.1 为什么需要混合检索?

用户问:"订单号 B-12345 的退货状态是什么?"

向量检索:
→ 找到"退货流程说明""退款政策"等语义相近的文档
→ 但"B-12345"这个精确订单号,向量检索找不到!

关键词检索:
→ 直接匹配"B-12345"
→ 精准命中包含该订单号的文档

5.2 混合检索实现(RRF 融合)

@Service
public class HybridSearchService {

    private final VectorStore vectorStore;
    private final KeywordIndex keywordIndex;

    public List<Document> search(String query, int topK) {
        // 1. 向量检索(语义相似)
        List<Document> vectorResults = vectorStore.similaritySearch(
            SearchRequest.builder()
                .query(query)
                .topK(topK * 2)
                .similarityThreshold(0.5)
                .build()
        );

        // 2. 关键词检索(精确匹配)
        List<Document> keywordResults = keywordIndex.search(query, topK * 2);

        // 3. RRF 融合排序
        return reciprocalRankFusion(vectorResults, keywordResults, topK);
    }

    /**
     * Reciprocal Rank Fusion(倒数排名融合)
     * score(d) = Σ 1 / (k + rank_i(d)), k = 60
     */
    private List<Document> reciprocalRankFusion(
            List<Document> vectorResults,
            List<Document> keywordResults,
            int topK) {

        final int K = 60;
        Map<String, Double> scores = new HashMap<>();
        Map<String, Document> docMap = new HashMap<>();

        // 向量检索分数
        for (int i = 0; i < vectorResults.size(); i++) {
            String id = getDocId(vectorResults.get(i));
            scores.merge(id, 1.0 / (K + i + 1), Double::sum);
            docMap.put(id, vectorResults.get(i));
        }

        // 关键词检索分数(叠加)
        for (int i = 0; i < keywordResults.size(); i++) {
            String id = getDocId(keywordResults.get(i));
            scores.merge(id, 1.0 / (K + i + 1), Double::sum);
            docMap.put(id, keywordResults.get(i));
        }

        // 按分数降序排列
        return scores.entrySet().stream()
            .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
            .limit(topK)
            .map(e -> docMap.get(e.getKey()))
            .toList();
    }
}

六、jieba 分词优化

6.1 为什么要用 jieba?

分词方案问题示例
n-gram噪音大,无意义词组多"退货流程" → "退货"、"货流"、"流程"
jieba精准分词,语义完整"退货流程" → "退货"、"流程" ✅

6.2 添加 jieba 依赖

<!-- pom.xml -->
<dependency>
    <groupId>com.huaban</groupId>
    <artifactId>jieba-analysis</artifactId>
    <version>1.0.2</version>
</dependency>

6.3 关键词索引(jieba 版)

@Component
public class KeywordIndex {

    private JiebaSegmenter segmenter;  // jieba 分词器
    private final Map<String, List<Document>> invertedIndex = new ConcurrentHashMap<>();

    @PostConstruct
    public void init() {
        // 预加载词典(首次较慢)
        this.segmenter = new JiebaSegmenter();
    }

    public void addDocuments(List<Document> documents) {
        for (Document doc : documents) {
            Set<String> keywords = extractKeywords(doc.getText());
            for (String keyword : keywords) {
                invertedIndex.computeIfAbsent(keyword, k -> new CopyOnWriteArrayList<>())
                    .add(doc);
            }
        }
    }

    public List<Document> search(String query, int topK) {
        Set<String> queryKeywords = extractKeywords(query);

        // 统计每个文档的关键词命中数
        Map<Document, Integer> hitCount = new HashMap<>();
        for (String keyword : queryKeywords) {
            for (Document doc : invertedIndex.getOrDefault(keyword, List.of())) {
                hitCount.merge(doc, 1, Integer::sum);
            }
        }

        // 按命中数降序排列
        return hitCount.entrySet().stream()
            .sorted(Map.Entry.<Document, Integer>comparingByValue().reversed())
            .limit(topK)
            .map(Map.Entry::getKey)
            .toList();
    }

    /**
     * 提取关键词:jieba INDEX 模式 + 正则提取英文/数字
     */
    public Set<String> extractKeywords(String text) {
        Set<String> keywords = new HashSet<>();

        // ① jieba INDEX 模式:同时输出长词和细粒度词
        List<SegToken> tokens = segmenter.process(text, JiebaSegmenter.SegMode.INDEX);
        for (SegToken token : tokens) {
            String word = token.word.trim();
            if (word.length() >= 2 && !STOP_WORDS.contains(word)) {
                keywords.add(word);
            }
        }

        // ② 正则提取英文单词和数字串(订单号、版本号等)
        Matcher matcher = ALPHANUMERIC_PATTERN.matcher(text);
        while (matcher.find()) {
            String word = matcher.group().toLowerCase();
            if (word.length() >= 2 && !STOP_WORDS.contains(word)) {
                keywords.add(word);
            }
        }

        return keywords;
    }

    // 中英文停用词表
    private static final Set<String> STOP_WORDS = Set.of(
        "的", "了", "是", "在", "我", "有", "和", "就", "不", "人",
        "都", "一", "一个", "这个", "那个", "可以", "什么", "怎么",
        "the", "a", "an", "is", "are", "was", "were", "be", "been",
        "to", "of", "in", "for", "on", "with", "at", "by", "from"
    );
}

6.4 分词效果对比

原文"退货流程通常需要3-7个工作日完成"

方案提取的关键词
n-gram退货, 货流, 流程, 程通, 通常, 常需... (噪音多)
jieba退货, 流程, 通常, 需要, 3-7, 工作日, 完成 ✅

七、完整项目实战

7.1 项目结构

enterprise-rag/
├── pom.xml
└── src/main/
    ├── java/com/example/rag/
    │   ├── EnterpriseRagApplication.java
    │   ├── controller/KnowledgeBaseController.java
    │   ├── service/
    │   │   ├── DocumentIngestionService.java
    │   │   ├── HybridSearchService.java
    │   │   └── RagAnswerService.java
    │   ├── component/
    │   │   ├── MultiFormatDocumentLoader.java
    │   │   ├── SemanticChunkSplitter.java
    │   │   ├── MetadataEnricher.java
    │   │   └── KeywordIndex.java
    │   └── config/RagConfig.java
    └── resources/
        ├── application.properties
        └── docs/
            ├── product-faq.md
            └── return-policy.md

7.2 依赖配置 pom.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
         https://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.0</version>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>enterprise-rag</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <properties>
        <java.version>21</java.version>
        <spring-ai.version>1.1.3</spring-ai.version>
    </properties>

    <repositories>
        <repository>
            <id>spring-milestones</id>
            <url>https://repo.spring.io/milestone</url>
        </repository>
    </repositories>

    <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>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <!-- Spring Boot Web -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- Spring AI 智谱 GLM(Chat + Embedding) -->
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-starter-model-zhipuai</artifactId>
        </dependency>

        <!-- 向量数据库:Chroma -->
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-starter-vector-store-chroma</artifactId>
        </dependency>

        <!-- 文档读取:PDF、Markdown -->
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-pdf-document-reader</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-markdown-document-reader</artifactId>
        </dependency>

        <!-- jieba 分词 -->
        <dependency>
            <groupId>com.huaban</groupId>
            <artifactId>jieba-analysis</artifactId>
            <version>1.0.2</version>
        </dependency>

        <!-- Lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>

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

7.3 配置文件

# ===== 智谱 GLM-4-Flash =====
spring.ai.zhipuai.api-key=your-api-key-here
spring.ai.zhipuai.chat.options.model=glm-4-flash

# ===== Chroma 向量数据库 =====
spring.ai.vectorstore.chroma.client.host=http://localhost
spring.ai.vectorstore.chroma.client.port=8000
spring.ai.vectorstore.chroma.collection-name=enterprise-kb
spring.ai.vectorstore.chroma.initialize-schema=true

# ===== 服务端口 =====
server.port=8080

# ===== 日志 =====
logging.level.com.example.rag=DEBUG

7.4 RAG 问答服务(核心代码)

@Service
public class RagAnswerService {

    private final ChatClient chatClient;
    private final HybridSearchService hybridSearch;

    public RagAnswerService(ChatClient.Builder builder, HybridSearchService hybridSearch) {
        this.hybridSearch = hybridSearch;
        this.chatClient = builder
            .defaultSystem("""
                你是企业知识库助手。
                请严格基于提供的上下文内容来回答问题。
                如果上下文中没有相关信息,请明确说"知识库中暂无相关信息"。
                """)
            .build();
    }

    public AnswerResult answer(String question) {
        // 1. 混合检索
        List<Document> docs = hybridSearch.search(question, 4);

        if (docs.isEmpty()) {
            return new AnswerResult(question,
                "知识库中暂无相关信息,请联系人工客服。", List.of(), 0);
        }

        // 2. 构建上下文
        String context = docs.stream()
            .map(doc -> String.format("【%s】\n%s",
                doc.getMetadata().getOrDefault("source", "未知"),
                doc.getText()))
            .collect(Collectors.joining("\n\n---\n\n"));

        // 3. 调用 AI
        String answer = chatClient.prompt()
            .user(String.format("根据以下内容回答问题:\n%s\n\n问题:%s", context, question))
            .call()
            .content();

        List<String> sources = docs.stream()
            .map(doc -> (String) doc.getMetadata().getOrDefault("source", "未知"))
            .distinct()
            .toList();

        return new AnswerResult(question, answer, sources, elapsed);
    }

    public record AnswerResult(String question, String answer, List<String> sources, long elapsedMs) {}
}

7.5 REST API 控制器

@RestController
@RequestMapping("/kb")
public class KnowledgeBaseController {

    private final DocumentIngestionService ingestionService;
    private final RagAnswerService answerService;

    public KnowledgeBaseController(DocumentIngestionService ingestionService,
                                   RagAnswerService answerService) {
        this.ingestionService = ingestionService;
        this.answerService = answerService;
    }

    /** 问答接口 GET /kb/ask?q=退货需要几天? */
    @GetMapping("/ask")
    public RagAnswerService.AnswerResult ask(@RequestParam("q") String question) {
        return answerService.answer(question);
    }

    /** 上传并摄入文档 POST /kb/ingest */
    @PostMapping("/ingest")
    public DocumentIngestionService.IngestionResult ingest(@RequestParam("file") MultipartFile file) {
        Resource resource = new ByteArrayResource(file.getBytes()) {
            @Override public String getFilename() { return file.getOriginalFilename(); }
        };
        return ingestionService.ingest(resource);
    }

    /** 初始化示例文档 POST /kb/init */
    @PostMapping("/init")
    public List<DocumentIngestionService.IngestionResult> init() {
        return ingestionService.ingestDirectory("classpath:docs");
    }

    /** 统计信息 GET /kb/stats */
    @GetMapping("/stats")
    public Map<String, Object> stats() {
        return Map.of(
            "vectorStore", vectorStore.size(),
            "keywordIndex", keywordIndex.getStats()
        );
    }

    /** 清空索引 DELETE /kb/clear */
    @DeleteMapping("/clear")
    public void clear() {
        keywordIndex.clear();
        vectorStore.clear();
    }
}

7.6 启动测试

# 1. 启动 Chroma 向量数据库(Docker)
docker run -d --name chroma -p 8000:8000 chromadb/chroma:latest

# 2. 配置智谱 API Key(或直接写入 application.properties)
export ZHIPUAI_API_KEY=your-api-key

# 3. 启动应用
mvn spring-boot:run

# 4. 初始化知识库
curl -X POST http://localhost:8080/kb/init

# 5. 提问测试
curl "http://localhost:8080/kb/ask?q=退货需要多少天?"

# 6. 上传新文档
curl -X POST http://localhost:8080/kb/ingest -F "file=@document.pdf"

7.7 效果展示

RAG1.jpg

RAG2.jpg

总结

本文实现了一套完整的企业级 RAG 管线,核心改进点:

改进项原方案新方案效果
大模型本地 Ollama智谱 GLM-4-Flash云端 API,无需本地部署
向量存储内存Chroma持久化存储,支持大规模数据
关键词分词n-gramjieba分词精准,噪音减少
文档分块固定 Token语义分块保留段落完整性
检索方式纯向量混合检索(RRF)兼顾语义 + 精确匹配

关注公众号「AI日撰」,点击菜单「获取源码」获取完整代码(Gitee 仓库)。