11. Spring AI + ELT

11. Spring AI + ELT

文章目录

ELT

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

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

Document Loaders

Document Loaders 文档读取器

springai提供了以下文档阅读器

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

读取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());
        }
    }
读取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  false 会创建
                .withIncludeBlockquote(false)               // 引用创建新document false 会创建
                .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());
        }
    }
pdf

● PagePdfDocumentReader一页1个document

● ParagraphPdfDocumentReader 按pdf目录分成一个个document

java 复制代码
<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());
        }
    }
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);
    }
DocumentSplitter‌

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

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

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

TokenTextSplitter
  1. chunkSize (默认值: 800) 100
    ○ 每个文本块的目标大小,以token为单位
  2. minChunkSizeChars (默认值: 350) 建议小一点
    ○ 如果块超过最小块字符数( 按照块的最后. ! ? \n 符号截取)
    ○ 如果块没超过最小块字符数, 不会按照符号截取(保留原块)。
java 复制代码
本服务条款适用于您对图灵航空 的体验。预订航班,即表示您同意这些条款。
1. 预订航班
- 通过我们的网站或移动应用程序预订。
- 预订时需要全额付款。  \n
- 确保个人信息(姓名、ID 等)的准确性,因为更正可能会产生 25
  1. minChunkLengthToEmbed (默认值: 5) 5
    ○ 丢弃短于此长度的文本块(如果去掉\r\n, 只剩5个有效文本, 那就丢掉)
java 复制代码
本服务条
  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);
    }

整个流程如下:

自定分割器:

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

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);
		}

	}

}
分隔经验:

过细分块的潜在问题

  1. 语义割裂‌: 破坏上下文连贯性,影响模型理解‌。
  2. 计算成本增加‌:分块过细会导致向量嵌入和检索次数增多,增加时间和算力开销‌。
  3. 信息冗余与干扰‌:碎片化的文本块可能引入无关内容,干扰检索结果的质量,降低生成答案的准确性‌。

分块过大的弊端

  1. 信息丢失风险‌:过大的文本块可能超出嵌入模型的输入限制,导致关键信息未被有效编码‌。
  2. 检索精度下降‌:大块内容可能包含多主题混合,与用户查询的相关性降低,影响模型反馈效果‌。
场景 分块策略 参数参考
微博/短文本 句子级分块,保留完整语义 每块100-200字符‌
学术论文 段落级分块,叠加10%重叠 每块300-500字符‌
法律合同 条款级分块,严格按条款分隔 每块200-400字符‌
长篇小说 章节级分块,过长段落递归拆分为段落 每块500-1000字符‌

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

分块五种策略

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


1)固定大小分块

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

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

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

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


2)语义分块

这个想法很简单。

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

输出可能如下所示:

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

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

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


3)递归分块

这也很简单。

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

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

输出可能如下所示:

如上图:

  • 首先,我们定义两个块(紫色的两个段落)。
  • 接下来,第 1 段被进一步分成更小的块。

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

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


4)基于文档结构的分块

这是另一种直观的方法。

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

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

输出可能如下所示:

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

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


5)基于LLM的分块

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

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

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

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

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


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

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

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

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

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

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")

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

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

很少会去改, 了解即可

KeywordMetadataEnriching

使用生成式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);

        // 通过传入大模型,让大模型提取文件内容当中的 5 个关键字
            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 生成出来的关键字无法进行元数据过滤?

SummaryMetadataEnricher

使用生成式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);
    }

文本向量化

向量化存储之前在"文本向量化"介绍了, 就是通过向量模型库进行向量化

代码:

依然通过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);
        }
 
    }

存储向量

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

java 复制代码
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);
    }

向量数据库检索

代码:

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

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);
    }

对话阶段

如果结合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 小时前
低资源NLP数据处理:少样本/零样本场景下数据增强与迁移学习结合方案
人工智能·深度学习·分类·nlp·bert
不会写DN3 小时前
用户头像文件存储功能是如何实现的?
java·linux·后端·golang·node.js·github
玄月三初3 小时前
超算互联网平台配置老一点的mmsegmentation环境
人工智能·计算机视觉·语义分割
AI新兵3 小时前
AI大事记12:Transformer 架构——重塑 NLP 的革命性技术(下)
人工智能·架构·transformer
鸿蒙小白龙3 小时前
OpenHarmony平台大语言模型本地推理:llama深度适配与部署技术详解
人工智能·语言模型·harmonyos·鸿蒙·鸿蒙系统·llama·open harmony
jerryinwuhan4 小时前
对图片进行解释的大语言模型
人工智能·语言模型·自然语言处理
聪明的笨猪猪4 小时前
Java JVM “类加载与虚拟机执行” 面试清单(含超通俗生活案例与深度理解)
java·经验分享·笔记·面试
盖世英雄酱581364 小时前
FullGC排查,居然是它!
java·后端
Jagger_4 小时前
SOLID原则中的依赖倒置原则(DIP):构建可维护软件架构的关键
后端