Spring-AI-Alibaba框架--基于Qwen-plus模型的RAG检索基础入门案例

881 阅读21分钟

什么是 Spring AI Alibaba

“Spring AI Alibaba(SAA) 是一款以 Spring AI 为基础,深度集成百炼平台,支持 ChatBot、工作流、多智能体应用开发模式的 AI 框架。”
总的来说spring-ai-alibaba就是在spring-AI所拥有的功能基础上,对百炼平台进行了适配,个人觉得后面可能会和spring-cloud-alibaba一样,深受国内外开发者喜爱,目前的最新版本1.0.0.2对标的是Spring-AI的1.0.0GA版,详细教程可以去spring-ai-alibaba官方文档查看,这里不做详细赘述。

什么是RAG

检索增强生成(Retrieval Augmented Generation, RAG)是一种有用的技术,用于克服大型语言模型在处理长篇内容、事实准确性和上下文感知能力方面的局限性。 Spring AI 通过提供模块化架构来支持 RAG,该架构允许您自己构建自定义 RAG 流程,或者使用 Advisor API 来使用开箱即用的 RAG 流程。

举个例子,下面有这样一个场景:
情景:你正在写一篇关于“量子计算的未来影响”的报告。

你手头有一个最新的大型语言模型(LLM),比如 GPT-4o 或 Qwen。


没有 RAG 的情况(纯 LLM 的局限性):

你直接向 LLM 提问:“量子计算对全球经济的未来影响是什么?请列举具体的公司和技术突破。”

LLM 可能会这样回答:

  • “量子计算有望彻底改变多个行业,例如金融、医药和材料科学。它可能加速新材料的发现,优化复杂的金融模型,并为药物开发提供前所未有的能力。一些领先的公司正在投资这一领域,如 IBM、Google 和 Microsoft。”

  • 问题所在:

    • 通用性强,但缺乏深度和特异性: 回答听起来合理,但信息非常泛泛,没有提到具体的“未来影响”的细节、量化数据或最新的研究进展。
    • 事实准确性可能不足: 提到的公司是正确的,但它可能无法提供最新的公司动态、具体的投资项目,或者某些公司在该领域面临的挑战。
    • 缺乏最新上下文: LLM 的训练数据有截止日期(例如,到 2023 年初),它无法访问 2024 年甚至 2025 年最新的量子计算研究报告、公司财报或行业分析。
    • 无法溯源: 你无法知道这些信息是从哪里来的,它是否真实可靠。 有了 RAG 的情况(检索增强生成):

现在,你有了 RAG 系统。这个系统连接了:

  1. 你的 LLM

  2. 一个知识库/检索系统:这个知识库包含了大量高质量、最新的、与“量子计算”相关的文档,例如:

    • 最新的行业研究报告(如高盛 2024 年关于量子计算的分析)
    • 顶尖大学(如 MIT、Stanford)的最新论文
    • 权威科技新闻网站的深度报道
    • 特定公司(如 Quantinuum、IonQ)的官方白皮书和年度报告。

RAG 的工作流程:

  1. 用户提问: 你向 RAG 系统提问:“量子计算对全球经济的未来影响是什么?请列举具体的公司和技术突破。”

  2. 检索(Retrieval): RAG 系统会首先分析你的问题,并将其转化为一个或多个查询,去知识库中搜索相关且高质量的文档片段

    • 例如,它可能会检索到:

      • 一篇 2024 年关于“量子加密标准发展”的NIST报告片段。
      • Quantinuum 公司 2025 年 Q1 财报中关于其 H-Series 处理器性能提升的段落。
      • 一篇分析量子算法在药物研发中应用潜力的最新科学论文的摘要。
      • 高盛 2024 年关于量子经济影响的报告中,提到金融模型加速的预测数据。
  3. 增强(Augmentation): RAG 系统将这些检索到的具体、最新、有事实依据的文档片段作为“上下文”或“参考资料”,连同你的原始问题一起,提供给 LLM。

    • LLM 接收到的是:“[用户问题] + [多篇相关文档片段]”。
  4. 生成(Generation): LLM 接收到这些高质量的上下文信息后,利用其强大的语言理解和生成能力,结合这些信息来回答你的问题。

LLM 使用 RAG 后可能给出的回答:

  • “量子计算预计将在未来五年内对全球经济产生显著影响,特别是在以下几个关键领域:

    • 金融建模: 据高盛 2024 年报告预测,量子算法能将某些复杂金融衍生品的定价速度提高 100 倍,从而实现更精准的风险管理和高频交易策略。这可能重塑投资银行的量化分析部门。

    • 新药发现: 最新研究表明,量子模拟能够以前所未有的精度模拟分子行为,加速新化合物的发现。例如,某 2025 年论文展示了量子算法在设计新型抗癌药物方面的突破,可能将药物研发周期缩短 30%。

    • 网络安全: 随着量子计算机的进步,当前的加密标准面临挑战。NIST 正在推动后量子加密(PQC)标准的制定,这将在全球范围内引发大规模的加密系统升级,预计未来三年内相关产业投资将达数十亿美元。

    • 领先公司及其技术:

      • Quantinuum: 其最新的 H-Series 量子处理器在 2025 年 Q1 取得了XX量子体积(Quantum Volume)的突破,这使其在优化化学模拟和金融模型方面具有领先优势。
      • IonQ: 正专注于离子阱技术的商业化,并与[具体公司名称]合作开发针对[具体行业]的量子解决方案。
      • IBM: 持续推进其量子系统路线图,并推出了[具体新平台或软件],旨在降低企业采用门槛。”

上面的场景案例其实不难看出,RAG的作用

  • 提升事实准确性: 基于实时、权威的外部知识生成答案,减少 LLM“胡说八道”(hallucination)的可能性。

  • 提供最新信息: 即使 LLM 的训练数据已经过时,RAG 也能通过检索最新文档来提供时效性强的信息。

  • 增强上下文感知: 为 LLM 提供了具体、相关的背景信息,使其生成更精准、更深入的回答。

  • 可溯源性: 由于答案是基于检索到的文档生成的,通常可以展示引用的来源(例如,来自哪篇报告、哪篇论文),提高了答案的透明度和可信度。

  • 处理长篇内容: LLM 的输入有长度限制。RAG 可以智能地只检索相关的“片段”,而不是将整个长文档都喂给 LLM,从而有效利用 LLM 的上下文窗口。

所以,RAG 就像是给 LLM 配备了一个“图书馆管理员”和“研究助理”,让它在回答问题时能随时查阅最新的、最相关的资料,而不是仅仅依赖自己脑中的“记忆”,这点在我们利用构建本地知识库进行知识检索的时候尤为重要。

Spring-ai-Alibaba中的RAG

Spring-ai-Alibaba的RAG沿用了Spring-ai的内容,下面我带大家来做个入门案例,方便大家理解RAG的过程和使用。

相关依赖包

下面以Gradle举例:

ext{

springAiAlibabaVersion = '1.0.0.2'
springAiVersion = '1.0.0'
dashScopeSDKVersion = '2.20.5'
protobufJavaVersion = '3.25.3'
openAIJavaVersion = '2.7.0'

}

//在dependencies里面加入下面这些依赖包
implementation platform("org.springframework.ai:spring-ai-bom:${springAiVersion}")
implementation 'com.zaxxer:HikariCP:5.0.1'
// 引入springAiAlibaba依赖
implementation platform("com.alibaba.cloud.ai:spring-ai-alibaba-bom:${springAiAlibabaVersion}")


// 引入springAiAlibaba依赖包
implementation 'com.alibaba.cloud.ai:spring-ai-alibaba-starter-dashscope'

//引入springAI相关依赖包
implementation 'org.springframework.ai:spring-ai-deepseek'

//引入RAG检索增强生成相关依赖
implementation 'org.springframework.ai:spring-ai-advisors-vector-store'
implementation 'org.springframework.ai:spring-ai-jsoup-document-reader'
implementation 'org.springframework.ai:spring-ai-markdown-document-reader'
implementation 'org.springframework.ai:spring-ai-pdf-document-reader'
implementation 'org.springframework.ai:spring-ai-tika-document-reader'
implementation 'org.springframework.ai:spring-ai-tika-document-reader'

//引入Milvus VectorStore依赖包
implementation 'org.springframework.ai:spring-ai-starter-vector-store-milvus'
implementation "com.google.protobuf:protobuf-java:${protobufJavaVersion}"
implementation "com.alibaba:dashscope-sdk-java:${dashScopeSDKVersion}"
implementation "com.openai:openai-java:${openAIJavaVersion}"

配置文件

首先是chat配置,这里使用qwen-plus。
注:形似${xxx}为环境变量,具体值各位自行补充,这里不方便透露,apikey可以去百炼平台获取。

image.png

第二个就是向量数据库的配置了,这里使用milvus作为向量存储的数据库,当然也可以用es

image.png

注:SpringAI官网支持的向量数据库:

image.png

配置类

ChatClient配置:这里为了后续可以灵活切换大模型因此使用了手动注册,其实配置文件写好后可以直接使用主动注入获取ChatClient。

image.png image.png

嵌入模型配置:

image.png

image.png

RAG相关配置:

image.png

image.png

向量存储数据库的初始化

根据实际使用时遇到的问题发现,只配置向量存储是不会自动初始化数据库的,需要我们自行初始化,下面代码是我调试很多遍后,milvus的初始化:

@Component
@Slf4j
public class VectorDBInit {
    @Value("${spring.ai.vectorstore.milvus.collectionName}")
    private String collectionName;
    // 替换为您的嵌入模型维度,具体维度可以看下各个嵌入模型的官方文档说明
    private int dimension = 1536;
    @Autowired
    private MilvusClient milvusClient;

    @PostConstruct
    void run() {
        // 检查 Collection 是否存在
        if (!milvusClient.hasCollection(HasCollectionParam.newBuilder()
                .withCollectionName(collectionName)
                .build()).getData()) {
            // 定义 Collection 的 Schema
            CreateCollectionParam.Builder collectionBuilder = CreateCollectionParam.newBuilder()
                    .withCollectionName(collectionName)
                    .withDescription("Vector store for Spring AI documents");

            collectionBuilder
                    .addFieldType(FieldType.newBuilder()
                            .withName("id")
                            .withDataType(DataType.VarChar)
                            .withMaxLength(256)
                            .withPrimaryKey(true)
                            .withAutoID(false)
                            .build())
                    .addFieldType(FieldType.newBuilder()
                            .withName("embedding")
                            .withDataType(DataType.FloatVector)
                            .withDimension(dimension)
                            .build())
                    .addFieldType(FieldType.newBuilder()
                            .withName("text") // 添加原始文本字段
                            .withDataType(DataType.VarChar)
                            .withMaxLength(16777216)
                            .build())
                    .addFieldType(FieldType.newBuilder() // 添加元数据字段
                            .withName("metadata")
                            .withDataType(DataType.VarChar)
                            .withMaxLength(165535)
                            .build());


            // 创建 Collection
            milvusClient.createCollection(collectionBuilder.build());
            log.info(String.format("Collection '{}' created successfully.", collectionName));

            // 创建索引
            CreateIndexParam indexParam = CreateIndexParam.newBuilder()
                    .withCollectionName(collectionName)
                    .withFieldName("embedding")
                    .withIndexType(IndexType.HNSW)
                    .withMetricType(MetricType.COSINE) // 根据您的需求选择距离度量
                    .withExtraParam("{"M":8,"efConstruction":64}") // 索引参数
                    .build();
            milvusClient.createIndex(indexParam);
            log.info(String.format("Index created on collection '%s' field 'vector'.", collectionName));

            // 加载 Collection
            milvusClient.loadCollection(LoadCollectionParam.newBuilder().withCollectionName(collectionName).build());
            log.info(String.format("Collection '%s' loaded.", collectionName));
        } else {
            log.info(String.format("Collection '%s' already exists.", collectionName));
            // 确保 Collection 已加载
            milvusClient.loadCollection(LoadCollectionParam.newBuilder().withCollectionName(collectionName).build());
            log.info(String.format("Collection '%s' loaded.", collectionName));
        }
    }


}

好了,上面就是我们使用RAG需要的启动步骤了,接下来我们开始正式介绍RAG,想要实现RAG,就必须对用输入的文本或者文件进行信息提取、数据清洗以及数据向量化并向量存储,官方提供了ETL方式。
“抽取、转换和加载(Extract, Transform, Load, ETL)框架是检索增强生成(RAG)用例中数据处理的支柱。 ETL 管线编排了数据从原始数据源到结构化向量存储的流动,确保数据以最佳格式存在,以便 AI 模型进行检索。 RAG 用例旨在通过从数据主体中检索相关信息来增强生成模型的能力,从而提高生成输出的质量和相关性。”

首先是文件/文本读取并构造成Document对象,这里我封装了方法供大家使用,但是目前没有详细优化,效果可能很一般,但是作为入门学习够了。

/**
 * @author ashao
 */
@Service
@Slf4j
public class DocumentReaderServiceImpl implements DocumentReaderService {

    // 文件上传目录
    // 建议使用绝对路径,例如 "/opt/app_data/uploads/"
    // 或者基于当前工作目录的相对路径,例如 "./uploads/"
    // 生产环境中,请确保此目录有写入权限且位于数据盘而非应用部署盘
    private static final String UPLOAD_DIR = "./uploads/";

    @Override
    public Resource uploadFile(MultipartFile file) {
        Resource resource = null;
        if (file.isEmpty()) {
            log.warn("接收到一个空文件上传请求。");
            // 这里不返回给前端,但可以在日志中记录
            throw new RuntimeException("空文件");
        }

        try {
            // 确保上传目录存在
            Path uploadPath = Paths.get(UPLOAD_DIR);
            if (!Files.exists(uploadPath)) {
                Files.createDirectories(uploadPath);
                log.info("创建上传目录: {}", uploadPath.toAbsolutePath());
            }

            // 获取原始文件名
            String originalFilename = file.getOriginalFilename();
            // 获取文件扩展名 (例如 .txt, .pdf)
            String fileExtension = "";
            int dotIndex = originalFilename.lastIndexOf('.');
            if (dotIndex > 0 && dotIndex < originalFilename.length() - 1) {
                fileExtension = originalFilename.substring(dotIndex);
            }

            // **推荐:生成一个唯一的文件名以避免冲突和安全问题**
            String uniqueFileName = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS"))
                    + "_" + (int)(Math.random() * 1000) + fileExtension;

            // 构建目标文件路径
            Path filePath = Paths.get(UPLOAD_DIR + uniqueFileName);

            // 将文件数据写入到服务器文件系统
            Files.copy(file.getInputStream(), filePath); // 使用 Files.copy 更高效和健壮

            log.info("文件上传成功。原始文件名: '{}', 保存路径: '{}'", originalFilename, filePath.toAbsolutePath());

            // 之后,您可以在这里对这个文件 (filePath) 进行后续操作
            // 例如:解析文件内容、将其路径存入数据库、触发其他业务逻辑等
            // processFile(filePath); // 调用其他服务方法处理文件
            resource = new UrlResource(filePath.toUri());
        } catch (IOException e) {
            log.error("文件上传失败: {}", e.getMessage(), e);
            // 异常时同样不直接返回给前端错误消息,但会在日志中记录
            // 如果需要,也可以抛出一个自定义异常,由全局异常处理器处理
        }
        return resource;
    }

    /**
     * 将该记录中名为 "description" 的字段的值作为新的 Document 对象的主体内容。
     * 将该记录中名为 "content" 的字段的值作为新的 Document 对象的元数据。
     * 将所有生成的 Document 对象收集到一个列表中并返回。
     * @param resource
     * @return
     */
    @Override
    public List<Document> loadJsonDocuments(Resource resource) {
        JsonReader jsonReader = new JsonReader(resource, "description", "content");
        return jsonReader.get();
    }

    @Override
    public List<Document> loadText(Resource resource) {
        TextReader textReader = new TextReader(resource);
        textReader.getCustomMetadata().put("filename", resource.getFilename());

        return textReader.read();
    }

    @Override
    public List<Document> loadHtml(Resource resource) {
        JsoupDocumentReaderConfig config = JsoupDocumentReaderConfig.builder()
                // 指定要提取的 HTML 元素(article p)。
                .selector("article p")
                // 设置字符编码(UTF-8)
                .charset("UTF-8")
                // 选择是否包含链接 URL。
                .includeLinkUrls(true)
                // 定义要从 meta 标签中提取的元数据(author, date)
                .metadataTags(List.of("author", "date"))
                // 添加额外的、通用的元数据(source)
                .additionalMetadata("source", resource.getFilename())
                .build();

        JsoupDocumentReader reader = new JsoupDocumentReader(resource, config);
        return reader.get();
    }

    /**
     * @descrpition 读取markdown文件
     * @author ashao
     * @date 2025-06-11 10:02
     **/
    @Override
    public List<Document> loadMarkdown(Resource resource) {
        // 排除非必要代码块和引用块,只保留原始文本内容
        MarkdownDocumentReaderConfig config = MarkdownDocumentReaderConfig.builder()
                // 指定水平分隔线作为文档边界
                .withHorizontalRuleCreateDocument(true)
                // 排除代码块(即代码块内容会被过滤)
                .withIncludeCodeBlock(false)
                // 排除引用块(即引用块内容会被过滤)
                .withIncludeBlockquote(false)
                // 添加一个名为 "filename" 的额外元数据
                .withAdditionalMetadata("filename", resource.getFilename())
                .build();

        MarkdownDocumentReader reader = new MarkdownDocumentReader(resource, config);
        return reader.get();
    }

    /**
     * @descrpition 明确按页面分割pdf文件,粗颗粒度,对pdf文件格式没那么敏感,但噪音也会较大
     * @author ashao
     * @date 2025-06-11
     **/
    @Override
    public List<Document> loadDocsFromPdfWithCatalog(Resource resource) {
        PagePdfDocumentReader pdfReader = new PagePdfDocumentReader(resource,
                PdfDocumentReaderConfig.builder()
                        .withPageTopMargin(0)
                        .withPageExtractedTextFormatter(ExtractedTextFormatter.builder()
                                .withNumberOfTopTextLinesToDelete(0)
                                .build())
                        .withPagesPerDocument(1)
                        .build());

        return pdfReader.read();
    }



    /**
     * @descrpition 明确按段落分割pdf文件,细颗粒度,对pdf文件格式敏感,但噪音较小
     * @author ashao
     * @date 2025-06-11
     **/
    @Override
    public List<Document> loadDocsFromPdf(Resource resource) {
        ParagraphPdfDocumentReader pdfReader = new ParagraphPdfDocumentReader(resource,
                PdfDocumentReaderConfig.builder()
                        // 设置页面顶部边距为 0,不跳过顶部内容。
                        .withPageTopMargin(0)
                        // 配置文本提取器,确保不删除每页顶部的任何行。
                        .withPageExtractedTextFormatter(ExtractedTextFormatter.builder()
                                .withNumberOfTopTextLinesToDelete(0)
                                .build())
                        // 限制每个生成的 Document 对象的内容最多只来自 PDF 的一页。
                        .withPagesPerDocument(13)
                        .build());
        return pdfReader.read();
    }

    /**
     * @descrpition 这个方法也可以用来解析pdf、docx等类型的文件,具体支持的文件类型可以去查看apache tika官网
     * @author ashao
     * @date 2025-06-11
     **/
    @Override
    public List<Document> loadOtherFiles(Resource resource) {
        TikaDocumentReader tikaDocumentReader = new TikaDocumentReader(resource);
        return tikaDocumentReader.read();
    }

    @Override
    public List<Document> loadMultiFile(Resource resource) throws IOException {
        String fileType = resource.getFilename()
                                    .substring(resource.getFilename().indexOf(".") + 1, resource.getFilename().length());
        switch (fileType) {
            case "pdf":
                return loadDocsFromPdfWithCatalog(resource);
            case "txt":
                return loadText(resource);
            case "html":
                return loadHtml(resource);
            case "md":
                return loadMarkdown(resource);
            case "json":
                return loadJsonDocuments(resource);
            default:
                return loadOtherFiles(resource);
        }
    }

}

其次就是重点的数据清洗了,将上面提取到的Document集合进行内容分快,官方提供了五种分块方法:

  1. TextSplitter
  2. TokenTextSplitter
  3. ContentFormatTransformer
  4. KeywordMetadataEnricher
  5. SummaryMetadataEnricher

同样的,我这里也提供一些封装的方法,不过也是简单实现,具体的RAG分块方案很多,大家后续可以自行查找并学习:

/**
 * @author ashao
 */
@Service
public class DocumentTransformersServiceImpl implements DocumentTransformersService {
    private final SummaryMetadataEnricher enricher;
    @Autowired
    private ChatModel chatModel;

    public  DocumentTransformersServiceImpl (SummaryMetadataEnricher enricher) {
        this.enricher = enricher;
    }

    @Override
    public List<Document> splitDocuments(List<Document> documents) {
        TokenTextSplitter splitter = new TokenTextSplitter();
        return splitter.apply(documents);
    }

    @Override
    public List<Document> splitCustomized(List<Document> documents) {
        TokenTextSplitter splitter = new TokenTextSplitter(10000, 400, 10, 50000, true);
        return splitter.apply(documents);
    }

    /**
     * TokenTextSplitter 是 TextSplitter 的一个实现,它使用 CL100K_BASE 编码,根据 token 数量将文本分割成块。
     * @param documents 需要处理的原Document集合
     * @param defaultChunkSize 这设置了每个文本块的目标大小,以 tokens(标记)为单位计量。默认情况下,它目标是每个块 800 个 token。
     *                         这是一个关键设置,因为大型语言模型(LLM)在单次处理时可以处理的 token 数量是有限的。
     * @param minChunkSizeChars 这指定了在分割器寻找自然断点之前,一个文本块必须满足的最小字符数。
     *                          如果一个块的字符数少于 350(默认值),分割器会尝试扩展它以达到这个最小值,然后再尝试分割。
     * @param minChunkLengthToEmbed 这定义了一个块要被包含在输出中所需的绝对最小长度(以字符为单位)。
     *                              如果在所有分割和格式化之后,一个块的长度最终少于 5 个字符(默认值),它就会被认为太小而没有意义,并被丢弃。
     * @param maxNumChunks 这个参数设置了可以从单个输入文本生成的最大块数。
     *                     默认值非常高(10,000),这意味着除非你处理极长的文档并希望限制总段数,否则不太可能达到这个上限。
     * @param keepSeparator 这是一个布尔(真/假)设置,它决定了分割后是否在块中保留分隔符(如换行符、句号等)。
     *                      默认是 true,这意味着分隔符将保留在块的文本中。将其设置为 false 将会移除它们。
     * @return 处理完成的扩充Document集合
     */
    @Override
    public List<Document> splitCustomized(List<Document> documents, int defaultChunkSize, int minChunkSizeChars, int minChunkLengthToEmbed, int maxNumChunks, boolean keepSeparator) {
        TokenTextSplitter splitter = new TokenTextSplitter(defaultChunkSize, minChunkSizeChars, minChunkLengthToEmbed, maxNumChunks, keepSeparator);
        return splitter.apply(documents);
    }


    @Override
    public List<Document> enrichDocumentsByKeywordMetadataEnricher(List<Document> documents) {
        KeywordMetadataEnricher enricher = new KeywordMetadataEnricher(this.chatModel, 5);
        return enricher.apply(documents);
    }

    /**
     * KeywordMetadataEnricher 是一个 DocumentTransformer(文档转换器),
     * 它利用一个生成式 AI 模型(Generative AI Model)从文档内容中提取关键词,并将这些关键词作为元数据添加到文档中
     * @param documents 输入的需要处理的Document集合
     * @param chatModel 指定用于生成关键字的AI模型
     * @param keywordCount 指定生成后每个Document提取的关键字个数
     * @return 返回处理好的扩充Document集合
     */
    @Override
    public List<Document> enrichDocumentsByKeywordMetadataEnricher(List<Document> documents, ChatModel chatModel, int keywordCount) {
        KeywordMetadataEnricher enricher = new KeywordMetadataEnricher(chatModel , keywordCount);
        return enricher.apply(documents);
    }


    /**
     * SummaryMetadataEnricher 是一个 DocumentTransformer(文档转换器),它利用一个生成式 AI 模型(Generative AI Model)为文档创建摘要,并将这些摘要作为元数据添加到文档中。
     * 它能够为当前文档生成摘要,也能为相邻文档(前一个和后一个)生成摘要
     * @param documents
     * @return
     */
    @Override
    public List<Document> enrichDocumentsBySummaryMetadataEnricher(List<Document> documents) {
        return this.enricher.apply(documents);
    }
   }

最后,将上面数据清洗过的Document集合进行向量化写入向量存储数据库中:

/**
 * @author ashao
 */
@Service
public class DocumentWriterAndStoreServiceImpl implements DocumentWriterAndStoreService {
    @Autowired
    private VectorStore vectorStore;


    /**
     * 将对象列表的内容写入文件的实现
     *
     * withDocumentMarkers:是否在输出中包含文档标记(默认值:false)。
     * metadataMode:指定要写入文件的文档内容(默认值:MetadataMode.NONE)。
     * append:如果为 true,则数据将写入文件末尾而不是开头(默认值:false)。
     *
     * @param documents 准备写入的document集合
     * @param fileName 要将文档写入到的文件的名称
     *
     */
    @Override
    public void writeDocuments(List<Document> documents, String fileName) {
        FileDocumentWriter writer = new FileDocumentWriter(fileName, true, MetadataMode.ALL, false);
        writer.accept(documents);
    }

    @Override
    public void delete(List<String> idList) {
        vectorStore.delete(idList);
    }

    @Override
    public void delete(Filter.Expression filterExpression) {
        vectorStore.delete(filterExpression);
    }

}

接下来我们编写一个接口可以测试一下效果:

 @PostMapping("/ragFiles")
 public String ragFiles(@RequestParam("file") MultipartFile file,@RequestParam(value = "userInput",defaultValue = "请帮我分析下文档") String userInput) throws IOException {
     Resource resource = documentReaderService.uploadFile(file);
     List<Document> documents1 = documentReaderService.loadMultiFile(resource);
     log.info("documents : {}",documents1);
     List<Document> documents2 = documentTransformersService.transformDocuments(documents1, chatModel, 5,userInput);

     vectorStore.doAdd(documents2);

     PromptTemplate customPromptTemplate = PromptTemplate.builder()
             .renderer(StTemplateRenderer.builder().startDelimiterToken('<').endDelimiterToken('>').build())
             .template("""
         <query>

         下面是上下文信息。

---------------------
<context>
---------------------

         给定上下文信息且没有先验知识,回答查询。

         遵循以下规则:
         1. 如果答案不在上下文中,就说“我不知道,因此不知道怎么回答”。
         2. 避免使用诸如“根据上下文……”或“所提供的信息……”之类的语句。
         """)
             .build();

     QueryAugmenter augmenter = ContextualQueryAugmenter.builder()
             //允许上下文为空
             .allowEmptyContext(true)
             .promptTemplate(customPromptTemplate)
             .build();

     VectorStoreDocumentRetriever vectorStoreDocumentRetriever = VectorStoreDocumentRetriever.builder()
             .vectorStore(vectorStore)
             .similarityThreshold(0.50)
             .build();


     RetrievalAugmentationAdvisor retrievalAugmentationAdvisor = RetrievalAugmentationAdvisor.builder()
             .documentRetriever(vectorStoreDocumentRetriever)
             .queryAugmenter(augmenter)
             .build();

     ChatOptions options = ChatOptions.builder()
             .model("qwen-long")
             .build();

     ChatClient.Builder builder = chatClient.mutate().defaultOptions(options);

     return builder.build().prompt()
             .user(userInput)
             .advisors(retrievalAugmentationAdvisor)
             .call()
             .content();

 }

简单介绍一下这个代码吧

1. QueryAugmenter augmenter = ContextualQueryAugmenter.builder()...

这部分是配置一个查询增强器(Query Augmenter) 。在 RAG 流程中,原始的用户查询可能不够明确,不足以直接用于检索或生成。查询增强器就是用来优化或改写用户原始查询的。

  • ContextualQueryAugmenter: 这是一个具体的查询增强器实现类。它的名字“Contextual”暗示它可能会利用上下文信息来改写或细化查询。

  • .builder() : 这是一个典型的构建器模式(Builder Pattern)的用法,用于创建 ContextualQueryAugmenter 的实例,提供了更灵活和可读的配置方式。

  • .allowEmptyContext(true) :

    • 作用: 这个配置允许在某些情况下,即使没有检索到相关文档(即上下文为空),查询增强器仍然可以处理查询。
    • 含义: 通常,如果 allowEmptyContextfalse,当检索结果为空时,整个 RAG 流程可能会中断或抛出错误。设置为 true 意味着,即使没有检索到任何相关文档,LLM 仍然可以尝试根据其自身知识来回答问题(尽管准确性可能降低),而不是直接报错。
  • .promptTemplate(customPromptTemplate) :

    • 作用: 指定用于查询增强的提示模板。
    • customPromptTemplate: 这是一个变量,它应该是一个预定义的字符串,包含了如何将用户查询和可能的上下文信息组合起来,形成一个更适合 LLM 处理的完整提示。例如,它可能包含占位符,让 LLM 知道如何“消化”检索到的信息。

简而言之: augmenter 的作用是根据一个自定义的提示模板来处理或改写用户查询,并且即使没有外部上下文也能工作。

2. VectorStoreDocumentRetriever vectorStoreDocumentRetriever = VectorStoreDocumentRetriever.builder()...

这部分是配置一个文档检索器(Document Retriever) ,它是 RAG 流程中“检索”阶段的核心组件,负责从知识库中找出与用户查询相关的文档。

  • VectorStoreDocumentRetriever: 这是一个基于向量存储的文档检索器。它依赖于嵌入模型将文本转化为向量,并利用向量数据库进行相似性搜索。

  • .vectorStore(vectorStore) :

    • 作用: 指定用于存储和检索文档向量的向量存储实例。

    • vectorStore: 这是一个变量,它应该是一个已经配置好的 VectorStore 对象。这个 vectorStore 负责:

      • 存储所有知识库文档的嵌入向量。
      • 接收用户查询的嵌入向量,并在其中执行相似性搜索,找到最相关的文档片段。
  • .similarityThreshold(0.50) :

    • 作用: 设置一个相似性阈值。
    • 含义: 当检索器进行向量相似性搜索时,它会计算用户查询向量与知识库中文档片段向量之间的相似度得分(通常是余弦相似度)。只有当相似度得分高于这个阈值(0.50)时,相应的文档片段才会被认为是相关的,并被返回给 LLM 作为上下文。
    • 重要性: 这个阈值决定了检索结果的相关性严格程度。值越高,要求越严格,返回的文档越少但越精确;值越低,返回的文档越多但可能包含更多不相关的。

简而言之: vectorStoreDocumentRetriever 的作用是从指定的向量存储中,根据相似性阈值检索与查询最相关的文档。

3. RetrievalAugmentationAdvisor retrievalAugmentationAdvisor = RetrievalAugmentationAdvisor.builder()...

这部分是 Spring AI 中 RAG 流程的核心协调器,它将查询增强器和文档检索器结合起来,形成一个完整的 RAG 管道。

  • RetrievalAugmentationAdvisor: 这是 Spring AI 提供的高级 API,用于封装和协调 RAG 流程。它不是直接执行检索或生成,而是作为这些子组件的“顾问”或“协调者”。

  • .documentRetriever(vectorStoreDocumentRetriever) :

    • 作用: 将之前配置好的文档检索器 (vectorStoreDocumentRetriever) 注册到 RetrievalAugmentationAdvisor 中。这意味着当需要检索文档时,Advisor 会调用这个检索器。
  • .queryAugmenter(augmenter) :

    • 作用: 将之前配置好的查询增强器 (augmenter) 注册到 RetrievalAugmentationAdvisor 中。这意味着在执行检索之前,Advisor 可能会先通过这个增强器来优化用户查询。

简而言之: retrievalAugmentationAdvisor 整合了文档检索器和查询增强器,它代表了一个完整的、可用的 RAG 管道。当你向这个 Advisor 提交一个用户查询时,它会内部协调:

  1. 可能先通过 queryAugmenter 处理查询。
  2. 然后通过 documentRetriever 检索相关文档。
  3. 最终将原始查询和检索到的文档一起提供给底层的 LLM,以生成增强的答案。

整体总结:

这段代码是在 Spring AI 框架下,构建一个可配置的 RAG 管道。它定义了:

  • 如何处理原始用户查询(通过 QueryAugmenter 进行增强和改写)。
  • 如何从向量数据库中检索相关文档(通过 VectorStoreDocumentRetriever 和相似度阈值)。
  • 如何将这两个核心功能组合起来,形成一个端到端的 RAG 解决方案(通过 RetrievalAugmentationAdvisor)。

这使得开发者能够灵活地定制 RAG 流程的不同阶段,以适应特定的应用需求。

ok啦,以上就是RAG检索相关的内容,有什么疑问和建议都可以在评论区告知。