基于Spring AI的业务数据NER增强混合检索RAG系统


目录


一、总体架构思路

在构建 RAG(Retrieval-Augmented Generation,检索增强生成)系统时,大模型与业务数据的集成通常遵循"向量化 → 检索 → 生成"的三阶段流程。本文基于实际生产项目(学术文献智能检索系统)的实践经验,详细阐述各阶段的核心技术选型与实现策略。

1.1 整体数据流架构

用户输入检索问题
NER实体识别与意图理解
GLM-4-Flash
提取实体与意图
查询向量化
GTE-multilingual-base
768维向量
构建混合检索查询
精确匹配
terms查询
向量检索
kNN查询
Elasticsearch混合查询
检索结果
Top-K文档
构建RAG Prompt
System+User+检索结果
LLM流式生成
GLM-4-Flash
SSE输出
流式返回答案
前端实时展示
打字机效果
保存历史记录
MySQL qa_history
用户看到答案

架构说明:

  1. Elasticsearch 双索引设计

    • 原始索引cqu_dev_journal_thesis_v1):存储原始论文数据,只读,作为向量化的数据源
    • 向量索引cqu_dev_journal_thesis_vector_v1):包含原始数据 + 三个向量字段,用于检索
  2. MySQL 数据库

    • 与 Elasticsearch 向量数据无直接关系
    • 仅用于存储问答历史记录(qa_history 表)
    • 支持历史上下文查询,用于 NER 的指代消解
  3. 数据流转过程

    • 向量化阶段:原始索引 → 向量化服务 → 向量索引
    • 检索阶段:用户查询 → NER + 向量化 → 混合检索 → Top-K 文档
    • 生成阶段:检索结果 + 用户问题 → LLM → 流式答案

1.2 核心技术栈

层级 技术选型 说明
AI 框架 Spring AI 1.0.0 Spring 官方的 AI 集成框架
大语言模型 智谱 AI GLM-4-Flash NER + 对话生成(支持 Function Calling)
Embedding 模型 gte-multilingual-base 本地部署,768 维向量,支持中英文
向量数据库 Elasticsearch 8.x 同时支持向量检索(kNN)和全文检索(BM25)
关系数据库 MySQL 8.0 + MyBatis Plus 存储问答历史和元数据
开发框架 Spring Boot 3.5.7 + Java 17 现代化的 Spring 生态

1.3 索引设计详解

原始索引(source-index)

索引名称: cqu_dev_journal_thesis_v1

用途: 只读数据源,存储原始论文数据

字段结构:

{
  "sid": "唯一标识",
  "title": "论文标题",
  "author": ["作者1", "作者2"],
  "journalName": "期刊名称",
  "year": 2024,
  "doi": "10.xxxx/xxxx",
  "issn": "1000-9825",
  "topic": ["主题词1", "主题词2"],
  ...
}
向量索引(vector-index)

索引名称: cqu_dev_journal_thesis_vector_v1

用途: 包含原始数据 + 向量字段,用于混合检索

映射定义:

{
  "mappings": {
    "properties": {
      "sid": { "type": "keyword" },
      "title": { "type": "text", "analyzer": "ik_max_word" },
      "author": { "type": "keyword" },
      "journalName": { "type": "text", "analyzer": "ik_smart" },
      "year": { "type": "integer" },
      "doi": { "type": "keyword" },
      "issn": { "type": "keyword" },
      "topic": { "type": "keyword" },

      "titleVector": {
        "type": "dense_vector",
        "dims": 768,
        "index": true,
        "similarity": "cosine"
      },
      "authorVector": {
        "type": "dense_vector",
        "dims": 768,
        "index": true,
        "similarity": "cosine"
      },
      "journalNameVector": {
        "type": "dense_vector",
        "dims": 768,
        "index": true,
        "similarity": "cosine"
      }
    }
  }
}

向量字段说明:

  • titleVector:标题文本的向量表示(权重最高)
  • authorVector:作者姓名的向量表示(用于作者检索)
  • journalNameVector:期刊名称的向量表示(用于期刊筛选)

二、数据准备阶段:向量化工程

2.1 为什么需要多字段向量化?

对于结构化业务数据(如学术论文、商品信息、用户档案),传统的"全文拼接向量化"存在以下问题:

问题 1:信息污染

  • 将所有字段拼接成一段文本(如 title + author + journal)会导致不同字段的语义相互干扰
  • 例如:“深度学习 张三 计算机学报” 无法区分哪个是标题、哪个是作者

问题 2:检索精度低

  • 用户查询"张三的论文"时,希望作者字段权重更高
  • 用户查询"深度学习相关论文"时,希望标题字段权重更高
  • 单一向量无法实现不同字段的差异化权重

解决方案:多字段向量化 + 加权检索

为核心字段(如标题、作者、期刊名称)分别生成向量,检索时根据查询类型动态调整权重。

2.2 向量化流程设计

配置信息(application.yml):

app:
  elasticsearch:
    # 原始索引(只读,用于读取现有数据)
    source-index: cqu_dev_journal_thesis_v1
    # 向量索引(包含向量数据)
    vector-index: cqu_dev_journal_thesis_vector_v1
    # 向量维度(gte-multilingual-base 模型)
    vector-dimension: 768
    # 批量处理大小
    batch-size: 100
    # 本地 Embedding 服务 URL
    embedding-service-url: http://192.168.0.168:5000

向量化流程:

1. 从原始索引读取一批数据(batch-size=100)
   ↓
2. 提取三个字段的文本
   - titleTexts: ["论文标题1", "论文标题2", ...]
   - authorTexts: ["张三, 李四", "王五", ...]
   - journalTexts: ["计算机学报", "软件学报", ...]
   ↓
3. 批量调用 Embedding 服务(并行处理)
   - embedTexts(titleTexts) → titleVectors
   - embedTexts(authorTexts) → authorVectors
   - embedTexts(journalTexts) → journalVectors
   ↓
4. 将向量赋值到文档对象
   doc.setTitleVector(titleVectors[i])
   doc.setAuthorVector(authorVectors[i])
   doc.setJournalNameVector(journalVectors[i])
   ↓
5. 批量写入向量索引(Bulk API)
   ↓
6. 继续下一批数据(支持断点续传)

2.3 核心实现代码

文件位置: DocumentVectorizationService.java:50-214

/**
 * 批量向量化论文数据
 * 从原始索引读取数据,为标题、作者、期刊三个字段分别生成向量,写入向量索引
 */
public VectorizationResult vectorizeDocuments(int startIndex, int size) {
    String sourceIndex = appConfig.getSourceIndex();        // cqu_dev_journal_thesis_v1
    String vectorIndex = appConfig.getVectorIndex();        // cqu_dev_journal_thesis_vector_v1
    int batchSize = appConfig.getBatchSize();               // 100

    log.info("[批量向量化] 开始,原始索引: {}, 向量索引: {}, 起始位置: {}, 数量: {}, 批次大小: {}",
            sourceIndex, vectorIndex, startIndex, size, batchSize);

    VectorizationResult result = new VectorizationResult();
    int processedCount = 0;
    int successCount = 0;

    try {
        int currentFrom = startIndex;
        boolean hasMore = true;

        while (hasMore) {
            // 计算本批次读取数量
            final int currentSize;
            if (size > 0) {
                int remaining = size - processedCount;
                if (remaining <= 0) break;
                currentSize = Math.min(batchSize, remaining);
            } else {
                currentSize = batchSize;
            }

            // 1. 从原始索引读取一批数据
            log.info("[读取数据] 第 {} 批,从位置 {} 开始,数量 {}",
                    processedCount / batchSize + 1, currentFrom, currentSize);

            SearchResponse<JournalThesis> searchResponse = elasticsearchClient.search(s -> s
                .index(sourceIndex)
                .query(q -> q.matchAll(m -> m))
                .from(currentFrom)
                .size(currentSize)
                .source(src -> src.fetch(true)),
                JournalThesis.class
            );

            List<Hit<JournalThesis>> hits = searchResponse.hits().hits();
            if (hits.isEmpty()) {
                log.info("[读取数据] 没有更多数据");
                break;
            }

            log.info("[读取数据] 成功读取 {} 条论文数据", hits.size());

            // 2. 准备三个字段的文本列表
            List<String> titleTexts = new ArrayList<>();
            List<String> authorTexts = new ArrayList<>();
            List<String> journalNameTexts = new ArrayList<>();
            List<JournalThesisVector> vectorDocuments = new ArrayList<>();

            for (Hit<JournalThesis> hit : hits) {
                try {
                    JournalThesis thesis = hit.source();
                    if (thesis == null) {
                        log.warn("[数据解析] 文档 {} 数据为空,跳过", hit.id());
                        continue;
                    }

                    // 转换为向量文档对象
                    JournalThesisVector vectorDoc = JournalThesisVector.fromJournalThesis(thesis);
                    vectorDocuments.add(vectorDoc);

                    // 收集三个字段的文本
                    titleTexts.add(vectorDoc.getTitle() != null ? vectorDoc.getTitle() : "");

                    String authorStr = "";
                    if (vectorDoc.getAuthor() != null && vectorDoc.getAuthor().length > 0) {
                        authorStr = String.join(", ", vectorDoc.getAuthor());
                    }
                    authorTexts.add(authorStr);

                    journalNameTexts.add(vectorDoc.getJournalName() != null ? vectorDoc.getJournalName() : "");

                } catch (Exception e) {
                    log.error("[数据解析] 文档解析失败: {}", hit.id(), e);
                }
            }

            // 3. 批量生成三字段向量(并行调用 Embedding 服务)
            log.info("[生成向量] 开始为 {} 个文档生成三字段向量(title, author, journalName)",
                     vectorDocuments.size());

            List<float[]> titleVectors = embeddingService.embedTexts(titleTexts);
            log.info("[生成向量] 标题向量生成完成,共 {} 个",
                     titleVectors != null ? titleVectors.size() : 0);

            List<float[]> authorVectors = embeddingService.embedTexts(authorTexts);
            log.info("[生成向量] 作者向量生成完成,共 {} 个",
                     authorVectors != null ? authorVectors.size() : 0);

            List<float[]> journalNameVectors = embeddingService.embedTexts(journalNameTexts);
            log.info("[生成向量] 期刊名称向量生成完成,共 {} 个",
                     journalNameVectors != null ? journalNameVectors.size() : 0);

            // 4. 将向量赋值到文档对象
            if (titleVectors == null || authorVectors == null || journalNameVectors == null) {
                log.error("[生成向量] 失败,返回的向量列表为空");
            } else {
                for (int i = 0; i < vectorDocuments.size(); i++) {
                    JournalThesisVector doc = vectorDocuments.get(i);

                    if (i < titleVectors.size() && titleVectors.get(i) != null) {
                        doc.setTitleVector(titleVectors.get(i));
                    }

                    if (i < authorVectors.size() && authorVectors.get(i) != null) {
                        doc.setAuthorVector(authorVectors.get(i));
                    }

                    if (i < journalNameVectors.size() && journalNameVectors.get(i) != null) {
                        doc.setJournalNameVector(journalNameVectors.get(i));
                    }
                }
            }

            // 5. 批量写入向量索引
            log.info("[写入索引] 开始批量写入向量索引");
            int written = bulkIndexVectorDocuments(vectorIndex, vectorDocuments);
            successCount += written;
            processedCount += hits.size();

            log.info("[批次完成] 已处理: {}, 成功: {}", processedCount, successCount);

            currentFrom += currentSize;
            if (hits.size() < currentSize) hasMore = false;
        }

        result.setSuccess(true);
        result.setProcessedCount(processedCount);
        result.setSuccessCount(successCount);

        log.info("[批量向量化] 完成,总计处理: {}, 成功: {}", processedCount, successCount);

    } catch (Exception e) {
        log.error("[批量向量化] 失败", e);
        result.setSuccess(false);
        result.setMessage("向量化失败: " + e.getMessage());
    }

    return result;
}

2.4 Embedding 服务封装

文件位置: EmbeddingService.java:72-152

服务配置:

  • 模型:gte-multilingual-base(支持中英文混合)
  • 向量维度:768
  • 部署方式:本地 HTTP 服务(http://192.168.0.168:5000)
  • API 端点/embed_batch

核心实现:

/**
 * 批量文本向量化(支持自动重试)
 */
public List<float[]> embedTexts(List<String> texts) {
    if (texts == null || texts.isEmpty()) {
        return new ArrayList<>();
    }

    int maxRetries = 3;
    int retryDelay = 1000;  // 初始延迟 1 秒

    for (int attempt = 1; attempt <= maxRetries; attempt++) {
        try {
            String url = appConfig.getEmbeddingServiceUrl() + "/embed_batch";
            log.info("[向量化] 调用 Embedding 服务: {}, 文本数量: {}, 尝试 {}/{}",
                url, texts.size(), attempt, maxRetries);

            // 构建请求
            Map<String, Object> requestBody = Map.of("texts", texts);
            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_JSON);
            headers.set("Connection", "keep-alive");
            HttpEntity<Map<String, Object>> request = new HttpEntity<>(requestBody, headers);

            // 发送 POST 请求
            ResponseEntity<EmbedBatchResponse> response = restTemplate.postForEntity(
                url, request, EmbedBatchResponse.class
            );

            EmbedBatchResponse body = response.getBody();
            if (body == null || body.getEmbeddings() == null) {
                throw new RuntimeException("Embedding 服务返回结果为空");
            }

            log.info("[向量化] 成功,返回 {} 个向量,维度: {}", body.getCount(), body.getDimension());

            // 将 List<List<Double>> 转换为 List<float[]>
            List<float[]> result = new ArrayList<>();
            for (List<Double> embedding : body.getEmbeddings()) {
                float[] floatArray = new float[embedding.size()];
                for (int i = 0; i < embedding.size(); i++) {
                    floatArray[i] = embedding.get(i).floatValue();
                }
                result.add(floatArray);
            }

            return result;

        } catch (ResourceAccessException e) {
            if (attempt == maxRetries) {
                throw new RuntimeException("Embedding 服务连接失败(已重试 " + maxRetries + " 次): " +
                    e.getMessage() + "。请检查服务是否运行:" + appConfig.getEmbeddingServiceUrl(), e);
            }

            log.warn("[向量化] 第 {} 次尝试失败,{}ms 后重试: {}", attempt, retryDelay, e.getMessage());

            try {
                Thread.sleep(retryDelay);
                retryDelay *= 2;  // 指数退避策略
            } catch (InterruptedException ie) {
                Thread.currentThread().interrupt();
                throw new RuntimeException("重试被中断", ie);
            }
        } catch (Exception e) {
            log.error("[向量化] 失败,文本数量: {}", texts.size(), e);
            throw new RuntimeException("批量文本向量化失败: " + e.getMessage(), e);
        }
    }

    throw new RuntimeException("Embedding 服务调用失败:未知错误");
}

三、智能检索阶段:NER 增强的混合 RAG

3.1 为什么需要 NER 增强检索?

传统的纯向量检索存在以下局限:

纯向量检索的三大缺陷
问题 示例 影响
语义漂移 "张三的论文"和"李四的论文"向量相似度很高,但查询意图完全不同 精确度低
实体缺失 无法精确匹配关键实体(如作者名、期刊名、年份) 召回质量差
意图模糊 无法区分"查找论文"(需要检索)和"分析主题"(需要总结) 处理策略错误
NER 增强检索的优势
【传统向量检索】
用户查询:"查找张三在计算机学报发表的关于深度学习的论文"
    ↓ 直接向量化
问题向量:[0.12, -0.34, ..., 0.56]
    ↓ 余弦相似度检索
结果:可能召回"李四在软件学报发表的机器学习论文"(语义相似但实体不匹配)

【NER 增强检索】
用户查询:"查找张三在计算机学报发表的关于深度学习的论文"
    ↓ NER 提取
实体:AUTHOR=张三, JOURNAL=计算机学报, TITLE=深度学习
    ↓ 构建混合查询(精确匹配 + 向量检索)
结果:只召回"张三""计算机学报"发表的"深度学习"相关论文(精确度 100%

3.2 实现方案对比

方案 适用场景 召回率 精确率 实现复杂度
混合检索(NER + 向量) 需要精确匹配实体的场景(学术检索、电商搜索、法律检索) 很高 很高
查询增强(Query Expansion) 用户问题较短或不清晰(客服问答、FAQ) 中等
元数据过滤(Metadata Filtering) 数据有明确分类体系(分类、标签、时间范围)
纯向量检索 通用语义匹配场景(开放域问答、文档摘要) 中等 中等
关键词检索(BM25) 精确匹配场景(代码搜索、日志查询)

本项目选择:混合检索(NER + 向量)

理由:

  1. 学术检索场景对精确度要求极高(必须匹配作者名、期刊名)
  2. 语义理解同样重要("深度学习"可能对应多种表述)
  3. 支持复杂查询(多条件组合)

3.3 NER 增强混合检索完整流程

步骤 1:NER 实体提取 + 意图识别

文件位置: NerService.java:110-157

System Prompt 设计(核心):

private static final String SYSTEM_PROMPT = """
    你是一个面向学术检索的专业 NER 助手,负责从用户查询中提取实体、识别意图。

    【实体类型】只能使用以下四类:
    - AUTHOR:作者姓名(中英文人名)
    - JOURNAL:期刊名称
    - TITLE:论文标题、研究主题、技术术语(视为检索题名,不拆分)
    - YEAR:具体年份(用逗号分隔,如 "2023,2024,2025")

    除以上四类外,不得使用任何其他类型。

    【YEAR 规则】将相对时间转换为具体年份(基于当前年份,如 2025):
    - 最近/过去/近 1 年 → 2024
    - 最近/过去/近 2 年 → 2024,2025
    - 最近/过去/近 3 年、最近几年 → 2023,2024,2025
    - 最近/过去/近 5 年 → 2021–2025
    - 范围如 2020–2023 → 展开成 2020,2021,2022,2023
    - 单一年份保持不变

    YEAR 的 value 禁止出现文字,只能是年份数字列表。

    【TITLE 规则】
    1. 引号中的内容视为完整标题
    2. "关于/有关/涉及 XX"中的 XX 视为 TITLE
    3. 技术术语、研究方向均视为 TITLE(如"深度学习""区块链")
    4. 当出现"作者 X""期刊 X"时,其余主体内容作为 TITLE
    5. 不拆分完整标题或主题

    【意图类型】
    - SEARCH_PAPER:查找论文/文献(出现"论文""文章""文献""查找"等)
    - TOPIC_ANALYSIS:介绍、解释、分析某主题(出现"介绍""分析""现状""趋势""是什么"等)
    - UNKNOWN:无法判断

    【置信度参考】
    - 0.9–1.0:明确实体(引号、人名、期刊)
    - 0.7–0.9:技术术语、常见主题
    - 0.5–0.7:可疑实体

    【输出格式】JSON:
    {
      "entities": [
        {"type": "AUTHOR", "value": "张三", "confidence": 0.95},
        {"type": "JOURNAL", "value": "计算机学报", "confidence": 0.90},
        {"type": "TITLE", "value": "深度学习", "confidence": 0.85},
        {"type": "YEAR", "value": "2023,2024,2025", "confidence": 0.90}
      ],
      "intent": "SEARCH_PAPER"
    }
    """;

User Prompt 构建(支持历史上下文):

private String buildNerPromptWithHistory(String text, List<QaHistory> historyContext) {
    StringBuilder prompt = new StringBuilder();
    int currentYear = Year.now().getValue();

    prompt.append("【当前年份】\n").append(currentYear).append("年\n\n");

    // 拼接历史对话上下文(最近 3 条),用于指代消解
    if (historyContext != null && !historyContext.isEmpty()) {
        prompt.append("【历史对话上下文】(用于辅助理解当前问题)\n");
        historyContext.stream()
            .sorted(Comparator.comparing(QaHistory::getCreatedAt))
            .limit(3)
            .forEach(h -> prompt.append("用户问题: ").append(h.getQuestion()).append("\n"));
        prompt.append("\n");
    }

    // 当前问题
    prompt.append("【当前问题】(需要从这里提取实体和识别意图)\n");
    prompt.append(text).append("\n\n");

    // 要求说明
    prompt.append("""
        要求:
        1. 必须提取所有相关实体(作者、期刊、题名、年份等)
        2. 对每个实体给出合理的置信度(0.0-1.0)
        3. 识别用户的主要查询意图
        4. 如果提取到 YEAR 类型且使用了相对时间,必须转换为具体年份数字(逗号分隔)
        5. 如果历史对话中的信息与当前问题有关联,应综合考虑提取实体

        请使用 JSON 格式返回结果。
        """);

    return prompt.toString();
}

实体提取示例:

输入:"查找张三在计算机学报发表的关于深度学习的最近三年论文"

输出:
{
  "entities": [
    {"type": "AUTHOR", "value": "张三", "confidence": 0.95},
    {"type": "JOURNAL", "value": "计算机学报", "confidence": 0.90},
    {"type": "TITLE", "value": "深度学习", "confidence": 0.85},
    {"type": "YEAR", "value": "2023,2024,2025", "confidence": 0.90}
  ],
  "intent": "SEARCH_PAPER"
}

历史上下文的作用(指代消解):

第一次对话:
用户:"查找张三的论文"
NER 提取:AUTHOR=张三

第二次对话:
用户:"他最近三年的论文呢?"
历史上下文:["查找张三的论文"]
NER 提取:AUTHOR=张三(从历史推断), YEAR=2023,2024,2025
步骤 2:根据意图选择处理器(策略模式)

文件位置: RagService.java:62-97

/**
 * 意图驱动的流式问答(策略模式,零 if-else)
 */
public void askStreamWithNer(String question,
                             List<QaHistory> historyContext,
                             Consumer<List<RetrievedDocument>> onRetrievedDocuments,
                             Consumer<String> onAnswerChunk,
                             Consumer<Exception> onError) {
    try {
        // 1. 基于历史上下文进行 NER 提取实体和意图
        NerResult nerResult = nerService.recognize(question, historyContext);

        log.info("提取到 {} 个实体,意图: {}",
            nerResult.getEntities().size(), nerResult.getQueryIntent().getDisplayName());

        // 2. 根据意图选择处理器(策略模式)
        IntentHandler handler = intentHandlerFactory.getHandler(nerResult.getQueryIntent());

        log.info("使用 {} 处理器", handler.getClass().getSimpleName());

        // 3. 使用意图处理器进行流式处理
        handler.handleStream(question, nerResult, historyContext,
            onRetrievedDocuments, onAnswerChunk, onError);

    } catch (Exception e) {
        log.error("[意图驱动RAG] 失败", e);
        if (onError != null) {
            onError.accept(e);
        }
    }
}

意图处理器映射:

意图类型 处理器类 处理逻辑
SEARCH_PAPER SearchPaperIntentHandler 执行混合检索,返回相关论文 + 生成总结
TOPIC_ANALYSIS TopicAnalysisIntentHandler 直接调用 LLM 进行主题分析(可选检索)
UNKNOWN DefaultIntentHandler 兜底处理器,尝试理解用户意图
步骤 3:构建 Elasticsearch 混合查询

核心查询逻辑(SearchPaperIntentHandler):

/**
 * 构建 NER 增强的混合查询
 * 结合实体精确匹配 + 向量语义检索
 */
private Query buildHybridQuery(NerResult nerResult, float[] questionVector) {
    List<Query> shouldQueries = new ArrayList<>();

    // 1. 精确匹配实体(使用 terms 查询)
    List<String> authors = nerResult.getAuthors();
    if (!authors.isEmpty()) {
        shouldQueries.add(Query.of(q -> q
            .terms(t -> t.field("author").terms(ts -> ts.value(
                authors.stream().map(FieldValue::of).collect(Collectors.toList())
            )))
        ));
    }

    List<String> journals = nerResult.getJournals();
    if (!journals.isEmpty()) {
        shouldQueries.add(Query.of(q -> q
            .terms(t -> t.field("journalName").terms(ts -> ts.value(
                journals.stream().map(FieldValue::of).collect(Collectors.toList())
            )))
        ));
    }

    List<Integer> years = nerResult.getParsedYears();
    if (!years.isEmpty()) {
        shouldQueries.add(Query.of(q -> q
            .terms(t -> t.field("year").terms(ts -> ts.value(
                years.stream().map(y -> FieldValue.of((long) y)).collect(Collectors.toList())
            )))
        ));
    }

    // 2. 向量相似度检索(标题、作者、期刊三字段加权)
    List<Float> vectorList = toFloatList(questionVector);

    // 标题向量(权重最高 5.0)
    shouldQueries.add(Query.of(q -> q
        .knn(knn -> knn
            .field("titleVector")
            .queryVector(vectorList)
            .k(20)
            .numCandidates(100)
            .boost(5.0f)
        )
    ));

    // 作者向量(权重 3.0)
    shouldQueries.add(Query.of(q -> q
        .knn(knn -> knn
            .field("authorVector")
            .queryVector(vectorList)
            .k(20)
            .numCandidates(100)
            .boost(3.0f)
        )
    ));

    // 期刊名称向量(权重 2.0)
    shouldQueries.add(Query.of(q -> q
        .knn(knn -> knn
            .field("journalNameVector")
            .queryVector(vectorList)
            .k(20)
            .numCandidates(100)
            .boost(2.0f)
        )
    ));

    // 3. 组合查询(Bool Query)
    return Query.of(q -> q
        .bool(b -> b
            .should(shouldQueries)
            .minimumShouldMatch("1")  // 至少匹配 1 个条件
        )
    );
}

生成的 Elasticsearch 查询示例:

{
  "query": {
    "bool": {
      "should": [
        {
          "terms": {
            "author": ["张三"]
          }
        },
        {
          "terms": {
            "journalName": ["计算机学报"]
          }
        },
        {
          "terms": {
            "year": [2023, 2024, 2025]
          }
        },
        {
          "knn": {
            "field": "titleVector",
            "query_vector": [0.12, -0.34, ..., 0.56],
            "k": 20,
            "num_candidates": 100,
            "boost": 5.0
          }
        },
        {
          "knn": {
            "field": "authorVector",
            "query_vector": [0.12, -0.34, ..., 0.56],
            "k": 20,
            "num_candidates": 100,
            "boost": 3.0
          }
        },
        {
          "knn": {
            "field": "journalNameVector",
            "query_vector": [0.12, -0.34, ..., 0.56],
            "k": 20,
            "num_candidates": 100,
            "boost": 2.0
          }
        }
      ],
      "minimum_should_match": 1
    }
  },
  "size": 5
}

查询参数说明:

  • k: 返回最相似的 K 个文档(设置为 20)
  • num_candidates: 候选文档数量(设置为 100,通常为 k 的 5-10 倍)
  • boost: 权重提升因子(标题 5.0 > 作者 3.0 > 期刊 2.0)
  • minimum_should_match: 至少匹配 1 个条件(可调整为 2 或 3 提高精确度)
步骤 4:执行检索并返回 Top-K 文档
/**
 * 执行混合检索
 */
private List<RetrievedDocument> executeHybridSearch(Query query, int topK) throws IOException {
    SearchResponse<JournalThesisVector> response = elasticsearchClient.search(s -> s
        .index(appConfig.getVectorIndex())  // 查询向量索引
        .query(query)
        .size(topK)
        .trackTotalHits(t -> t.enabled(true)),
        JournalThesisVector.class
    );

    return response.hits().hits().stream()
        .map(hit -> {
            JournalThesisVector source = hit.source();
            RetrievedDocument doc = new RetrievedDocument();
            doc.setId(source.getSid());
            doc.setTitle(source.getTitle());
            doc.setAuthor(source.getAuthor());
            doc.setJournalName(source.getJournalName());
            doc.setYear(source.getYear());
            doc.setScore(hit.score());  // 相关性得分
            return doc;
        })
        .collect(Collectors.toList());
}

3.4 NER 工具选择与对比

方案对比表
方案 优点 缺点 延迟 成本 适用场景
LLM-based NER(GLM-4/Qwen/Claude) 灵活、无需训练,支持自定义实体类型;上下文理解能力强;支持指代消解 成本较高,响应延迟较大 约 500ms 实体类型灵活、准确率要求高
传统 NER 模型(LAC、HanLP) 推理速度快,部署成本低 需要标注数据训练,泛化能力弱;难以处理新实体类型 约 50ms 实体类型固定、性能敏感
BERT-based NER 准确率高,支持迁移学习 需要 GPU 推理,模型维护成本高 约 100ms 中等规模数据集,平衡准确率和性能
规则 + 词典匹配 实现简单,速度极快 覆盖度差,维护困难 约 10ms 实体数量有限、固定模式
本项目选择:LLM-based NER(智谱 AI GLM-4-Flash)

选择理由:

  1. 灵活性:支持自定义实体类型(AUTHOR、JOURNAL、TITLE、YEAR),无需训练
  2. 准确率:上下文理解能力强,可处理复杂查询(如"他的最近三年论文")
  3. 意图识别:一次调用同时完成实体提取和意图分类
  4. 快速迭代:修改 Prompt 即可调整规则,无需重新训练模型

成本优化策略:

  • 使用轻量级模型(GLM-4-Flash,而非 GLM-4)
  • 缓存常见查询的 NER 结果(Redis)
  • 批量处理历史查询,预计算实体

3.5 实战经验与关键技巧

最佳实践

1. 历史上下文利用

// 示例:指代消解
// 用户第一次问:"查找张三的论文"
// 用户第二次问:"他最近三年的论文呢?"
// 通过传入历史上下文,NER 可以将"他"解析为"张三"
NerResult result = nerService.recognize(currentQuestion, historyContext);

2. 年份归一化

// 将"最近三年"转换为具体年份列表 "2023,2024,2025"
// 在 Prompt 中明确当前年份(2025 年)
// LLM 会自动计算并返回具体年份

3. 置信度过滤

// 过滤低置信度实体(< 0.5)
List<Entity> highConfidenceEntities = nerResult.getEntities().stream()
    .filter(entity -> entity.getConfidence() >= 0.5)
    .collect(Collectors.toList());

4. 向量检索权重调优

// 根据业务场景调整不同字段的权重
// 论文检索:title (5.0) > author (3.0) > journal (2.0)
// 作者检索:author (5.0) > title (2.0) > journal (2.0)

四、LLM 推理阶段:上下文增强的答案生成

4.1 Prompt 工程:构建高质量上下文

核心原则:

  1. 明确角色定位:告诉 LLM “你是一个学术检索助手”
  2. 提供结构化上下文:将检索到的文档格式化为清晰的列表
  3. 约束输出范围:强调"仅基于提供的文档回答",避免幻觉
  4. 流式输出:使用 SSE 实现打字机效果,提升用户体验

示例 Prompt 设计:

private String buildRagPrompt(String question, List<RetrievedDocument> documents) {
    StringBuilder prompt = new StringBuilder();

    prompt.append("你是一个专业的学术文献检索助手。\n\n");

    prompt.append("【用户问题】\n");
    prompt.append(question).append("\n\n");

    prompt.append("【检索到的相关论文】\n");
    for (int i = 0; i < documents.size(); i++) {
        RetrievedDocument doc = documents.get(i);
        prompt.append(String.format("%d. 论文标题:%s\n", i + 1, doc.getTitle()));
        prompt.append(String.format("   作者:%s\n", String.join(", ", doc.getAuthor())));
        prompt.append(String.format("   期刊:%s\n", doc.getJournalName()));
        prompt.append(String.format("   年份:%s\n", doc.getYear()));
        prompt.append(String.format("   相关性得分:%.2f\n", doc.getScore()));
        prompt.append("\n");
    }

    prompt.append("【回答要求】\n");
    prompt.append("1. 仅基于上述检索到的论文信息回答用户问题\n");
    prompt.append("2. 如果检索结果无法回答问题,请明确告知用户\n");
    prompt.append("3. 使用简洁、专业的语言,避免冗长\n");
    prompt.append("4. 如果用户问到数量,请准确统计\n");

    return prompt.toString();
}

4.2 流式输出实现(SSE)

文件位置: SearchPaperIntentHandler.java

/**
 * 流式生成答案(Server-Sent Events)
 */
private void streamAnswer(String question,
                         List<RetrievedDocument> documents,
                         Consumer<String> onAnswerChunk) {
    // 构建 RAG Prompt
    String prompt = buildRagPrompt(question, documents);

    // 使用 Spring AI 的流式 API
    chatClient.prompt()
        .user(prompt)
        .stream()
        .content()
        .subscribe(
            chunk -> {
                // 每收到一个 token,立即回调给前端
                if (onAnswerChunk != null) {
                    onAnswerChunk.accept(chunk);
                }
            },
            error -> log.error("流式生成失败", error),
            () -> log.info("流式生成完成")
        );
}

Controller 层 SSE 实现:

@GetMapping(value = "/ask-stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter askStream(@RequestParam String question) {
    SseEmitter emitter = new SseEmitter(300000L);  // 5 分钟超时

    CompletableFuture.runAsync(() -> {
        try {
            // 获取历史上下文
            List<QaHistory> historyContext = qaHistoryService.getRecentHistory(userId, 3);

            ragService.askStreamWithNer(
                question,
                historyContext,
                // 检索到文档时回调
                documents -> {
                    try {
                        emitter.send(SseEmitter.event()
                            .name("documents")
                            .data(documents));
                    } catch (IOException e) {
                        log.error("发送文档列表失败", e);
                    }
                },
                // 答案片段回调
                chunk -> {
                    try {
                        emitter.send(SseEmitter.event()
                            .name("answer")
                            .data(chunk));
                    } catch (IOException e) {
                        log.error("发送答案片段失败", e);
                    }
                },
                // 错误回调
                error -> {
                    try {
                        emitter.send(SseEmitter.event()
                            .name("error")
                            .data(error.getMessage()));
                        emitter.completeWithError(error);
                    } catch (IOException e) {
                        log.error("发送错误信息失败", e);
                    }
                }
            );

            // 完成流式传输
            emitter.send(SseEmitter.event().name("done").data("[DONE]"));
            emitter.complete();

        } catch (Exception e) {
            log.error("流式问答失败", e);
            emitter.completeWithError(e);
        }
    });

    return emitter;
}

前端 SSE 接收示例(JavaScript):

const eventSource = new EventSource('/api/rag/ask-stream?question=' + encodeURIComponent(question));

eventSource.addEventListener('documents', (event) => {
  // 收到检索到的文档
  const documents = JSON.parse(event.data);
  displayDocuments(documents);
});

eventSource.addEventListener('answer', (event) => {
  // 收到答案片段(逐字显示)
  const chunk = event.data;
  appendAnswerChunk(chunk);
});

eventSource.addEventListener('error', (event) => {
  // 处理错误
  console.error('SSE error:', event);
  eventSource.close();
});

eventSource.addEventListener('done', () => {
  // 答案生成完成
  eventSource.close();
});

4.3 幻觉抑制技巧

问题: LLM 容易编造不存在的信息(如虚构论文标题、作者)

解决方案:

1. 在 Prompt 中明确约束

重要:你只能基于【检索到的相关论文】中的信息回答问题。
如果检索结果无法回答问题,请回复:"抱歉,检索到的论文中没有相关信息。"
禁止编造任何论文信息。

2. 后处理验证

// 检查生成的答案中提到的论文是否在检索结果中
boolean isValid = validateAnswerAgainstDocuments(answer, documents);
if (!isValid) {
    log.warn("检测到幻觉内容,拒绝输出");
    return "检索结果中没有相关信息。";
}

3. 引用机制

// 在答案中添加引用标记 [1]、[2]
String answerWithCitations = addCitations(answer, documents);
// 输出示例:
// "根据检索结果,张三在2024年发表了《深度学习综述》[1],该论文..."

4.4 实战经验与优化策略

最佳实践

1. 上下文长度控制

  • 检索 Top 20 文档,但只传递 Top 5 给 LLM(降低成本和延迟)
  • 每篇文档只保留关键字段(标题、作者、期刊、年份),去除摘要和全文

2. 流式输出优化

  • 首先返回检索到的文档列表(让用户立即看到结果)
  • 然后开始流式生成答案(提升体验)

3. 错误降级

try {
    streamAnswer(question, documents, onAnswerChunk);
} catch (Exception e) {
    // 如果 LLM 调用失败,至少返回检索到的文档
    log.error("LLM 生成失败,降级为纯检索模式", e);
    onAnswerChunk.accept("检索到以下相关论文:\n" + formatDocuments(documents));
}

五、完整实战案例

案例:学术文献智能检索系统

需求: 用户输入自然语言查询,系统返回相关论文并生成总结性回答。

示例查询:“查找Baoyu Fan在期刊《Mathematics》发表的关于深度学习的最近三年论文”

步骤 1、NER 实体提取

输出:

{
 "originalText" : "查找张三在计算机学报发表的关于深度学习的最近三年论文",
 "entities" : [ {
   "type" : "AUTHOR",
   "value" : "张三",
   "confidence" : 0.95
 }, {
   "type" : "JOURNAL",
   "value" : "计算机学报",
   "confidence" : 0.9
 }, {
   "type" : "TITLE",
   "value" : "深度学习",
   "confidence" : 0.85
 }, {
   "type" : "YEAR",
   "value" : "2022",
   "confidence" : 0.9
 }, {
   "type" : "YEAR",
   "value" : "2023",
   "confidence" : 0.9
 }, {
   "type" : "YEAR",
   "value" : "2024",
   "confidence" : 0.9
 } ],
 "intent" : "SEARCH_PAPER"
}

image-20251126112339747

步骤 2、根据意图选择处理器

image-20251126111525975

步骤 3、向量化实体并构建 ES混合查询

构建es查询dsl:

 {
    "bool": {
      "must": [
        {
          "term": {
            "author": {
              "value": "Baoyu Fan"
            }
          }
        },
        {
          "terms": {
            "year": [
              2022,
              2023,
              2024
            ]
          }
        }
      ],
      "should": [
        {
          "knn": {
            "boost": 5.0,
            "field": "titleVector",
            "query_vector": [
              0.003465077141299844,
              -0.029448172077536583,
              -0.07837327569723129
              ...
            ],
            "k": 10,
            "num_candidates": 100
          }
        },
        {
          "knn": {
            "boost": 2.0,
            "field": "journalNameVector",
            "query_vector": [
              -0.012345678901234567,
              0.056789012345678901,
              0.034567890123456789
              ...
            ],
            "k": 10,
            "num_candidates": 100
          }
        }
      ],
      "minimum_should_match": 0
    }
  }

image-20251126112453027

检索结果:

image-20251126113212729

步骤4、LLM 流式生成答案

结合rag检索结果构建提示词

你是一个专业的学术文献检索助手,请基于提供的论文信息回答用户的问题。

要求:
1. 回答要准确、专业、有条理
2. 尽量引用提供的论文信息
3. 如果信息不足,明确告知用户
4. 保持客观中立的态度
以下是检索到的相关论文信息:

[论文 1]
详情链接:https://jyx.cxstar.cn/#/articleDetail?sid=2024014725ed-9269-4658-bc92-f2ac77eb5f2a
题名:KDTM: Multi-Stage Knowledge Distillation Transfer Model for Long-Tailed DGA Detection
作者:Yue Liu, Xiaochen Yuan, Han Ma, Baoyu Fan, Wei Ke
期刊:Mathematics
年份:2024
相关度评分:20.0481


提取到的关键信息:
- AUTHOR:Baoyu Fan
- JOURNAL:Mathematics
- TITLE:深度学习
- YEAR:2023,2024,2025

请基于以上信息回答以下问题:
查找Baoyu Fan在期刊《Mathematics》发表的关于深度学习的最近三年论文

步骤5、大模型返回结果

image-20251126114031512


六、参考资料与扩展阅读

1. Spring AI 官方文档

  • https://docs.spring.io/spring-ai/reference/
  • 涵盖 Embedding、Vector Store、Chat Model 等核心概念

2. Elasticsearch Vector Search

  • https://www.elastic.co/guide/en/elasticsearch/reference/current/knn-search.html
  • kNN 查询、HNSW 索引原理

3. 智谱 AI API 文档

  • https://open.bigmodel.cn/dev/api
  • GLM-4 系列模型使用指南

4. RAG 综述

  • Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks
  • Lewis et al., NeurIPS 2020
  • https://arxiv.org/abs/2005.11401
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐