news 2026/4/16 12:01:39

Spring AI + ELT

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring AI + ELT

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

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

Document Loaders

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

读取Text

@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

<dependency>

<groupId>org.springframework.ai</groupId>

<artifactId>spring-ai-markdown-document-reader</artifactId>

</dependency>

@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

<dependency>

<groupId>org.springframework.ai</groupId>

<artifactId>spring-ai-markdown-document-reader</artifactId>

</dependency>

@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站:

<dependency>

<groupId>com.alibaba.cloud.ai</groupId>

<artifactId>spring-ai-alibaba-starter-document-reader-bilibili</artifactId>

</dependency>

@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就提供了一个文档拆分器:

TextSplitter 抽象类

TokenTextSplitter 按token分隔

TokenTextSplitter

chunkSize (默认值: 800) 100

○ 每个文本块的目标大小,以token为单位

minChunkSizeChars (默认值: 350) 建议小一点

○ 如果块超过最小块字符数( 按照块的最后. ! ? \n 符号截取)

○ 如果块没超过最小块字符数, 不会按照符号截取(保留原块)。

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

1. 预订航班

- 通过我们的网站或移动应用程序预订。

- 预订时需要全额付款。 \n

- 确保个人信息(姓名、ID 等)的准确性,因为更正可能会产生 25

minChunkLengthToEmbed (默认值: 5) 5

○ 丢弃短于此长度的文本块(如果去掉\r\n, 只剩5个有效文本, 那就丢掉)

本服务条

maxNumChunks(默认值: 10000)

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

keepSeparator(默认值: true)

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

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

}

整个流程如下:

自定分割器:

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

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

}

}

}

分隔经验:

过细分块的潜在问题

‌语义割裂‌: 破坏上下文连贯性,影响模型理解‌。

‌计算成本增加‌:分块过细会导致向量嵌入和检索次数增多,增加时间和算力开销‌。

‌信息冗余与干扰‌:碎片化的文本块可能引入无关内容,干扰检索结果的质量,降低生成答案的准确性‌。

分块过大的弊端

‌信息丢失风险‌:过大的文本块可能超出嵌入模型的输入限制,导致关键信息未被有效编码‌。

‌检索精度下降‌:大块内容可能包含多主题混合,与用户查询的相关性降低,影响模型反馈效果‌。

‌场景‌ ‌分块策略‌ ‌参数参考‌

微博/短文本 句子级分块,保留完整语义 每块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内容

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

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

source: xushu.pdf

The World is Big and Salvation Lurks Around the Corner

很少会去改, 了解即可

KeywordMetadataEnriching

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

new KeywordMetadataEnricher(chatModel, 5);

chatModel 需要提取关键字的模型

关键字数量

@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,‘三打白骨精的故事以及后续剧情’。

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

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

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

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

@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进行向量化

@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, 可以省略向量化这一步, 向量数据库会在底层自动完成向量化

for (Document document : apply) {

float[] embedded = embeddingModel.embed(document);

}

// 替换为: 写入=向量化+存储

vectorStore.write(apply);

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

}

向量数据库检索

代码:

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

// 3. 相似性查询

SearchRequest searchRequest = SearchRequest

.builder().query("预定航班")

.topK(5)

.similarityThreshold(0.3)

.build();

List<Document> results = vectorStore.similaritySearch(searchRequest);

// 4.输出

System.out.println(results);

完整代码:

@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整合在一起

@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

传统的向量检索存在几个关键问题:

语义相似度的局限性:向量检索主要基于余弦相似度等数学计算,但相似的向量表示不一定意味着内容一定绝对相关。单纯的向量相似度无法充分理解查询的真实意图和上下文。

排序质量不佳:初始检索的排序往往不是最优的,可能将不太相关的文档排在前面,尤其性能差的向量模型更为明显。

上下文理解缺失:传统检索(完全依赖向量数据库和向量模型)缺乏对查询和文档完整上下文的深度理解,容易出现语义漂移问题。

重排序:

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/8 15:25:30

UMAP降维与密度聚类联用:从数据可视化到智能分析的技术进阶

UMAP降维与密度聚类联用&#xff1a;从数据可视化到智能分析的技术进阶 【免费下载链接】umap Uniform Manifold Approximation and Projection 项目地址: https://gitcode.com/gh_mirrors/um/umap 你是否曾经面对过这样的困境&#xff1a;手头拥有海量的高维数据&#…

作者头像 李华
网站建设 2026/4/16 10:26:16

驾驭品牌主权:解析独立站的核心优势与增长吸引力

在全球跨境电商的竞争格局中&#xff0c;独立站&#xff08;Direct-to-Consumer,DTC&#xff09;模式已成为中国品牌实现全球化增长与价值沉淀的关键选择。超店Shoplus作为至真科技旗下的独立站出海成功平台&#xff0c;深刻理解独立站为商家带来的显著优势和持久吸引力。我们致…

作者头像 李华
网站建设 2026/4/14 4:46:05

深度评测:Blender屏幕操作显示工具在教程制作中的实战表现

深度评测&#xff1a;Blender屏幕操作显示工具在教程制作中的实战表现 【免费下载链接】Screencast-Keys Blender Add-on: Screencast Keys 项目地址: https://gitcode.com/gh_mirrors/sc/Screencast-Keys 对于Blender用户来说&#xff0c;制作教程视频时最头疼的问题莫…

作者头像 李华
网站建设 2026/4/16 11:59:38

NetGuard完整指南:简单几步掌控Android应用网络权限

想要彻底解决Android应用偷跑流量、耗电严重的问题吗&#xff1f;NetGuard这款开源网络管理工具正是你需要的解决方案。无需root权限&#xff0c;通过本地网络技术实现精准的网络控制&#xff0c;让每个应用的网络访问权限都在你的掌握之中。本指南将带你从零开始&#xff0c;快…

作者头像 李华
网站建设 2026/4/12 16:30:50

uesave完整教程:解锁Unreal Engine游戏存档编辑新技能

uesave完整教程&#xff1a;解锁Unreal Engine游戏存档编辑新技能 【免费下载链接】uesave-rs 项目地址: https://gitcode.com/gh_mirrors/ue/uesave-rs 存档管理困境&#xff1a;玩家们共同的烦恼 在游戏世界里&#xff0c;你是否曾经面临这样的尴尬局面&#xff1f;…

作者头像 李华
网站建设 2026/4/14 18:35:00

从零开始搭建BUCK电路:TL494控制芯片实战手册

从零开始搭建BUCK电路&#xff1a;TL494控制芯片实战手册 【免费下载链接】BUCK电路-TL494方案资源下载 本仓库提供了一个完整的BUCK电路设计方案&#xff0c;基于TL494控制芯片。该方案包含了详细的原理图、PCB设计文件以及Gerber文件&#xff0c;方便用户进行电路的设计、验证…

作者头像 李华