SpringAI RAG全链路讲解

RAG简介

检索增强生成(Retrieval-augmented Generation)

对于基础大模型来说, 他只具备通用信息,他的参数都是拿公网进行训练,并且有一定的时间延迟, 无法得知一些具体业务数据和实时数据, 这些数据往往在各种文件中(比如txt、word、html、数据库...)

虽然function-call、SystemMessage可以用来解决一部分问题

但是它只能少量,并且针对的场景不一样

如果你要提供大量的业务领域信息, 就需要给他外接一个知识库:

比如

  1. 我问他退订要多少费用
  2. 这些资料可能都由产品或者需求编写在了文档中:
    a. 所以需要现在需求信息存到向量数据库(这个过程叫Embedding, 涉及到文档读取、分词、向量化存入)
  3. 去向量数据库中查询"退订费用相关信息"
  4. 将查询到的数据和对话信息再请求大模型
  5. 此时会响应退订需要多少费用

概念

向量:

向量通常用来做相似性搜索,比如语义的一维向量,可以表示词语或短语的语义相似性。例如,"你好"、"hello"和"见到你很高兴"可以通过一维向量来表示它们的语义接近程度。

然而,对于更复杂的对象,比如小狗,无法仅通过一个维度来进行相似性搜索。这时,我们需要提取多个特征,如颜色、大小、品种等,将每个特征表示为向量的一个维度,从而形成一个多维向量。例如,一只棕色的小型泰迪犬可以表示为一个多维向量 [棕色, 小型, 泰迪犬]。

如果需要检索见过更加精准, 我们肯定还需要更多维度的向量, 组成更多维度的空间,在多维向量空间中,相似性检索变得更加复杂。我们需要使用一些算法,如余弦相似度或欧几里得距离,来计算向量之间的相似性。向量数据库 会帮我实现。

文本向量化

通过向量模型即可向量化, 这里我们学到了一种新的模型, 叫"向量模型" 专门用来做文本向量化的。大语言模型不能做向量化, 所以需要单独找一个向量模型

1.deepseek不支持向量模型

2.阿里百炼有大量向量模型

a.默认模型 DashScopeEmbeddingProperties#DEFAULT_EMBEDDING_MODEL="text-embedding-v1"

复制代码
spring.ai.dashscope.embedding.options.model= text-embedding-v4

3.ollama有大量向量模型,自己拉取

以ollama为例:

xml 复制代码
spring.ai.ollama.embedding.model= nomic-embed-text
java 复制代码
@SpringBootTest
public class EmbaddingTest {

    @Test
    public void testEmbadding(@Autowired OllamaEmbeddingModel ollamaEmbeddingModel) {

        float[] embedded = ollamaEmbeddingModel.embed("我叫徐庶");
        System.out.println(embedded.length);
        System.out.println(Arrays.toString(embedded));

    }
}

从结果可以知道"我叫XX"这句话经过OllamaEmbeddingModel向量化之后得到的一个长度为768的float数组。注意,768是向量模型nomic-embed-text-v1.5固定的,不会随着句子长度而变化,不同的向量模型提供了不同的维度。

那么,我们通过这种向量模型得到一句话对应的向量有什么作用呢?非常有用,因为我们可以基于向量来判断两句话之间的相似度,举个例子:

查询跟秋田犬类似的狗, 在向量数据库中根据每个狗的特点进行多维向量, 你会发现秋田犬的向量数值和柴犬的向量数值最接近, 就可以查到类似的狗。 (当然我这里只是举例,让你对向量数据库有一个印象)

● 向量模型的本质目标,就是把语义相似的内容用"相近"的向量表示,把"不相关"内容尽量拉远。

● 所以好的向量模型能够更好的识别语义, 进行向量化.

向量数据库

对于向量模型生成出来的向量,我们可以持久化到向量数据库,并且能利用向量数据库来计算两个向量之间的相似度,或者根据一个向量查找跟这个向量最相似的向量。

在SpringAi中,VectorStore 表示向量数据库,目前支持的向量数据库有

● Azure Vector Search- The Azure vector store.

● Apache Cassandra - The Apache Cassandra vector store.

● Chroma Vector Store - The Chroma vector store.

● Elasticsearch Vector Store-TheElasticsearchvector store.可以"以向量+关键词"方式做混合检索。

深度优化更多针对文本,不是专门"向量搜索引擎"。向量存储和检索容量有限制,查询延迟高于Milvus。

● GemFire Vector Store - The GemFire vector store.

●MariaDB Vector Store - The MariaDB vector store.

● Milvus Vector Store - The Milvus vector store.

● MongoDB Atlas Vector Store - The MongoDB Atlas vector store.

● Neo4jVectorStore-TheNeo4jvector store.可以结合结构化图谱查询与向量检索,大规模嵌入检索(如

干万一亿级高维向量)性能明显落后于Milvus

● OpenSearch Vector Store - The OpenSearch vector store.

● Oracle Vector Store The Oracle Database vector store.

● PgVector Store- The PostgreSQL/PGVector vector store.

● Pinecone Vector Store - PineCone vector store.

● Qdrant Vector Store - Qdrant vector store.

● Redis Vector Store-The Redis vector store.低门槛实现小规模向量检索。对于高维大规模向量(如几百万到上亿条),性能和存储效率不如专用向量库。

● SAP Hana Vector Store - The SAP HANA vector store.

● Typesense Vector Store - The Typesense vector store.

● Weaviate Vector Store - The Weaviate vector store.

● SimpleVectorStore - A simple implementation of persistent vector storage, good for educational purposes.

其中有熟悉的几个数据库都可以用来存储向量,比如Elasticsearch、MongoDb、Neo4j、Pgsql、Redis。

视频中我会讲解2种:

  1. SimpleVectorStore 教学版向量数据库
  2. Milvus Vector Store Milvus(国产团队)、文档友好、社区国内活跃、性能最佳、市场占用率大。 实战中使用的向量数据库.

匹配检索

在这个示例中, 我分别存储了预订航班和取消预订2段说明到向量数据库中

然后通过"退票要多少钱" 进行查询

代码执行结果为:

OllamaEmbedding结果

java 复制代码
 @Bean
    public VectorStore vectorStore(OllamaEmbeddingModel embeddingModel) {
        SimpleVectorStore.SimpleVectorStoreBuilder builder = SimpleVectorStore.builder(embeddingModel);
        return builder.build();
    }
SearchRequest

可以利用searchRequest设置检索请求:

● query 代表要检索的内容

● topK 设置检索结果的前N条

  • 通常我们查询所有结果查出来, 因为查询结果最终要发给大模型, 查询过多的结果会:
    *
    1. 过多的token意味着更长延迟, 更多的费用, 并且过多上下文会超限;
      1. 研究表明过多的内容会降低 LLM 的召回性能

● similarityThreshold 设置相似度阈值, 可以通关设置分数限制召回内容相似度. 从而过滤掉废料。 (中文语料要适当降低分数) , 所以应遵循始终以"业务召回效果"为主,而不是追求网上常说的高分阙值

java 复制代码
@BeforeEach
public void init( @Autowired
                  VectorStore vectorStore) {
    // 1. 声明内容文档
    Document doc = Document.builder()
    .text("""
          预订航班:
          - 通过我们的网站或移动应用程序预订。
          - 预订时需要全额付款。
          - 确保个人信息(姓名、ID 等)的准确性,因为更正可能会产生 25 的费用。
          """)
    .build();
    Document doc2 = Document.builder()
    .text("""
          取消预订:
          - 最晚在航班起飞前 48 小时取消。
          - 取消费用:经济舱 75 美元,豪华经济舱 50 美元,商务舱 25 美元。
          - 退款将在 7 个工作日内处理。
          """)
    .build();


    // 2. 将文本进行向量化,并且存入向量数据库(无需再手动向量化)
    vectorStore.add(Arrays.asList(doc,doc2));
}

@Test
void similaritySearchTest(
    @Autowired
    VectorStore vectorStore) {
    // 3. 相似性查询
    SearchRequest searchRequest = SearchRequest
    .builder().query("预定航班")
    .topK(5)
    .similarityThreshold(0.3)
    .build();
    List<Document> results = vectorStore.similaritySearch(searchRequest);

    // 4.输出
    System.out.println(results);

}

可以看到明显阿里的向量模型归类的更加准确,Ollama的向量模型查出来后结果并不正确。 所以为了你的准确性,请选择性能更好的向量模型。 想要更快更相似的搜索,用好的向量数据库。

接入ChatClient

  1. 依赖
xml 复制代码
<dependency>
   <groupId>org.springframework.ai</groupId>
   <artifactId>spring-ai-advisors-vector-store</artifactId>
</dependency>
  1. 代码
java 复制代码
  @Bean
    public VectorStore vectorStore(DashScopeEmbeddingModel embeddingModel) {
        SimpleVectorStore.SimpleVectorStoreBuilder builder = SimpleVectorStore.builder(embeddingModel);
        return builder.build();
    }
  1. 测试
    实际你会发现, 最核心的是通过拦截器:QuestionAnswerAdvisor . 你应该能猜到底层肯定会通过拦截对话将相似内容发给大模型。 可以结合SimpleLoggerAdvisor 查看日志内容.
java 复制代码
@SpringBootTest
public class SimpleVectorStoreTest {

    @BeforeEach
    public void init( @Autowired
            VectorStore vectorStore) {
        // 1. 声明内容文档
        Document doc = Document.builder()
                .text("""
                预订航班:
                - 通过我们的网站或移动应用程序预订。
                - 预订时需要全额付款。
                - 确保个人信息(姓名、ID 等)的准确性,因为更正可能会产生 25 的费用。
                """)
                .build();
        Document doc2 = Document.builder()
                .text("""
                取消预订:
                - 最晚在航班起飞前 48 小时取消。
                - 取消费用:经济舱 75 美元,豪华经济舱 50 美元,商务舱 25 美元。
                - 退款将在 7 个工作日内处理。
                """)
                .build();


        // 2. 将文本进行向量化,并且存入向量数据库(无需再手动向量化)
        vectorStore.add(Arrays.asList(doc,doc2));
    }
 

    @Test
    void chatRagTest(
            @Autowired
            VectorStore vectorStore,
            @Autowired DashScopeChatModel chatModel
            ) {

        ChatClient chatClient = ChatClient.builder(chatModel)
                .build();

        String message="退费需要多少费用?";
        String content = chatClient.prompt().user(message)
                .advisors(
                        new SimpleLoggerAdvisor(),
                        QuestionAnswerAdvisor.builder(vectorStore)
                                .searchRequest(
                                        SearchRequest
                                        .builder().query(message)
                                                .topK(5)
                                                .similarityThreshold(0.3)
                                                .build())
                                .build()
                ).call().content();

        System.out.println(content);

    }

}

RetrievalAugmentationAdvisor

● 查询空时扩展策略 :

java 复制代码
.queryAugmenter(ContextualQueryAugmenter.builder()
                        .allowEmptyContext(false)
                        .emptyContextPromptTemplate(PromptTemplate.builder().template("用户查询位于知识库之外。礼貌地告知用户您无法回答").build())
                        .build())
  • 查询检索器
    • 检索提示词重写

用户查询可能存在表述模糊、语义不完整、关键词缺失等问题,通过查询改写可优化查询的语义表征,让检索更精准:

java 复制代码
.queryTransformers(RewriteQueryTransformer.builder()
                        .chatClientBuilder(ChatClient.builder(dashScopeChatModel))
                        .targetSearchSystem("航空票务助手")
                        .build())

○ 翻译重写

java 复制代码
.queryTransformers(TranslationQueryTransformer.builder()
                                    .chatClientBuilder(ChatClient.builder(dashScopeChatModel))
                                    .targetLanguage("中文")
                                    .build())

● 后置处理器:需要文档后处理和重排序

● 实现复杂的 RAG 流水线

java 复制代码
@Test
    public void testRag3(@Autowired VectorStore vectorStore,
                        @Autowired DashScopeChatModel dashScopeChatModel) {


        chatClient = ChatClient.builder(dashScopeChatModel)
                .defaultAdvisors(SimpleLoggerAdvisor.builder().build())
                .build();

        // 增强多
        Advisor retrievalAugmentationAdvisor = RetrievalAugmentationAdvisor.builder()
                // 查 = QuestionAnswerAdvisor
                .documentRetriever(VectorStoreDocumentRetriever.builder()
                        .similarityThreshold(0.50)
                        .vectorStore(vectorStore)
                        .build())
                // 检索为空时,返回提示
                /*.queryAugmenter(ContextualQueryAugmenter.builder()
                        .allowEmptyContext(false)
                        .emptyContextPromptTemplate(PromptTemplate.builder().template("用户查询位于知识库之外。礼貌地告知用户您无法回答").build())
                        .build())*/
                // 相似性查询内容转换
                /*.queryTransformers(RewriteQueryTransformer.builder()
                        .chatClientBuilder(ChatClient.builder(dashScopeChatModel))
                        .targetSearchSystem("航空票务助手")
                        .build())*/
                // 检索后文档监控、操作
                /*.documentPostProcessors((query, documents) -> {
                    System.out.println("Original query: " + query.text());
                    System.out.println("Retrieved documents: " + documents.size());
                    return documents;
                })*/
                .build();

        String answer = chatClient.prompt()
                .advisors(retrievalAugmentationAdvisor)
                .user("退一张票大概要多少费用?希望别扣太多啊")
                .call()
                .content();

        System.out.println(answer);
    }

    @TestConfiguration
    static class TestConfig {

        @Bean
        public VectorStore vectorStore(DashScopeEmbeddingModel embeddingModel) {
            return SimpleVectorStore.builder(embeddingModel).build();
        }
    }

ELT

在之前,我们主要完成了数据检索阶段, 但是完整的RAG流程还需要有emedding阶段, 即:

提取(读取)、转换(分隔)和加载(写入)

1.Document Loaders 文档读取器

springai提供了以下文档阅读器

● JSON

● 文本

● HTML(JSoup)

● Markdown

● PDF页面

● PDF段落

● Tika(DOCX、PPTX、HTML......)

alibaba ai也提供了很多阅读器
https://github.com/alibaba/spring-ai-alibaba/tree/main/community/document-parsers

● document-parser-apache-pdfbox:用于解析 PDF 格式文档。

● document-parser-bshtml:用于解析基于 BSHTML 格式的文档。

● document-parser-pdf-tables:专门用于从 PDF 文档中提取表格数据。

● document-parser-bibtex:用于解析 BibTeX 格式的参考文献数据。

● document-parser-markdown:用于解析 Markdown 格式的文档。

● document-parser-tika:一个多功能文档解析器,支持多种文档格式。

以及网络来源文档读取器:
https://github.com/alibaba/spring-ai-alibaba/tree/main/community/document-readers

1.1 读取Text
java 复制代码
 @Test
    public void testReaderText(@Value("classpath:rag/terms-of-service.txt") Resource resource) {
        TextReader textReader = new TextReader(resource);
        List<Document> documents = textReader.read();

        for (Document document : documents) {
            System.out.println(document.getText());
        }
    }
1.2 读取markdown
xml 复制代码
<dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-markdown-document-reader</artifactId>
        </dependency>
java 复制代码
@Test
    public void testReaderMD(@Value("classpath:rag/9_横店影视股份有限公司_0.md") Resource resource) {
        MarkdownDocumentReaderConfig config = MarkdownDocumentReaderConfig.builder()
                .withHorizontalRuleCreateDocument(true)     // 分割线创建新document
                .withIncludeCodeBlock(false)                // 代码创建新document
                .withIncludeBlockquote(false)               // 引用创建新document
                .withAdditionalMetadata("filename", resource.getFilename())    // 每个document添加的元数据
                .build();

        MarkdownDocumentReader markdownDocumentReader = new MarkdownDocumentReader(resource, config);
        List<Document> documents = markdownDocumentReader.read();
        for (Document document : documents) {
            System.out.println(document.getText());
        }
    }
1.3 pdf

PagePdfDocumentReader一页1个document

ParagraphPdfDocumentReader 按pdf目录分成一个个document

xml 复制代码
 <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-markdown-document-reader</artifactId>
        </dependency>
java 复制代码
@Test
public void testReaderPdf(@Value("classpath:rag/平安银行2023年半年度报告摘要.pdf") Resource resource) {

    PagePdfDocumentReader pdfReader = new PagePdfDocumentReader(resource,
            PdfDocumentReaderConfig.builder()
                    .withPageTopMargin(0)
                    .withPageExtractedTextFormatter(ExtractedTextFormatter.builder()
                            .withNumberOfTopTextLinesToDelete(0)
                            .build())
                    .withPagesPerDocument(1)
                    .build());

    List<Document> documents = pdfReader.read();
    for (Document document : documents) {
        System.out.println(document.getText());
    }
}


// 必需要带目录,  按pdf的目录分document
@Test
public void testReaderParagraphPdf(@Value("classpath:rag/平安银行2023年半年度报告.pdf") Resource resource) {
    ParagraphPdfDocumentReader pdfReader = new ParagraphPdfDocumentReader(resource,
            PdfDocumentReaderConfig.builder()
                    // 不同的PDF生成工具可能使用不同的坐标系 , 如果内容识别有问题, 可以设置该属性为true
                    .withReversedParagraphPosition(true)
                   .withPageTopMargin(0)       // 上边距
                    .withPageExtractedTextFormatter(ExtractedTextFormatter.builder()
                            // 从页面文本中删除前 N 行
                            .withNumberOfTopTextLinesToDelete(0)
                            .build())
                    .build());

    List<Document> documents = pdfReader.read();
    for (Document document : documents) {
        System.out.println(document.getText());
    }
}
1.4 B站:
xml 复制代码
  <dependency>
        <groupId>com.alibaba.cloud.ai</groupId>
        <artifactId>spring-ai-alibaba-starter-document-reader-bilibili</artifactId>
    </dependency>
java 复制代码
@Test
void bilibiliDocumentReaderTest() {
    BilibiliDocumentReader bilibiliDocumentReader = new BilibiliDocumentReader(
            "https://www.bilibili.com/video/BV1C5UxYuEc2/?spm_id_from=333.1387.upload.video_card.click&vd_source=fa810d8b8d6765676cb343ada918d6eb");
    List<Document> documents = bilibiliDocumentReader.get();
    System.out.println(documents);
}

2. DocumentSplitter‌ 文档拆分器(转换器)

由于文本读取过来后, 还需要分成一段一段的片段(分块chunk), 分块是为了更好地拆分语义单元,这样在后面可以更精确地进行语义相似性检索,也可以避免LLM的Token限制。

SpringAi就提供了一个文档拆分器:

● TextSplitter 抽象类

● TokenTextSplitter 按token分隔

2.1TokenTextSplitter
  1. chunkSize (默认值: 800) 100
    ○ 每个文本块的目标大小,以token为单位

  2. minChunkSizeChars (默认值: 350) 建议小一点
    ○ 如果块超过最小块字符数( 按照块的最后. ! ? \n 符号截取)
    ○ 如果块没超过最小块字符数, 不会按照符号截取(保留原块)。

    本服务条款适用于您对图灵航空 的体验。预订航班,即表示您同意这些条款。

    1. 预订航班
    • 通过我们的网站或移动应用程序预订。
    • 预订时需要全额付款。 \n
    • 确保个人信息(姓名、ID 等)的准确性,因为更正可能会产生 25
  3. minChunkLengthToEmbed (默认值: 5) 5
    ○ 丢弃短于此长度的文本块(如果去掉\r\n, 只剩5个有效文本, 那就丢掉)

本服务条

  1. maxNumChunks (默认值: 10000)

    ○ 最多能分多少个块, 超过了就不管了

  2. keepSeparator (默认值: true)

    ○ 是否在块中保留分隔符、换行符 \r\n

java 复制代码
 @Test
    public void testTokenTextSplitter(@Value("classpath:rag/terms-of-service.txt") Resource resource) {
        TextReader textReader = new TextReader(resource);
        textReader.getCustomMetadata().put("filename", resource.getFilename());
        List<Document> documents = textReader.read();


        TokenTextSplitter splitter = new TokenTextSplitter(1000, 400, 10, 5000, true);
        List<Document> apply = splitter.apply(documents);

        apply.forEach(System.out::println);
    }

整个流程如下:

2.2自定分割器:

支持中英文:同时支持中文和英文标点符号

java 复制代码
package com.xushu.springai.rag.ELT;

 

public class ChineseTokenTextSplitter extends TextSplitter {

	private static final int DEFAULT_CHUNK_SIZE = 800;

	private static final int MIN_CHUNK_SIZE_CHARS = 350;

	private static final int MIN_CHUNK_LENGTH_TO_EMBED = 5;

	private static final int MAX_NUM_CHUNKS = 10000;

	private static final boolean KEEP_SEPARATOR = true;

	private final EncodingRegistry registry = Encodings.newLazyEncodingRegistry();

	private final Encoding encoding = this.registry.getEncoding(EncodingType.CL100K_BASE);

	// The target size of each text chunk in tokens
	private final int chunkSize;

	// The minimum size of each text chunk in characters
	private final int minChunkSizeChars;

	// Discard chunks shorter than this
	private final int minChunkLengthToEmbed;

	// The maximum number of chunks to generate from a text
	private final int maxNumChunks;

	private final boolean keepSeparator;

	public ChineseTokenTextSplitter() {
		this(DEFAULT_CHUNK_SIZE, MIN_CHUNK_SIZE_CHARS, MIN_CHUNK_LENGTH_TO_EMBED, MAX_NUM_CHUNKS, KEEP_SEPARATOR);
	}

	public ChineseTokenTextSplitter(boolean keepSeparator) {
		this(DEFAULT_CHUNK_SIZE, MIN_CHUNK_SIZE_CHARS, MIN_CHUNK_LENGTH_TO_EMBED, MAX_NUM_CHUNKS, keepSeparator);
	}

	public ChineseTokenTextSplitter(int chunkSize, int minChunkSizeChars, int minChunkLengthToEmbed, int maxNumChunks,
			boolean keepSeparator) {
		this.chunkSize = chunkSize;
		this.minChunkSizeChars = minChunkSizeChars;
		this.minChunkLengthToEmbed = minChunkLengthToEmbed;
		this.maxNumChunks = maxNumChunks;
		this.keepSeparator = keepSeparator;
	}

	public static Builder builder() {
		return new Builder();
	}

	@Override
	protected List<String> splitText(String text) {
		return doSplit(text, this.chunkSize);
	}

	protected List<String> doSplit(String text, int chunkSize) {
		if (text == null || text.trim().isEmpty()) {
			return new ArrayList<>();
		}

		List<Integer> tokens = getEncodedTokens(text);
		List<String> chunks = new ArrayList<>();
		int num_chunks = 0;
		// maxNumChunks多能分多少个块, 超过了就不管了
		while (!tokens.isEmpty() && num_chunks < this.maxNumChunks) {
			// 按照chunkSize进行分隔
			List<Integer> chunk = tokens.subList(0, Math.min(chunkSize, tokens.size()));
			String chunkText = decodeTokens(chunk);

			// Skip the chunk if it is empty or whitespace
			if (chunkText.trim().isEmpty()) {
				tokens = tokens.subList(chunk.size(), tokens.size());
				continue;
			}

			// Find the last period or punctuation mark in the chunk
			int lastPunctuation =
					Math.max(chunkText.lastIndexOf('.'),
					Math.max(chunkText.lastIndexOf('?'),
					Math.max(chunkText.lastIndexOf('!'),
					Math.max(chunkText.lastIndexOf('\n'),
					Math.max(chunkText.lastIndexOf('。'),
					Math.max(chunkText.lastIndexOf('?'),
					chunkText.lastIndexOf('!')
					))))));

			// 按照句子截取之后长度 > minChunkSizeChars
			if (lastPunctuation != -1 && lastPunctuation > this.minChunkSizeChars) {
				// 保留按照句子截取之后的内容
				chunkText = chunkText.substring(0, lastPunctuation + 1);
			}
			// 按照句子截取之后长度 < minChunkSizeChars 保留原块


			// keepSeparator=true 替换/r/n   =false不管
			String chunkTextToAppend = (this.keepSeparator) ? chunkText.trim()
					: chunkText.replace(System.lineSeparator(), " ").trim();

			// 替换/r/n之后的内容是不是<this.minChunkLengthToEmbed 忽略
			if (chunkTextToAppend.length() > this.minChunkLengthToEmbed) {
				chunks.add(chunkTextToAppend);
			}

			// Remove the tokens corresponding to the chunk text from the remaining tokens
			tokens = tokens.subList(getEncodedTokens(chunkText).size(), tokens.size());

			num_chunks++;
		}

		// Handle the remaining tokens
		if (!tokens.isEmpty()) {
			String remaining_text = decodeTokens(tokens).replace(System.lineSeparator(), " ").trim();
			if (remaining_text.length() > this.minChunkLengthToEmbed) {
				chunks.add(remaining_text);
			}
		}

		return chunks;
	}

	private List<Integer> getEncodedTokens(String text) {
		Assert.notNull(text, "Text must not be null");
		return this.encoding.encode(text).boxed();
	}

	private String decodeTokens(List<Integer> tokens) {
		Assert.notNull(tokens, "Tokens must not be null");
		var tokensIntArray = new IntArrayList(tokens.size());
		tokens.forEach(tokensIntArray::add);
		return this.encoding.decode(tokensIntArray);
	}

	public static final class Builder {

		private int chunkSize = DEFAULT_CHUNK_SIZE;

		private int minChunkSizeChars = MIN_CHUNK_SIZE_CHARS;

		private int minChunkLengthToEmbed = MIN_CHUNK_LENGTH_TO_EMBED;

		private int maxNumChunks = MAX_NUM_CHUNKS;

		private boolean keepSeparator = KEEP_SEPARATOR;

		private Builder() {
		}

		public Builder withChunkSize(int chunkSize) {
			this.chunkSize = chunkSize;
			return this;
		}

		public Builder withMinChunkSizeChars(int minChunkSizeChars) {
			this.minChunkSizeChars = minChunkSizeChars;
			return this;
		}

		public Builder withMinChunkLengthToEmbed(int minChunkLengthToEmbed) {
			this.minChunkLengthToEmbed = minChunkLengthToEmbed;
			return this;
		}

		public Builder withMaxNumChunks(int maxNumChunks) {
			this.maxNumChunks = maxNumChunks;
			return this;
		}

		public Builder withKeepSeparator(boolean keepSeparator) {
			this.keepSeparator = keepSeparator;
			return this;
		}

		public ChineseTokenTextSplitter build() {
			return new ChineseTokenTextSplitter(this.chunkSize, this.minChunkSizeChars, this.minChunkLengthToEmbed,
					this.maxNumChunks, this.keepSeparator);
		}

	}

}
2.3分隔经验:

过细分块的潜在问题

  1. ‌语义割裂‌: 破坏上下文连贯性,影响模型理解‌ 。
  2. ‌计算成本增加‌:分块过细会导致向量嵌入和检索次数增多,增加时间和算力开销‌。
  3. ‌信息冗余与干扰‌:碎片化的文本块可能引入无关内容,干扰检索结果的质量,降低生成答案的准确性‌。
    分块过大的弊端
  4. ‌信息丢失风险‌:过大的文本块可能超出嵌入模型的输入限制,导致关键信息未被有效编码‌。
  5. ‌检索精度下降‌:大块内容可能包含多主题混合,与用户查询的相关性降低,影响模型反馈效果‌。
‌场景‌ 分块策略‌ 参数参考‌
微博/短文本 句子级分块,保留完整语义 每块100-200字符‌
学术论文 段落级分块,叠加10%重叠 每块300-500字符‌
法律合同 条款级分块,严格按条款分隔 每块200-400字符‌
长篇小说 章节级分块,过长段落递归拆分为段落 每块500-1000字符‌

不要过分指望按照文本主题进行分隔, 因为实战中的资料太多而且没有规律, 根本没办法保证每个chunk是一个完整的主题内容, 哪怕人为干预也很难。 所以实战中往往需要结合资料来决定分割器,大多数情况就是按token数分, 因为没有完美的, 还可以加入人工干预,或者大模型分隔。

2.4分块五种策略

以下是 RAG 的五种分块策略:

2.4.2. 1)固定大小分块

生成块的最直观和直接的方法是根据预定义的字符、单词或标记数量将文本分成统一的段。

由于直接分割会破坏语义流,因此建议在两个连续的块之间保持一些重叠(上图蓝色部分)。

这很容易实现。而且,由于所有块的大小相同,它简化了批处理。

但有一个大问题。这通常会打断句子(或想法)。因此,重要的信息很可能会分散到不同的块之间。

2.4.2 .2)语义分块

这个想法很简单。

  • 根据句子、段落或主题部分等有意义的单位对文档进行细分。
  • 接下来,为每个片段创建嵌入。
  • 假设我从第一个片段及其嵌入开始。
    • 如果第一个段的嵌入与第二个段的嵌入具有较高的余弦相似度,则这两个段形成一个块。
    • 这种情况一直持续到余弦相似度显著下降。
    • 一旦发生这种情况,我们就开始新的部分并重复。

输出可能如下所示:

与固定大小的块不同,这保持了语言的自然流畅并保留了完整的想法。

由于每个块都更加丰富,它提高了检索准确性,进而使 LLM 产生更加连贯和相关的响应。

一个小问题是,它依赖于一个阈值来确定余弦相似度是否显著下降,而这个阈值在不同文档之间可能会有所不同。

2.4.3 .3)递归分块

这也很简单。

首先,根据固有分隔符(如段落或章节)进行分块。

接下来,如果每个块的大小超出了预定义的块大小限制,则将其拆分成更小的块。但是,如果块符合块大小限制,则不再进行进一步拆分。

输出可能如下所示:

如上图:

● 首先,我们定义两个块(紫色的两个段落)。

● 接下来,第 1 段被进一步分成更小的块。

与固定大小的块不同,这种方法还保持了语言的自然流畅性并保留了完整的想法。

然而,在实施和计算复杂性方面存在一些额外的开销。

2.4.5 .4)基于文档结构的分块

这是另一种直观的方法。

它利用文档的固有结构(如标题、章节或段落)来定义块边界。

这样,它就通过与文档的逻辑部分对齐来保持结构完整性。

输出可能如下所示:

也就是说,这种方法假设文档具有清晰的结构,但事实可能并非如此。

此外,块的长度可能会有所不同,可能会超出模型令牌的限制。您可以尝试使用递归拆分进行合并。

2.4.5 .5)基于LLM的分块

既然每种方法都有优点和缺点,为什么不使用 LLM 来创建块呢?

可以提示 LLM 生成语义上孤立且有意义的块。

显然,这种方法将确保较高的语义准确性,因为 LLM 可以理解超越简单启发式方法(用于上述四种方法)的上下文和含义。

唯一的问题是,它是这里讨论的所有五种技术中计算要求最高的分块技术。

此外,由于 LLM 通常具有有限的上下文窗口,因此需要注意这一点。

每种技术都有其自身的优势和劣势。

我观察到语义分块在很多情况下效果很好,但同样,您需要进行测试。

选择将在很大程度上取决于内容的性质、嵌入模型的功能、计算资源等。

我们很快就会对这些策略进行实际演示。

同时,如果您错过了,昨天我们讨论了构建依赖于成对内容相似性的强大 NLP 系统的技术(RAG 就是其中之一)。

2.5 ContentFormatTransformer

检索到的内容最终会发给大模型, 由该组件决定发送到模型的RAG内容

java 复制代码
private static final String DEFAULT_TEXT_TEMPLATE = String.format("%s\n\n%s", TEMPLATE_METADATA_STRING_PLACEHOLDER,
			TEMPLATE_CONTENT_PLACEHOLDER);

即:假设:

● 文本内容:"The World is Big and Salvation Lurks Around the Corner"

● 元数据:Map.of("fileName", "xushu.pdf")

最终发送给大模型的格式化内容是:

java 复制代码
source: aaa.pdf
  
The World is Big and Salvation Lurks Around the Corner

很少会去改, 了解即可

2.6KeywordMetadataEnriching

使用生成式AI模型从文档内容中提取关键词并将其添加为元数据,为文档添加关键词标签,提升检索精度

new KeywordMetadataEnricher(chatModel, 5);

  1. chatModel 需要提取关键字的模型
  2. 关键字数量
java 复制代码
 @Test
    public void testKeywordMetadataEnricher(
            @Autowired DashScopeChatModel chatModel,
            @Value("classpath:rag/terms-of-service.txt") Resource resource) {
        TextReader textReader = new TextReader(resource);
        textReader.getCustomMetadata().put("filename", resource.getFilename());
        List<Document> documents = textReader.read();


        ChineseTokenTextSplitter splitter = new ChineseTokenTextSplitter();
        List<Document> apply = splitter.apply(documents);
    
            KeywordMetadataEnricher enricher = new KeywordMetadataEnricher(chatModel, 5);
            apply=  enricher.apply(apply);

        for (Document document : apply) {
            System.out.println(document.getText());
            System.out.println(document.getText().length());
        }
        apply.forEach(System.out::println);
    }

作用

帮助做元数据过滤。 并不参数向量数据库的相似性检索
问题

KeywordMetadataEnriching 生成出来的关键字无法进行元数据过滤

在SpringAi1.0.1 中已支持KeywordMetadataEnriching 自定义模版:

● Enhanced KeywordMetadataEnricher with custom template functionality to provide more flexible metadata enrichment capabilities 2082a59

2.7SummaryMetadataEnricher

使用生成式AI模型为文档创建摘要并将其添加为元数据。它可以为当前文档以及相邻文档(前一个和后一个)生成摘要,以提供更丰富的上下文信息 。

场景: 有顺序关联的文档,比如西游记小说的RAG,'三打白骨精的故事以及后续剧情'。

技术文档 :前后章节有依赖关系

教程内容 :步骤之间有逻辑顺序

法律文档 :条款之间有关联性

学术论文:章节间有逻辑递进

java 复制代码
 @Test
    public void testSummaryMetadataEnricher(
            @Autowired DashScopeChatModel chatModel,
            @Value("classpath:rag/terms-of-service.txt") Resource resource) {
        TextReader textReader = new TextReader(resource);
        textReader.getCustomMetadata().put("filename", resource.getFilename());
        List<Document> documents = textReader.read();


        ChineseTokenTextSplitter splitter = new ChineseTokenTextSplitter();
        List<Document> apply = splitter.apply(documents);

        SummaryMetadataEnricher enricher = new SummaryMetadataEnricher(chatModel,
                List.of(SummaryMetadataEnricher.SummaryType.PREVIOUS,
                        SummaryMetadataEnricher.SummaryType.CURRENT,
                        SummaryMetadataEnricher.SummaryType.NEXT));


        apply = enricher.apply(apply);
    }
复制代码
Document{id='66e859b2-f719-43ca-8466-d97f1880b530', text='更改预订

- 允许在航班起飞前 24 小时更改。
- 通过在线更改或联系我们的支持人员。
- 改签费:经济舱 50,豪华经济舱 30,商务舱免费。
3.
取消预订

- 最晚在航班起飞前 48 小时取消。', media='null', metadata={prev_section_summary=The key topics and entities of the section include:

1. **Service Terms Agreement**: The terms apply to the user's experience with 春秋航空 (Chunqiu Airlines).
2. **Acceptance of Terms**: By booking a flight, the user agrees to these terms.
3. **Flight Booking**:
   - Bookings can be made via the website or mobile application.
   - Full payment is required at the time of booking.
   - Personal information must be accurate to avoid a correction fee of 25 units.

Entities:
- 春秋航空 (Chunqiu Airlines)
- Website and mobile application
- Flight bookings
- Payment process
- Personal information (name, ID), charset=UTF-8, filename=terms-of-service.txt, source=terms-of-service.txt, section_summary=The key topics and entities of the section are as follows:

1. **更改预订 (Modifying Reservations)**:
   - Allowed within 24 hours before the flight departure.
   - Can be done either online or by contacting support personnel.
   - Change fees: 
     - Economy class: 50
     - Premium economy class: 30
     - Business class: Free

2. **取消预订 (Canceling Reservations)**:
   - Must be done at least 48 hours before the flight departure.

Summary: The section outlines the policies for modifying and canceling reservations, including timeframes and associated fees for different classes (economy, premium economy, and business)., next_section_summary=The section outlines the **cancellation fees** for different cabin classes and the **refund processing time**. Key entities include:  
- Cancellation fees: **Economy class (75 USD)**, **Premium Economy class (50 USD)**, **Business class (25 USD)**.  
- Refund processing time: **7 business days**.}, score=null}

3.文本向量化

向量化存储 就是通过向量模型库进行向量化

代码:

依然通过Qwen向量模型进行向量化: 将第分割的chunk进行向量化

java 复制代码
 @Test
    public void testTokenTextSplitter( 
            @Autowired DashScopeEmbeddingModel embeddingModel,
            @Value("classpath:rag/terms-of-service.txt") Resource resource) {
     
        TextReader textReader = new TextReader(resource);
        textReader.getCustomMetadata().put("filename", resource.getFilename());
        List<Document> documents = textReader.read();


        ChineseTokenTextSplitter splitter = new ChineseTokenTextSplitter(100);
        List<Document> apply = splitter.apply(documents);

        for (Document document : apply) {
            float[] embedded = embeddingModel.embed(document);
        }
 
    }

4.存储向量

但是我告诉你其实 , 我们通过向量数据库存储document, 可以省略向量化这一步, 向量数据库会在底层自动完成向量化

for (Document document : apply) {
float[] embedded = embeddingModel.embed(document);
}

复制代码
// 替换为: 写入=向量化+存储
vectorStore.write(apply);
java 复制代码
@Test
    public void testTokenTextSplitter(
            @Autowired VectorStore vectorStore,
            @Value("classpath:rag/terms-of-service.txt") Resource resource) {
        TextReader textReader = new TextReader(resource);
        textReader.getCustomMetadata().put("filename", resource.getFilename());
        List<Document> documents = textReader.read();


        ChineseTokenTextSplitter splitter = new ChineseTokenTextSplitter(100);
        List<Document> apply = splitter.apply(documents);

        vectorStore.add(apply);
    }

5.向量数据库检索

代码:

需要先将文本进行向量化, 然后去向量数据库查询,

java 复制代码
// 3. 相似性查询
        SearchRequest searchRequest = SearchRequest
                .builder().query("预定航班")
                .topK(5)
                .similarityThreshold(0.3)
                .build();
        List<Document> results = vectorStore.similaritySearch(searchRequest);

        // 4.输出
        System.out.println(results);

完整代码:

java 复制代码
@Test
public void testRag(
        @Autowired VectorStore vectorStore,
        @Value("classpath:rag/terms-of-service.txt") Resource resource) {
    // 1. 读取
    TextReader textReader = new TextReader(resource);
    textReader.getCustomMetadata().put("filename", resource.getFilename());
    List<Document> documents = textReader.read();

    // 2.分隔
    ChineseTokenTextSplitter splitter = new ChineseTokenTextSplitter(100);
    List<Document> apply = splitter.apply(documents);
    // 3. 向量化+写入
    vectorStore.write(apply);

    // 3. 相似性查询
    SearchRequest searchRequest = SearchRequest
            .builder().query("退费需要多少费用")
            .topK(5)
            .similarityThreshold(0.3)
            .build();
    List<Document> results = vectorStore.similaritySearch(searchRequest);

    // 4.输出
    System.out.println(results);
}

6.对话阶段

如果结合ChatClient 可以直接将检索和Advisor整合在一起

java 复制代码
@Test
    public void testRagToLLM(
            @Autowired VectorStore vectorStore,
            @Autowired DashScopeChatModel chatModel,
            @Value("classpath:rag/terms-of-service.txt") Resource resource) {
        TextReader textReader = new TextReader(resource);
        textReader.getCustomMetadata().put("filename", resource.getFilename());
        List<Document> documents = textReader.read();


        ChineseTokenTextSplitter splitter = new ChineseTokenTextSplitter(100);
        List<Document> apply = splitter.apply(documents);

        vectorStore.write(apply);

        // 3. 相似性查询  
        ChatClient chatClient = ChatClient.builder(chatModel)
                .build();

        String message="退费需要多少费用?";
        String content = chatClient.prompt().user(message)
                .advisors(
                        new SimpleLoggerAdvisor(),
                        QuestionAnswerAdvisor.builder(vectorStore)
                                .searchRequest(
                                        SearchRequest
                                                .builder().query(message)
                                                .topK(5)
                                                .similarityThreshold(0.3)
                                                .build())
                                .build()
                ).call().content();

        System.out.println(content);
    }

SpringAI整个过程原理:

提升检索精度---rerank(重排序)

为什么需要 rerank

传统的向量检索存在几个关键问题:
语义相似度的局限性 :向量检索主要基于余弦相似度等数学计算,但相似的向量表示不一定意味着内容一定绝对相关。单纯的向量相似度无法充分理解查询的真实意图和上下文。
排序质量不佳 :初始检索的排序往往不是最优的,可能将不太相关的文档排在前面,尤其性能差的向量模型更为明显。
上下文理解缺失:传统检索(完全依赖向量数据库和向量模型)缺乏对查询和文档完整上下文的深度理解,容易出现语义漂移问题。

重排序:

主要在检索阶段进行改进:

二阶段优化架构 :rerank 采用"粗排+精排"的两阶段架构。第一阶段快速检索大量候选文档,第二阶段使用专门的重排序模型进行精确评分。
专业化模型 :重排序模型(如 gte-rerank-hybrid)专门针对文档相关性评估进行训练,能够更准确地计算查询与文档的语义匹配度。
分数阈值过滤 :通过设置最小分数阈值,可以过滤掉低质量的文档,确保只有高相关性的内容被保留。在实现中可以看到这个过滤逻辑:
动态参数调整 :支持根据实际效果动态调整 topN 等参数,优化最终返回的文档数量和质量。

代码

说明:

为了更好的测试

  1. 我这里用的事ollama一个性能较差的向量模型, 这样才能更好体现他瞎排的顺序
  2. 我分隔的比较小new ChineseTokenTextSplitter(80,10,5,10000,true);为了有更多的document;
  3. 粗排需要设置数量较大的topk(建议200) , 精排(默认topN5)
java 复制代码
@SpringBootTest
public class RerankTest {

    @BeforeEach
    public void init(
            @Autowired VectorStore vectorStore,
            @Value("classpath:rag/terms-of-service.txt") Resource resource) {
        // 读取
        TextReader textReader = new TextReader(resource);
        textReader.getCustomMetadata().put("filename", resource.getFilename());
        List<Document> documents = textReader.read();


        // 分隔
        ChineseTokenTextSplitter splitter = new ChineseTokenTextSplitter(80,10,5,10000,true);
        List<Document> apply = splitter.apply(documents);


        // 存储向量(内部会自动向量化)
        vectorStore.add(apply);
    }

    @TestConfiguration
    static class TestConfig {

        @Bean
        public VectorStore vectorStore(OllamaEmbeddingModel embeddingModel) {
            return SimpleVectorStore.builder(embeddingModel).build();
        }
    }


    @Test
    public void testRerank(
            @Autowired DashScopeChatModel dashScopeChatModel,
            @Autowired VectorStore vectorStore,
            @Autowired DashScopeRerankModel rerankModel) {

        ChatClient chatClient = ChatClient.builder(dashScopeChatModel)
                .build();


        RetrievalRerankAdvisor retrievalRerankAdvisor =
                new RetrievalRerankAdvisor(vectorStore, rerankModel
                        , SearchRequest.builder().topK(200).build());

        String content = chatClient.prompt().user("退票费用")
                .advisors(retrievalRerankAdvisor)
                .call()
                .content();


        System.out.println(content);

    }
}

重排前:

排第一的doucment跟退费并没有关系:

重排后:

排第一的document:

相关推荐
取个鸣字真的难3 小时前
简单快速的用 Claude Code 帮你创建 PPT 生成 Skills
vscode·powerpoint·ai编程
Stuomasi_xiaoxin3 小时前
Windows 安装 OpenCode 完全指南:从 npm 踩坑到 Chocolatey 完美部署
python·ai编程·opencode
GISer_Jing20 小时前
Memory、Rules、Skills、MCP如何重塑AI编程
前端·人工智能·aigc·ai编程
github.com/starRTC21 小时前
Claude Code中英文系列教程24:使用钩子hooks扩展 Claude Code 的行为
人工智能·ai编程
Testopia1 天前
AI编程实例 - 爆款文章预测:K-Means聚类与分类算法的实践
人工智能·分类·kmeans·ai编程·聚类
ssxueyi1 天前
AI编程之智谱+claude code详细安装使用教程
人工智能·ai编程·智谱·claude code
Aliex_git1 天前
Claude Code 使用笔记(四)- GitHub Claude 审查助手
人工智能·笔记·学习·github·ai编程
RANCE_atttackkk1 天前
使用redis+springboot+langchain4j实现AI对话持久化
java·数据库·spring boot·redis·缓存·ai·ai编程
LeenixP1 天前
GitHub Copilot 调用第三方模型API
ai·github·copilot·ai编程·oai compatible