基于 langchain4j 的简易 RAG

基于 langchain4j 的简易 RAG

RAG 是什么

langchain4j 的官网给了一段通俗易懂的描述

简单来说,RAG 是一种在发送给 LLM 之前,从你的数据中找到并注入相关信息片段到提示中的方法

RAG 分为两个阶段,索引检索

关于检索部分,下面的项目中将分别使用 langchain4j 的原生组件以及高级用法分别实现

简单说明一下我对这两个阶段的理解

索引

rag-索引

上图是 langchain4j 官网给出的示意图

大概的过程就是

  • 原始的文档(可以是 word,pdf,markdown等格式的文件,即图中的 Document)经过切割之后,得到了一个个的片段(即图中的 Segments)
  • 一般走图中的右侧的流程,将片段经过内嵌模型(即图中的 Embedding Model)计算后,得到内嵌对象(图中的 Embedding),最后将内嵌对象保存到专门的数据库(图中的 Embedding Store)中

过程中出现了几个名词

  • Document:理解为包含内容的文档,比如 word,pdf,markdown等格式的文件

  • Segment:由 Document 拆分而来,具体的拆分规则可以自定义

至于为什么需要拆分,因为大模型一次能处理的上下文窗口是有限制的,如果直接把一个包含有 1百万 token 的文档喂给大模型,大模型也吃不消

将文档进行拆分,然后把一个个小的片段保存到数据库中,在需要用到这个文档时,只查询出相关性比较高的 topN 片段给大模型,这样大模型才能处理地过来,也能节省 token 的消耗,以及加快大模型的运行速度。缺点是根据拆分的规则,可能会丢失一部分信息

  • Embedding Model:内嵌模型。我理解为把自然语言转换成向量的一个模型

为什么要转换成向量?我们需要计算两句话的相关性高不高,需要特殊的算法。比如需要判断土豆和马铃薯的相关性高还是土豆和黄豆的相关性比较高

  • Embedding:内嵌对象。也就是内嵌模型的输出,也是内嵌数据库的输入
  • Embedding Store:内嵌数据库。用来保存内嵌对象的地方

许多我们熟悉的数据库也支持保存向量,比如 redis,postgresql。但它们在向量数据的处理方面毕竟没那么专业

检索

rag-检索

在索引完成之后,内嵌数据库中已经保存了一些文档数据

而检索的过程就是将用户的提问在内嵌数据库中查询相关性最高的片段,然后将这些片段和用户的提问聚合,再喂给大模型,由大模型做整合

在 langchain4j 给的图示中,对部分内容进行了省略。我尝试以代码的角度对这个流程进行完善

  • 用户的提问信息被称为一个 Query
  • Query 也可以像文档一样,先进行转换。在 langchain4j 目前的实现中,给出了三种转换
  1. DefaultQueryTransformer:什么都不做
  2. CompressingQueryTransformer:通过大模型对用户的提问进行压缩。这是给大模型的提示词
1
2
3
4
5
6
7
8
9
10
11
12
Read and understand the conversation between the User and the AI. 
Then, analyze the new query from the User.
Identify all relevant details, terms, and context from both the conversation and the new query.
Reformulate this query into a clear, concise, and self-contained format suitable for information retrieval.

Conversation:
{{chatMemory}}

User query: {{query}}

It is very important that you provide only reformulated query and nothing else!
Do not prepend a query with anything!

翻译一下是

1
2
3
4
5
6
7
8
9
10
11
12
阅读并理解用户与AI之间的对话。
然后,分析用户的新查询。
从对话和新查询中识别所有相关细节、术语和上下文。
将此查询重新表述为清晰、简洁且适合信息检索的独立格式。

对话:
{{chatMemory}}

用户查询:{{query}}

非常重要,您只能提供重新表述的查询,不得提供其他内容!
不要在查询前添加任何内容!
  1. ExpandingQueryTransformer:和 CompressingQueryTransformer 一样,通过大模型对原有的提问进行扩展。可以指定扩展的数量,默认是 3 条。提示词如下
1
2
3
4
5
6
7
Generate {{n}} different versions of a provided user query. 
Each version should be worded differently, using synonyms or alternative sentence structures,
but they should all retain the original meaning.
These versions will be used to retrieve relevant documents.
It is very important to provide each query version on a separate line,
without enumerations, hyphens, or any additional formatting!
User query: {{query}}

翻译

1
2
3
4
5
6
7
生成提供的用户查询的{{n}}个不同版本。
每个版本应使用不同的措辞,使用同义词或不同的句子结构,
但它们都应该保留原始意义。
这些版本将用于检索相关文档。
非常重要,每个查询版本应单独一行提供,
不要使用列举、破折号或任何其他格式化!
用户查询:{{query}}
  • 经过转换之后,得到的一个 Query 列表。接下来需要的对每个 Query 进行路由,以选择每个 Query 的检索方式
  • 同样的,路由也有多个实现。langchain4j 中有如下两种

DefaultQueryRouter:按顺序执行全部的检索方式

LanguageModelQueryRouter:问一下大模型选择哪些检索方式

  • 关于检索方式,可以有很多很多。比如查询关系型数据库,web搜索,查询内嵌数据库,查询es数据库等等

相对来说,对于自然语言的处理,内嵌数据库和web搜索是我觉得比较合适的搜索方式

  • 有了路由选择,每个 Query 都能被安排到对应的检索方式去处理。而处理的结果就是一个个 Content 列表
  • 再将这些 Content 列表进行聚合,按相关性倒序排。在 langchain4j 中也提供了两种实现

DefaultContentAggregator:有一个特殊的算法,可以计算每个 Content 的相关性分数

ReRankingContentAggregator:用专门的排序模型(ScoringModel)评分

  • 把排序好的 Content 注入到用户的提问中。可以自定义注入的模板
  • 最后将注入好的消息交给大模型,让大模型完成最后的总结

简单总结一下整个流程

括号中是 langchain4j 的组件接口

1
用户提问 -> 问题压缩/扩展(QueryTransformer) -> 问题路由(QueryRouter) -> 检索(ContentRetriever) -> 聚合(ContentAggregator) -> 注入(ContentInjector) -> 交给llm

环境准备

在动手实现之前,需要准备一些环境

  1. 大模型的 api key。我这里使用的是 deepseek 的 key
  2. 内嵌模型的 api key。我用的是阿里云的内嵌模型 text-embedding-v3
  3. 内嵌数据库。自建的 milvus 数据库
  4. tavily 搜索引擎的访问 key。申请网站是 https://app.tavily.com

也可以换用 谷歌搜索,区别不是很大

这里给出 milvus 环境的 docker compose 配置

注意事项:由于没有挂载数据卷,容器被销毁后,数据也会被删除

端口

19530:milvus 对外提供服务的端口

3000:milvus web 服务的访问端口

访问 3000 端口可以在网页端操作 milvus

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
services:
etcd:
container_name: milvus-etcd
image: quay.io/coreos/etcd:v3.5.18
environment:
- ETCD_AUTO_COMPACTION_MODE=revision
- ETCD_AUTO_COMPACTION_RETENTION=1000
- ETCD_QUOTA_BACKEND_BYTES=4294967296
- ETCD_SNAPSHOT_COUNT=50000
command: etcd -advertise-client-urls=http://etcd:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd
healthcheck:
test: ["CMD", "etcdctl", "endpoint", "health"]
interval: 30s
timeout: 20s
retries: 3

minio:
container_name: milvus-minio
image: minio:latest
environment:
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
command: minio server /minio_data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3

milvus:
container_name: milvus
image: milvusdb/milvus:v2.5.10
command: ["milvus", "run", "standalone"]
security_opt:
- seccomp:unconfined
environment:
ETCD_ENDPOINTS: etcd:2379
MINIO_ADDRESS: minio:9000
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9091/healthz"]
interval: 30s
start_period: 90s
timeout: 20s
retries: 3
ports:
- "19530:19530"
depends_on:
- "etcd"
- "minio"

attu:
container_name: milvus-attu
image: zilliz/attu:v2.5.7
environment:
MILVUS_URL: milvus:19530
ports:
- "3000:3000"
depends_on:
- "milvus"

networks:
default:
name: milvus

编码

要求 jdk 版本在 21 及以上,项目中有一处使用了虚拟线程

项目简介

分别使用 langchain4j 的高级语法和低级组件来实现一个简单的 RAG 功能

外部知识库的来源有两个部分,也可自行扩展

  1. 内嵌数据库
  2. tavily 搜索

其中,内嵌数据库的数据库将通过 function call 的方式写入。下面将提供一个简单的案列供参考

pom 依赖

maven 的中央仓库没有 langchain4j-milvus-spring-boot-starter 依赖

有两个处理方案

  1. clone langchain4j-spring 仓库打包对应的版本(1.0.0-beta3)到本地仓库
  2. 修改成 langchain4j-milvus 依赖,自行编码构建 MilvusEmbeddingStore bean 对象

这里我选择的是方法一

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>top.wuhunyu.rag</groupId>
<artifactId>langchain4j-rag-example</artifactId>
<version>1.0.0</version>

<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring-boot.version>3.4.0</spring-boot.version>
<langchain4j.version>1.0.0-beta3</langchain4j.version>
</properties>

<dependencyManagement>
<dependencies>
<!-- springboot 版本锁定 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<scope>import</scope>
<type>pom</type>
</dependency>
<!-- langchain4j 版本锁定 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-bom</artifactId>
<version>${langchain4j.version}</version>
<scope>import</scope>
<type>pom</type>
</dependency>
</dependencies>
</dependencyManagement>

<dependencies>
<!-- web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- langchain4j springboot 启动器 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-spring-boot-starter</artifactId>
</dependency>
<!-- langchain4j 响应式编程 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-reactor</artifactId>
</dependency>
<!-- langchain4j openai 接入 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-open-ai-spring-boot-starter</artifactId>
</dependency>
<!-- langchain4j milvus 接入 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-milvus-spring-boot-starter</artifactId>
<version>${langchain4j.version}</version>
</dependency>
<!-- langchain4j tavily 搜索引擎接入 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-web-search-engine-tavily</artifactId>
</dependency>
<!-- lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>

</project>
application.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
spring:
application:
name: langchain4j-rag-example

langchain4j:
open-ai:
# 大模型
chat-model:
base-url: https://api.deepseek.com/v1
api-key: [deepseek api key]
model-name: deepseek-chat
log-requests: true
log-responses: true
# 流式大模型
streaming-chat-model:
base-url: https://api.deepseek.com/v1
api-key: [deepseek api key]
model-name: deepseek-chat
log-requests: true
log-responses: true
# 内嵌模型
embedding-model:
base-url: https://dashscope.aliyuncs.com/compatible-mode/v1
api-key: [embedding api key]
model-name: text-embedding-v3
max-segments-per-batch: 5
dimensions: 1024
# 内嵌数据库
milvus:
host: [自建 milvus 数据库主机地址]
port: 19530
database-name: rag
collection-name: java
dimension: 1024
# 最大搜索结果数
max-results: 10

# 网络搜索引擎
tavily:
# 最大搜索结果数
max-results: 10
api-key: [tavily api key]
监听器

langchain4j 提供了一个监听器接口(dev.langchain4j.model.chat.listener.ChatModelListener),可以让开发者监听与大模型的交互过程

下面的代码在请求和响应的时候打印了请求的消息对象。我们可以从中观察整个 RAG 过程和大模型交互的次数,以及中间过程的处理结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* 大模型交互监听器
*
* @author wuhunyu
* @date 2025/06/08 11:21
**/

@Component("myChatModelListener")
@Slf4j
public class MyChatModelListener implements ChatModelListener {

@Override
public void onRequest(final ChatModelRequestContext requestContext) {
final var chatRequest = requestContext.chatRequest();
var messages = chatRequest.messages();
log.debug("onRequest: {}", messages);
}

@Override
public void onResponse(final ChatModelResponseContext responseContext) {
final var chatResponse = responseContext.chatResponse();
var aiMessage = chatResponse.aiMessage();
log.debug("onResponse: {}", aiMessage);
}

@Override
public void onError(final ChatModelErrorContext errorContext) {
ChatModelListener.super.onError(errorContext);
}
}
bean 配置

这个配置类注入了一些 langchain4j 的 bean,在高级语法和低级组件中都有用到

具体 bean 的作用可以看注释

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
/**
* rag bean 配置
*
* @author wuhunyu
* @date 2025/06/08 10:44
**/

@Configuration
@EnableConfigurationProperties(RAGBeanConfig.TavilyProperties.class)
public class RAGBeanConfig {

// 消息存储,此处为内存存储
@Bean("chatMemoryStore")
public ChatMemoryStore chatMemoryStore() {
return new InMemoryChatMemoryStore();
}

// 用于隔离不同的用户会话消息。也可使用 MessageWindowChatMemory 实现用来替换 TokenWindowChatMemory
@Bean("chatMemoryProvider")
public ChatMemoryProvider chatMemoryProvider(ChatMemoryStore chatMemoryStore) {
return memoryId -> TokenWindowChatMemory.builder()
.chatMemoryStore(chatMemoryStore)
.maxTokens(10240, new OpenAiTokenizer(OpenAiChatModelName.GPT_4))
.build();
}

// Query 转换器。也可以换用 ExpandingQueryTransformer 进行 Query 扩展
@Bean("queryTransformer")
public QueryTransformer queryTransformer(ChatLanguageModel chatLanguageModel) {
return new CompressingQueryTransformer(chatLanguageModel);
}

@Data
@NoArgsConstructor
@AllArgsConstructor
@ConfigurationProperties("tavily")
public static class TavilyProperties {

private Integer maxResults;

private String apiKey;

}

// web 搜索引擎,此处选择的是 tavily 搜索引擎
@Bean("webSearchContentRetriever")
public WebSearchContentRetriever webSearchContentRetriever(TavilyProperties tavilyProperties) {
final var tavilyWebSearchEngine = TavilyWebSearchEngine.builder()
.apiKey(tavilyProperties.getApiKey())
.build();
return WebSearchContentRetriever.builder()
.maxResults(tavilyProperties.getMaxResults())
.webSearchEngine(tavilyWebSearchEngine)
.build();
}

// 内嵌数据库检索器,此处选择的是 milvus 数据库
@Bean("embeddingStoreContentRetriever")
public EmbeddingStoreContentRetriever embeddingStoreContentRetriever(
@Value("${langchain4j.milvus.max-results:50}") Integer maxResults,
MilvusEmbeddingStore milvusEmbeddingStore,
EmbeddingModel embeddingModel
) {
return EmbeddingStoreContentRetriever.builder()
.embeddingStore(milvusEmbeddingStore)
.embeddingModel(embeddingModel)
.maxResults(maxResults)
.build();
}

// Query 路由,让大模型决定选择哪一个检索器或者哪几个检索器。retrieverToDescription 属性的 key 为检索器,value 为检索器的描述
@Bean("queryRouter")
public QueryRouter queryRouter(
ChatLanguageModel chatLanguageModel,
WebSearchContentRetriever webSearchContentRetriever,
EmbeddingStoreContentRetriever embeddingStoreContentRetriever
) {
return LanguageModelQueryRouter.builder()
.chatLanguageModel(chatLanguageModel)
.retrieverToDescription(Map.of(
webSearchContentRetriever, "Web Search",
embeddingStoreContentRetriever, "Embedding Database"
))
.build();
}

// content 聚合器,如果有 ReRanking 模型,也可以选择使用 ReRankingContentAggregator 对检索结果进行排序
@Bean("contentAggregator")
public ContentAggregator contentAggregator() {
return new DefaultContentAggregator();
}

// content 注入器,可以自定义注入内容的模型
@Bean("contentInjector")
public ContentInjector contentInjector() {
return new DefaultContentInjector();
}

// 检索增强器,简单理解为把上面的几个组件联系到一起协作处理 Query,最终返回增强后的 ChatMessage
@Bean("retrievalAugmentor")
public RetrievalAugmentor retrievalAugmentor(
QueryTransformer queryTransformer,
QueryRouter queryRouter,
ContentAggregator contentAggregator,
ContentInjector contentInjector
) {
// 自定义线程池
final var threadFactory = new CustomizableThreadFactory("retrievalAugmentor-");
final var executorService = Executors.newThreadPerTaskExecutor(threadFactory);

return DefaultRetrievalAugmentor.builder()
.queryTransformer(queryTransformer)
.queryRouter(queryRouter)
.contentAggregator(contentAggregator)
.contentInjector(contentInjector)
.executor(executorService)
.build();
}

}
高级语法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 高级 langchain4j 编程
*
* @author wuhunyu
* @date 2025/06/08 11:23
**/

@AiService(
wiringMode = AiServiceWiringMode.EXPLICIT,
chatModel = "openAiChatModel",
streamingChatModel = "openAiStreamingChatModel",
chatMemoryProvider = "chatMemoryProvider",
retrievalAugmentor = "retrievalAugmentor"
)
public interface AdvanceLLMService {

@SystemMessage("你是一个知识渊博的助手,请通俗易懂地回答我的问题。")
TokenStream streamChat(@MemoryId Long userId, @UserMessage String userMessage);

}

高级语法主要的配置在 @AiService 这个注解上

  • chatModel 和 streamingChatModel 分别表示非流式大模型对象和流式大模型对象。这么说可能会有一些歧义。大模型都是支持流式返回的,可以称为同步返回和异步一点一点返回,带 streaming 前缀的就是一步一点一点返回的
  • chatMemoryProvider 用来区别不同的会话
  • retrievalAugmentor 检索增强,RAG 的核心实现就是这个组件,后续低级写法中,也是对这个组件进行分解成多个基础组件并手动编码实现同样的效果

userId 参数是用来区别不同的会话使用的,通过同一个 id 值会讲历史的对话记录一并发送给大模型

userMessage 参数是实际用户的提问

方法顶上的 @SystemMessage 注解表示一个系统消息,在优先级上要高于普通的用户消息

TokenStream 类型的响应值表示这是一个流式的返回

那么,让我们试一下这个高级语法实现的 RAG 效果如何

我们新增一个请求路由来调用这个方法

请求路由
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
/**
* rag
*
* @author wuhunyu
* @date 2025/06/08 11:35
**/

@RestController
@RequestMapping("/rag")
@RequiredArgsConstructor
@Slf4j
public class RAGController {

private final AdvanceLLMService advanceLLMService;

@Data
@NoArgsConstructor
@AllArgsConstructor
public static class UserMessageRequest implements Serializable {

@Serial
private static final long serialVersionUID = -8569299218617670092L;

/**
* 用户id
*/
private Long userId;

/**
* 消息
*/
private String message;

}

@PostMapping(value = "/advance", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter advance(@RequestBody UserMessageRequest userMessageRequest) {
final var tokenStream = advanceLLMService.streamChat(
userMessageRequest.getUserId(),
userMessageRequest.getMessage()
);

// 结果返回
final var sseEmitter = new SseEmitter(-1L);
tokenStream.onPartialResponse(s -> {
try {
sseEmitter.send(
SseEmitter.event()
.data(s)
.build()
);
} catch (IOException e) {
throw new RuntimeException(e);
}
})
.onCompleteResponse(chatResponse -> {
log.info("chatResponse: {}", chatResponse.aiMessage().text());
sseEmitter.complete();
})
.onError(throwable -> {
sseEmitter.complete();
log.error("", throwable);
});
tokenStream.start();

return sseEmitter;
}

}

这时候内嵌数据库还是没数据的状态,尝试询问大模型一个有关 jvm 的问题

发起一个 POST 请求,如下

1
2
3
4
5
6
curl --location --request POST 'http://localhost:8080/rag/advance' \
--header 'Content-Type: application/json' \
--data-raw '{
"userId": 1,
"message": "请简单地描述一下 jvm 是什么"
}'

此前我们配置了一个监听器,让我们来看看都和大模型交互了一些什么信息

问题路由

可以看到,发送了一个选择给大模型,选择的条件是我的问题本身

1
2
3
4
5
2025-06-08 20:27:07.224 [http-nio-8080-exec-2] DEBUG top.wuhunyu.rag.listener.MyChatModelListener - onRequest: [UserMessage { name = null contents = [TextContent { text = "Based on the user query, determine the most suitable data source(s) to retrieve relevant information from the following options:
1: Web Search
2: Embedding Database
It is very important that your answer consists of either a single number or multiple numbers separated by commas and nothing else!
User query: 请简单地描述一下 jvm 是什么" }] }]

而此次询问大模型返回的结果是

1
2025-06-08 20:27:11.492 [http-nio-8080-exec-2] DEBUG top.wuhunyu.rag.listener.MyChatModelListener - onResponse: AiMessage { text = "1,2" toolExecutionRequests = null }

也就是大模型角色有必要从 Web SearchEmbedding Database 两个数据来源中获取数据

接下来,便是分别从两个数据来源获取响应的结果,然后进行排序,注入,最后再发送给大模型,由大模型进行总结

最后的请求和响应日志都太长了,这里就不贴出来了

在请求调试工具中, SSE 的返回结果形如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
data:J

data:VM

data:(

data:Java

data: Virtual

data: Machine

data:,

data:Java

data:

data:虚拟机

data:)

data:是

data: Java
...
function call

上面这次问题,在内嵌数据库中,并没有查询到任何数据。这点可以通过日志观察得出来,最后一次提问中,发送给大模型的提示词中,并没有包含 web 的数据来源,而来源于 web 搜索的数据都会有一个网站的来源。比如

1
2
3
4
5
深入理解HotSpot JVM 基本原理 - 腾讯云
它保存所有被JVM加载的类和接口的运行时常量池,成员变量以及方法的信息,静态变量以及方法的字节码。JVM的提供者可以通过不同的方式来实现方法区。在Oracle 的HotSpot JVM里,方法区被称为永久区或者永久代(PermGen)。

doocs/jvm: JVM 底层原理最全知识总结 - GitHub
🤗 JVM 底层原理最全知识总结. Contribute to doocs/jvm development by creating an account on GitHub.

表明了这两条数据分别来源于 腾讯云 和 GitHub

现在让我们利用 tool 将文档向量化并写入内嵌数据库中,不过在使用 tool 之前,我们需要先声明

本地工具 LocalFileHandlerTool

这个工具属于 langchain4j 的高级语法,低级写法太麻烦了,个人也推荐使用注解的方式描述工具

只有两个功能

  1. 列出服务器指定的路径下的所有文件
  2. 将指定的文件索引到内嵌数据库中。这里的内嵌数据库指的是环境准备中的 milvus 数据库

实现中,代码有一个 PATH 常量,需要替换成自己机器上的路径,需要这个路径下有一些文档文件(比如 word,pdf,markdown等格式的文件),便于向量化然后写入到内嵌数据库中

其中标注了 @Tool 注解的便是一个 tool,可以通过 @P 注解对入参进行描述

最后给到大模型的是一个 json 格式的工具描述,可以自行断点观察

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
/*
* 本地文件处理
*
* @author wuhunyu
* @date 2025/06/08 11:18
**/

@Component("localFileHandlerTool")
@RequiredArgsConstructor
@Slf4j
public class LocalFileHandlerTool {

public static final String PATH = "/Users/wuhunyu/WorkSpace/learn";

private final EmbeddingModel embeddingModel;

private final MilvusEmbeddingStore milvusEmbeddingStore;

@Tool("列出目录下的所有文件")
@SuppressWarnings("all")
public List<String> listDir() {
return Arrays.stream(
new File(PATH).listFiles()
)
.filter(File::isFile)
.map(File::getName)
.toList();
}

@Tool("获取指定文件名的大小,单位:字节。当文件不存在或者不可读时,返回 -1")
public Long fileSize(@P("文件名称") String fileName) {
if (!this.isFile(fileName)) {
return -1L;
}
return new File(this.getCompleteFileName(fileName)).length();
}

private boolean isFile(String fileName) {
fileName = this.getCompleteFileName(fileName);
if (StringUtils.isBlank(fileName)) {
return false;
}
final var file = new File(fileName);
return file.exists() && file.isFile() && file.canRead();
}

private String getCompleteFileName(String fileName) {
return PATH + File.separator + fileName;
}

@Tool("将指定文件的内容存放至 embedding 数据库中。操作成功时,返回 success,其他返回都为错误信息")
public String toEmbedding(@P("文件名称") String fileName) {
if (!this.isFile(fileName)) {
return "文件不存在";
}
try {
// 加载文档
final var document = FileSystemDocumentLoader.loadDocument(
this.getCompleteFileName(fileName),
new TextDocumentParser()
);
// 文档拆分器
DocumentBySentenceSplitter documentBySentenceSplitter = new DocumentBySentenceSplitter(
300,
100,
new OpenAiTokenizer(OpenAiEmbeddingModelName.TEXT_EMBEDDING_3_SMALL));

// 文档拆分,并添加 source 元数据
var textSegments = documentBySentenceSplitter.split(document)
.stream()
.peek(textSegment -> {
Metadata metadata = textSegment.metadata();
metadata.put("source", fileName);
})
.toList();

// 添加到 内嵌数据库 中
var listResponse = embeddingModel.embedAll(textSegments);
milvusEmbeddingStore.addAll(listResponse.content(), textSegments);

// 返回成功
return "success";
} catch (Exception e) {
log.error("读取本地文档 {} 到内嵌数据库失败: ", fileName, e);
return e.getMessage();
}
}

}

新增一个内嵌数据服务 EmbeddingService

不知道你看出来和 AdvanceLLMService 的区别了吗

本质区别就是少了 retrievalAugmentor 属性,多了 tools 属性

为什么要这么做呢,不能合并成一个吗?

由于 retrievalAugmentor 内部会做各种动作对最初的提问进行增强,很可能会干扰大模型的判断,所以我们需要一个比较干净的上下文环境减少大模型的误判断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 内嵌数据准备
*
* @author gongzhiqiang
* @date 2025/06/08 20:45
**/

@AiService(
wiringMode = AiServiceWiringMode.EXPLICIT,
chatModel = "openAiChatModel",
streamingChatModel = "openAiStreamingChatModel",
chatMemoryProvider = "chatMemoryProvider",
tools = {
"localFileHandlerTool"
}
)
public interface EmbeddingService {

Result<String> chat(@MemoryId Long userId, @UserMessage String userMessage);

}

在请求路由上添加一个调用

1
2
3
4
5
@PostMapping("/embedding-tool")
public String embeddingTool(@RequestBody UserMessageRequest userMessageRequest) {
final var result = embeddingService.chat(userMessageRequest.getUserId(), userMessageRequest.getMessage());
return result.content();
}

先要大模型列出路径下的全部文件

1
2
3
4
5
6
curl --location --request POST 'http://localhost:8080/rag/embedding-tool' \
--header 'Content-Type: application/json' \
--data-raw '{
"userId": 2,
"message": "列出全部的文件"
}'

结果如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
当前目录下的文件列表如下:

1. Kafka.md
2. MyBatis.md
3. ArchLinux.md
4. Docker操作命令.md
5. JVM类的加载过程.md
6. Go.md
7. Java程序员-xxx.md
8. SpringMVC.md
9. Zookeeper2.md
10. Spring5.md
11. JVM字节码与类的加载篇.md
12. JUC.md
13. 面试积累.md
14. Redis.md
15. SpringBoot.md
16. 二叉树前中后序遍历.md
17. nginx.md
18. Java工程师-xxx.md
19. JVM内存与垃圾回收篇.md
20. Java8新特性.md
21. SpringSecurity.md
22. MySQL.md
23. ITX主机计划.md
24. JVM.md
25. 面试小笔记.md
26. Linux.md
27. JVM性能调优.md
28. Maven.md
29. C语言.md
30. JVM运行时参数.md
31. Git.md
32. 工作经历-xxx.md
33. ElasticSearch.md
34. RabbiMQ.md
35. Docker.md
36. SpringCloud.md
37. 反射.md
38. 面试学习.md
39. 实战开发.md
40. NIO.md
41. Spring简单整合MyBatis(基于xml方式).md
42. MySQL调优.md
43. Zookeeper.md
44. 个人笔记.md

如果需要进一步操作(如获取文件大小或嵌入内容),请告诉我!

将 19 号文件加载到内嵌数据库中

1
2
3
4
5
6
curl --location --request POST 'http://localhost:8080/rag/embedding-tool' \
--header 'Content-Type: application/json' \
--data-raw '{
"userId": 2,
"message": "将 19 号文件加载到内嵌数据库中"
}'

结果如下

向量数据库

再次询问 请简单地描述一下 jvm 是什么

1
2
3
4
5
6
curl --location --request POST 'http://localhost:8080/rag/advance' \
--header 'Content-Type: application/json' \
--data-raw '{
"userId": 1,
"message": "请简单地描述一下 jvm 是什么"
}'

可以在控制台观察到最后发送给大模型的消息中,除了标记由来源的消息,也有不携带消息来源的消息,这些不携带消息来源的消息就是来源于内嵌数据库中

低级写法

所谓低级写法,就是将 @AiService 注解实现的功能用编码的方式手动实现一遍

此处只给出代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
/**
* langchain4j 低级写法
*
* @author wuhunyu
* @date 2025/06/08 12:23
**/

@Component("basicLLMService")
@RequiredArgsConstructor
@Slf4j
public class BasicLLMService {

private final ChatLanguageModel chatLanguageModel;

private final QueryTransformer queryTransformer;

private final ChatMemoryProvider chatMemoryProvider;

private final QueryRouter queryRouter;

private final ContentAggregator contentAggregator;

private final ContentInjector contentInjector;

public String chat(Long userId, String message) {
Query query = Query.from(
message,
Metadata.from(
UserMessage.from(message),
userId,
chatMemoryProvider.get(userId)
.messages()
)
);

// transform
Collection<Query> queries = queryTransformer.transform(query);

// query router
Map<Query, Collection<List<Content>>> query2Contents = new HashMap<>();
for (Query curQuery : queries) {
Collection<ContentRetriever> contentRetrievers = queryRouter.route(curQuery);
List<List<Content>> contents = new ArrayList<>();
for (ContentRetriever contentRetriever : contentRetrievers) {
contents.add(contentRetriever.retrieve(curQuery));
}
query2Contents.put(curQuery, contents);
}

// content aggregator
List<Content> finalContents = contentAggregator.aggregate(query2Contents);

// content injector
ChatMessage userMessage = contentInjector.inject(
finalContents,
UserMessage.from(message)
);

// 系统消息
var systemMessage = SystemMessage.from(
"""
你是一个知识渊博的助手,请通俗易懂地回答我的问题。
"""
);

// 历史消息
final var messages = chatMemoryProvider.get(userId)
.messages();
if (CollectionUtils.isEmpty(messages)) {
// 第一次提问
messages.add(systemMessage);
}
messages.add(userMessage);

// chat
ChatRequest chatRequest = ChatRequest.builder()
.messages(messages)
.build();

// response
return chatLanguageModel.chat(chatRequest)
.aiMessage()
.text();
}

}

总结

本文先按作者自己的理解讲述了对 langchain4j 框架的 RAG 实现流程以及各个组件的作用

然后通过编码的方式实现了一个简单的 RAG 例子,基本用上了 langchain4j 的大多组件

遇到的问题

  1. 超出最大窗口大小

项目中使用的对话记忆方案是 TokenWindowChatMemory,最大的 token 被设置为了 10240。如果把 langchain4j.milvus.max-resultstavily.max-results 都调整成 50,可以观察到一个很诡异的事情是明明网络搜索和内嵌数据库搜索一共 100 条记录,最后却没有发送给大模型

网络搜索和内嵌数据库搜索

发送给大模型的消息

其实问题出在上图中的 chatMemory.add(userMessage) 代码(图中 175 行)。由于 100 条记录的长度超出了 10240 长度,因此被丢弃了这部分消息

实际长度

在生产开发过程中,还是需要注意一下最大窗口问题

  1. 适配 OpenAI API

OpenAI API 的官方文档地址是 https://platform.openai.com/docs/api-reference/chat/create

langchain4j-open-ai-spring-boot-starter 依赖中,提供了 dev.langchain4j.model.openai.internal.chat.ChatCompletionRequestdev.langchain4j.model.openai.internal.chat.ChatCompletionResponse 作为请求体类型和响应类型

一切都很完美是不是,我们需要实现 OpenAI API 接口的话,可以直接利用这两个类而不需要自己编写又臭又长还可能出错的 VO 类

但我现在要给你拨一瓢冷水了,dev.langchain4j.model.openai.internal.chat.ChatCompletionRequest#messages 的类型是一个接口,这意味着网络请求在反序列化时不清楚到底应该使用哪一个实现,因此不对它进行改造是会出错的

你可以将这个接口替换为某个实现类以修复这个问题

  1. 高级语法无法传递 ChatRequestParameters 参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Experimental
public interface ChatRequestParameters {

String modelName();

Double temperature();

Double topP();

Integer topK();

Double frequencyPenalty();

Double presencePenalty();

Integer maxOutputTokens();

List<String> stopSequences();

List<ToolSpecification> toolSpecifications();

ToolChoice toolChoice();

ResponseFormat responseFormat();

static DefaultChatRequestParameters.Builder<?> builder() {
return new DefaultChatRequestParameters.Builder<>();
}

ChatRequestParameters overrideWith(ChatRequestParameters parameters);
}

如果你使用过 Gemini 网页版或者像是 Cherry Studio 这样的客户端,应该能注意到在和大模型对话时,是可以调整一些参数的,比如 temperaturemaxOutputTokens

langchain4j 所谓的高级语法主要是基于 @AiService 这个注解。而这个注解的处理类是 dev.langchain4j.service.DefaultAiServices

以非流式对话为例(代码片段为 dev.langchain4j.service.DefaultAiServices 的 214 行到 224 行)

1
2
3
4
5
6
7
8
9
10
11
ChatRequestParameters parameters = ChatRequestParameters.builder()
.toolSpecifications(toolExecutionContext.toolSpecifications())
.responseFormat(responseFormat)
.build();

ChatRequest chatRequest = ChatRequest.builder()
.messages(messages)
.parameters(parameters)
.build();

ChatResponse chatResponse = context.chatModel.chat(chatRequest);

看出来了没,ChatRequestParameters 构建时就没有注入 temperaturemaxOutputTokens 等这些参数

也就是说,使用高级语法时,是无法实现对这些参数进行调整的

同样,我在 langchain4j 仓库中找到了一个 issue

有一个评论是这样的

1
Response<AiMessage> chat(@UserMessage String userMessage, @ChatRequestParams ChatRequestParameters params);

@ChatRequestParamslangchain4j 中是不存在的,这位兄弟想要通过注解的方式实现自定义 ChatRequestParameters

但目前这个 issue 还是 open 状态,目前如果有微调参数的需要,还是只能使用低级语法

不过值得注意的是,并不是所有大模型都支持这些参数的调整

项目代码

GitHub

参考

langchain4j 官网

作者

wuhunyu

发布于

2025-06-09

更新于

2025-06-09

许可协议