Langchain4j - 基于 Qdrant 实现的 RAG 知识问答系统

项目简介

在当今的大模型应用落地中,RAG(检索增强生成,Retrieval-Augmented Generation)已成为解决模型幻觉、打通企业私域数据的核心技术。简单来说,RAG 就像是给大模型配备了一个可以实时查阅的 “外部知识字典”——当用户提出问题时,系统会首先从海量的私域文档中检索出最相关的知识切片,然后将这些黄金上下文与问题一同喂给大模型,从而确保模型的回答基于客观事实,而非凭空捏造。

这里我选择 Qdrant 作为本项目的向量数据库底座。Qdrant 具有极其出色的 gRPC 读写性能、毫秒级的过滤检索响应,以及对动态元数据(Payload)的强大过滤能力,能够完美支撑起高频、低延迟的知识检索诉求,确保知识库的扩展性与检索精准度。

需要向读者说明的是,本项目具有极强的技术探索性质。我的核心目的不仅是为了搭建一个应用,更是为了通过手写代码,由浅入深地拆解 RAG 技术在大模型应用中的落地细节与底层运作机制。

技术参考:Embedding (Vector) StoresEmbedding StoreRAGLangchan4j support for QdrantQdrant


本地 ollama 向量 LLM 的安装

别问我为什么不在阿里云 dashscope 进行测试,问就是没钱 😭。其实本地测试也挺好的,唯一的缺点就是慢…。好了废话不多说,首先是 Ollama 的安装,它是运行各种 LLM 的容器。参考资料 Ollama 下载地址和参考文档Github Ollama。其次是安装大语言模型和向量模型。由于我的本地内存也不太富裕,语言模型选择 qwen3:4b。关于向量模型,阿里通义千问的最新配套向量模型名称叫 qwen:embedding(或者你可以首选本地最流行、生态兼容性极佳的 nomic-embed-text )。拉取完,它就会常驻在本地为你提供高效率的向量化服务。

1
2
3
4
5
6
7
8
9
# 创建 ollama 命令的快捷方式
$ sudo ln -s /Applications/Ollama.app/Contents/Resources/ollama /usr/local/bin/ollama

# 验证命令是否已经认识
$ ollama --version

# 下载你想要的语言模型和向量模型
$ ollama pull qwen3:4b
$ ollama pull qwen3-embedding


本地 Qdrant 环境的准备

向量存储(VectorStore)是一种用于存储和检索高维向量数据的数据库或存储解决方案;它特别适用于处理那些经过嵌入模型转化后的数据。在 VectorStore 中,查询与传统关系数据库不同。它们执行相似性搜索,而不是精确匹配。当给定一个向量作为查询时,VectorStore 返回与查询向量相似的向量。本文我们使用 Qdrant 作为向量数据库。使用 Docker Desktop 安装和配置 Qdrant 非常简单。因为 Qdrant 是用 Rust 编写的,整个镜像极为轻量,并且它原生自带了一个 Dashboard。

1
2
3
4
5
6
7
8
9
10
11
# -p 6333:6333:Web Dashboard 控制台以及执行 REST API 请求(绝大多数 Web 交互和轻量开发使用该端口)。
# -p 6334:6334:gRPC 端口。用于 Java (LangChain4j)、Python 等客户端进行高性能、低延迟的向量数据通信。
$ docker run -d \
--name qdrant-local \
-p 6333:6333 \
-p 6334:6334 \
-v $(pwd)/qdrant_storage:/tmp/qdrant/storage \
qdrant/qdrant:latest

# 宿主机访问 Qdrant Dashboard
$ curl http://localhost:6333/dashboard

在 Qdrant 中,数据的核心单位叫做 Collection(集合),等同于关系型数据库中的表。我们可以直接在浏览器自带的 Console 中,或者在你本地的终端用 curl 命令,创建一个集合备用:

1
2
3
4
5
6
7
8
$ curl -X PUT "http://localhost:6333/collections/owlias_knowledge_base" \
-H "Content-Type: application/json" \
-d '{
"vectors": {
"size": 4096,
"distance": "Cosine"
}
}'


项目依赖和基础演示

项目依赖

下面是该项目中需要用到的一些依赖包,全部都是按最新版本来整。其父项目可以参考 父项目-POM

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
<dependencies>
<!-- OpenAI 适配器:用于将接入协议转换为标准 OpenAI API 格式(如调用 Ollama 的本地服务) -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-open-ai</artifactId>
</dependency>
<!-- LangChain4j 核心库:提供 LLM 抽象、AI Services、Prompt 模板和 RAG 核心路由调度 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j</artifactId>
</dependency>
<!-- 响应式扩展:打通 LangChain4j 与 Project Reactor,让大模型支持异步流式(Flux/Mono)输出 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-reactor</artifactId>
</dependency>
<!-- Qdrant 存储适配器:实现与生产级向量数据库 Qdrant 的高维向量读写与检索对接 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-qdrant</artifactId>
</dependency>
<!-- 通义千问社区版 Spring Boot 启动器:用于在 Spring 容器中快速装配阿里云 DashScope 模型 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-community-dashscope-spring-boot-starter</artifactId>
</dependency>
<!-- Apache Tika 文档解析器:文本提取核心,支持全自动解析 PDF、Word、PPT 等物理知识库文件 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-document-parser-apache-tika</artifactId>
</dependency>
<!-- JDK HttpClient 驱动:底层网络传输底座,负责向大模型或向量数据库发送高效的 HTTP/gRPC 请求 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-http-client-jdk</artifactId>
</dependency>
<!-- Spring WebFlux:提供 Reactive 网络栈支持,便于将 Flux<String> 转化为 Server-Sent Events (SSE) 吐给前端 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- 日志框架:负责系统运行时、HTTP 请求以及 PDFBox 等底层组件的日志过滤与输出管理 -->
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
</dependency>
</dependencies>


基本演示代码

过程演示代码

基于以上环境的准备,我们就可以先来构建一个简单的基于本地内存向量的 RAG 演示程序,目的是跑通流程。以此为起点,我们将逐步过度到使用 Qdrant 向量数据库将私域知识持久化。

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
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
import dev.langchain4j.data.document.Document;
import dev.langchain4j.data.document.loader.FileSystemDocumentLoader;
import dev.langchain4j.data.document.parser.apache.tika.ApacheTikaDocumentParser;
import dev.langchain4j.data.document.splitter.DocumentSplitters;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.http.client.jdk.JdkHttpClient;
import dev.langchain4j.model.chat.StreamingChatModel;
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.model.openai.OpenAiChatModelName;
import dev.langchain4j.model.openai.OpenAiEmbeddingModel;
import dev.langchain4j.model.openai.OpenAiStreamingChatModel;
import dev.langchain4j.model.openai.OpenAiTokenCountEstimator;
import dev.langchain4j.rag.content.retriever.ContentRetriever;
import dev.langchain4j.rag.content.retriever.EmbeddingStoreContentRetriever;
import dev.langchain4j.service.AiServices;
import dev.langchain4j.store.embedding.EmbeddingStore;
import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore;
import reactor.core.publisher.Flux;
import java.net.http.HttpClient;
import java.nio.file.Paths;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.CountDownLatch;

public class InMemoryRagExample {

// 定义一个顶层的 AI 接口服务(LangChain4j 会自动帮你动态代理实现它)
interface KnowledgeAssistant {
Flux<String> answer(String question);
}

public static void main(String[] args) throws InterruptedException {
// ==========================================
// 步骤一:初始化基础设施(大模型 & 向量模型)
// ==========================================
StreamingChatModel chatModel = OpenAiStreamingChatModel.builder()
.baseUrl("http://localhost:11434/v1")
.apiKey("api_key_xxx")
.modelName("gemma3:1b")
.temperature(0.0) // 生产 RAG 建议将温度设为 0,防止大模型胡说八道
.timeout(Duration.ofSeconds(1200))
.logRequests(true)
.logResponses(false)
.build();

// 引入本地轻量向量模型(计算 Embedding)
EmbeddingModel embeddingModel = OpenAiEmbeddingModel.builder()
.baseUrl("http://localhost:11434/v1")
.apiKey("api_key_xxx")
.modelName("qwen3-embedding")
// LangChain4j 给整个 OpenAI 客户端定义的全局调用总超时。
.timeout(Duration.ofSeconds(600))
.maxRetries(3)
.logRequests(true)
.logResponses(true)
.httpClientBuilder(JdkHttpClient.builder()
.httpClientBuilder(
// Java 进程和 Ollama 握手的最长时间:建立物理连接 ── (超过 60 秒没反应?) ──> [ConnectTimeout 报错]
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(60))
)
// 丢出数据,等待 Ollama 慢慢计算 ── (超过 300 秒没吐出结果?) ──> [ReadTimeout 报错]
.readTimeout(Duration.ofSeconds(300)))
.build();

// ==========================================
// 步骤二:构建内存知识库(In-Memory Vector Store)
// ==========================================
EmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>();


// 1. 定义好你的切片策略(直接拉出来用,不要让 Ingestor 锁死它)
var documentSplitter = DocumentSplitters.recursive(
300,
30,
new OpenAiTokenCountEstimator(OpenAiChatModelName.GPT_5)
);


// 创建一个虚拟的私有知识文档
Document privateDocument = Document.from("""
项目 Owlias v1.3 的全新架构由张三于2026年独立开发完成,核心通讯底座基于高性能 Netty 构建。
"""
);

// 加载并解析 PDF 文件
Document pdfDocument = FileSystemDocumentLoader.loadDocument(
Paths.get("/xxx/产品书册01.pdf"),
new ApacheTikaDocumentParser()
);

// 加载并解析 Word 文件
Document wordDocument = FileSystemDocumentLoader.loadDocument(
Paths.get("/xxx/产品手册02.docx"),
new ApacheTikaDocumentParser()
);

/*EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder()
// 指定切片策略:每个切片最多 300 个 Token,前后切片重叠 30 个 Token
.documentSplitter(DocumentSplitters.recursive(
300,
30, // maxOverlapSizeInTokens: 块与块之间的重叠度
new OpenAiTokenCountEstimator(OpenAiChatModelName.GPT_5)
))
.embeddingModel(embeddingModel)
.embeddingStore(embeddingStore)
.build();
ingestor.ingest(privateDocument, pdfDocument, wordDocument);*/

// 核心改造:纯手动、单条迭代灌入(彻底干掉 Batch 引起的超时问题)
System.out.println("--- 开始流式单条文本块灌入(化整为零) ---");
List<Document> allDocs = List.of(privateDocument, pdfDocument, wordDocument);
int totalSegments = 0;

for (int i = 0; i < allDocs.size(); i++) {
Document doc = allDocs.get(i);
// 本地执行纯文本切片,速度极快
List<TextSegment> segments = documentSplitter.split(doc);
System.out.printf("[文档 %d] 切片完成,共解析出 %d 个文本块。开始逐个向 Ollama 申请向量...\n", i + 1, segments.size());

for (int j = 0; j < segments.size(); j++) {
TextSegment segment = segments.get(j);

// 单条向本地 Ollama 发起计算请求,每次仅计算一个切片,耗时通常在百毫秒级
var embeddingContent = embeddingModel.embed(segment).content();
// 拿到单个向量,立刻存入内存向量库
embeddingStore.add(embeddingContent, segment);

totalSegments++;
// 每完成 10 条或者当前文档结束,打印一次进度反馈
if (totalSegments % 10 == 0 || j == segments.size() - 1) {
System.out.printf(" └进度通知:已成功安全向量化并存入 %d 个切片...\n", totalSegments);
}
}
}
System.out.println("--- 所有文档切片全部真·单发同步成功!向量库已就绪 ---");

// ==========================================
// 步骤三:构建检索器 (Content Retriever) & 拼装 RAG
// ==========================================
ContentRetriever contentRetriever = EmbeddingStoreContentRetriever.builder()
.embeddingStore(embeddingStore)
.embeddingModel(embeddingModel)
.maxResults(1) // 每次检索只召回最相似的 1 条知识片
.minScore(0.6) // 相似度阈值,低于 0.6 的不要
.build();

// 组装 AiServices
KnowledgeAssistant assistant = AiServices.builder(KnowledgeAssistant.class)
.streamingChatModel(chatModel)
.contentRetriever(contentRetriever) // 注入检索器,RAG 核心!
.build();

// ==========================================
// 步骤四:测试问答
// ==========================================
String question = "owlias 响应码 00001 代表什么?使用中文严格回答,不要瞎编。";
System.out.println("用户提问: " + question);

Flux<String> response = assistant.answer(question);
CountDownLatch countDownLatch = new CountDownLatch(1);
response.subscribe( // 订阅这个流(真正触发执行)
token -> {
System.out.print(token);
System.out.flush();
},
error -> {
System.err.println("\n发生异常: " + error.getMessage());
countDownLatch.countDown();
},
() -> {
System.out.println("\n\n--- 文本流传输结束 ---");
countDownLatch.countDown();
}
);
countDownLatch.await();
}
}

这只是一个最简单的演示程序,它最大的缺点就是将文档的向量化和知识的检索耦合在了一起,非常低效!


常见文档加载器

常见的文档加载器 Document Loader:

  • 来自 langchain4j 模块的文件系统文档加载器(FileSystemDocumentLoader)
  • 来自 langchain4j 模块的类路径文档加载器(ClassPathDocumentLoader)
  • 来自 langchain4j 模块的网址文档加载器(UrlDocumentLoader)
  • 来自 langchain4j-document-loader-amazon-s3 模块的亚马逊 S3 文档加载器(AmazonS3DocumentLoader)
  • 来自 langchain4j-document-loader-azure-storage-blob 存储文档加载器(AzureBlobStorageDocumentLoader)
  • 来自 langchain4j-document-loader-github 模块的GitHub 文档加载器(GitHubDocumentLoader)
  • 来自 langchain4j-document-loader-google-cloud-storage 模块的加载器(GoogleCloudStorageDocumentLoader)
  • 来自 langchain4j-document-loader-selenium 模块的Selenium 文档加载器(SeleniumDocumentLoader)
  • 来自 langchain4j-document-loader-tencent-cos 模块的腾讯云对象存储文档加载器(TencentCosDocumentLoader)


常见文档解析器

常见文档解析器 Document Parser:

文档可以是各种格式的文件,比如 PDF、DOC、TXT 等等。为了解析这些不同格式的文件,有一个 “文档解析器”接口 DocumentParser,并且我们的库中包含了该接口的几种实现方式:

  • 来自 langchain4j 模块的文本文档 TextDocumentParser,它能够解析纯文本格式的文件(TXT、HTML、MD 等)。
  • 来自 langchain4j-document-parser-apache-pdfbox 模块的 ApachePdfBoxDocumentParser,它可以解析 PDF 文件。
  • 来自 langchain4j-document-parser-apache-poi 模块的 Apache POI 文档解析器(ApachePoiDocumentParser),它能够解析微软办公软件的文件格式(例如 DOC、DOCX、PPT、PPTX、XLS、XLSX 等)。
  • 来自 langchain4j-document-parser-apache-tika模块的 Apache Tika 文档解析器 ApacheTikaDocumentParser,它可以自动检测并解析几乎所有现有的文件格式。


常见文档分割器

常见文档分割器 Splitter:

  • 按段落文档分割器(DocumentByParagraphSplitter)
  • 按行文档分割器(DocumentByLineSplitter)
  • 按句子文档分割器(DocumentBySentenceSplitter)
  • 按单词文档分割器(DocumentByWordSplitter)
  • 按字符文档分割器(DocumentByCharacterSplitter)
  • 按正则表达式文档分割器(DocumentByRegexSplitter)
  • 递归分割:DocumentSplitters.recursive(…)

默认情况下每个文本片段最多不能超过 300 个 token,这是 LangChain4j 内置的 “安全上限”,意思是:任何一个 TextSegment 在送入 Embedding 模型前,token 数 ≤ 300。它的目的是防止超过 Embedding 模型的最大输入长度(如 text-embedding-v2/ qwen3-embedding)。

DocumentSplitter 的工作方式是两级拆分:

1
2
3
4
5
Document
└── 一级拆分(粗粒度)
└── TextSegment(可能还太大)
└── 二级拆分(子分割器,自动触发)
└── 最终 TextSegment(≤300 token)

具体的工作过程:

  • DocumentSplitter 只做一件事,把 Document→ List<TextSegment>,它本身不关心语义,只关心:按什么规则切(段落 / 行 / 句子),每个 segment 多大,是否允许再切(子分割)。

  • 300 token 限制的 “触发点”(关键类 DefaultDocumentSplitter),超过 300 个 token 就一定会再切,子分割器是自动创建的。子分割器如下【一级 Splitter -> 默认子 Splitter】:

    • DocumentByParagraphSplitter ->DocumentBySentenceSplitter
    • DocumentByLineSplitter -> DocumentBySentenceSplitter
    • DocumentBySentenceSplitter -> DocumentByWordSplitter
    • DocumentByWordSplitter -> DocumentByCharacterSplitter
    • DocumentByCharacterSplitter -> 不再切
  • 一级切不下来就自动降级到更细粒度,这种递归的实现是内部完成的,你不需要手写递归。我们需要做的就是选对一级 Splitter(最重要)

    1
    2
    3
    4
    DocumentSplitter splitter = new DocumentByParagraphSplitter(
    300, // maxSegmentSize 就是那个 300 token
    50 // maxOverlap 段落之间重叠 token,防止语义断裂
    );

    一般不需要手动指定子 Splitter,除非你有特殊需求,如:法律合同须按“条款”切,代码按函数或类切,表格按行切:

    1
    2
    3
    4
    5
    6
    DocumentSplitter splitter = new DocumentByParagraphSplitter(
    300,
    50,
    new FixedTokenizer(), // tokenizer
    new DocumentBySentenceSplitter(...) // 显式子分割器
    );


向量化过程和检索过程解耦

向量化过程

向量化过程的实现:使用 Qdrant 实现私域知识的持久化。

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
import dev.langchain4j.data.document.Document;
import dev.langchain4j.data.document.loader.FileSystemDocumentLoader;
import dev.langchain4j.data.document.parser.apache.tika.ApacheTikaDocumentParser;
import dev.langchain4j.data.document.splitter.DocumentSplitters;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.http.client.jdk.JdkHttpClient;
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.model.openai.OpenAiChatModelName;
import dev.langchain4j.model.openai.OpenAiEmbeddingModel;
import dev.langchain4j.model.openai.OpenAiTokenCountEstimator;
import dev.langchain4j.store.embedding.qdrant.QdrantEmbeddingStore;
import java.net.http.HttpClient;
import java.nio.file.Paths;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;

/**
* 生产解耦架构 - 文档提取与离线向量化灌入程序
*/
public class IngestionPipeline {
private static final String qdrantHost = "localhost";
private static final int qdrantGrpcPort = 6334;
private static final String collectionName = "owlias_knowledge_base"; ////

public static void main(String[] args) {
// 1. 初始化生产级向量模型客户端(带严格超时重试防线)
EmbeddingModel embeddingModel = OpenAiEmbeddingModel.builder()
.baseUrl("http://localhost:11434/v1")
.apiKey("api_key_xxx")
.modelName("qwen3-embedding")
.timeout(Duration.ofSeconds(1200))
.maxRetries(3)
.logRequests(true) // 生产环境灌入一定要关掉!否则巨量的 HTTP 请求 JSON 文本会直接卡死你的控制台。测试环境先打开
.logResponses(true)
.httpClientBuilder(JdkHttpClient.builder()
.httpClientBuilder(HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(60)))
.readTimeout(Duration.ofSeconds(300)))
.build();

// 2. 初始化 Qdrant 生产级向量存储引擎
// 映射集合(Collection)名称定为 "owlias_knowledge_base"
QdrantEmbeddingStore embeddingStore = QdrantEmbeddingStore.builder()
.host(qdrantHost)
.port(qdrantGrpcPort) // Qdrant 默认 gRPC 端口为 6334
.collectionName(collectionName) // 已经不再需要强制配置维度 .dimension 1536
.build();

// 3. 配置切片策略
var documentSplitter = DocumentSplitters.recursive(
300,
30,
new OpenAiTokenCountEstimator(OpenAiChatModelName.GPT_5)
);

// 4. 读取待灌入的本地知识文档 👈🏻
System.out.println("[数据清洗] 正在加载物理文档...");
Document privateDoc = Document.from(
"""
2025年全国高考报名人数为 1335万人,比2024年的1342万人减少7万人,是多年来报名人数首次下降。
根据国家统计局公布的数据,2025年全国普通与职业本专科计划招生总人数为 1070.8万人。其中,本科录取人数约为480万人。
"""
);
Document pdfDoc = FileSystemDocumentLoader.loadDocument(Paths.get("/xxx/广东省2025年本科普通类(历史)投档情况.pdf"), new ApacheTikaDocumentParser());
Document wordDoc = FileSystemDocumentLoader.loadDocument(Paths.get("/xxx/山东省普通高校招生志愿填报百问百答.doc"), new ApacheTikaDocumentParser());
List<Document> documents = List.of(privateDoc, pdfDoc, wordDoc);

// 5. 提取并混合切片
List<TextSegment> allSegments = new ArrayList<>();
for (Document doc : documents) {
allSegments.addAll(documentSplitter.split(doc));
}
int totalSegmentSize = allSegments.size();
System.out.printf("[数据清洗] 文档解析完毕,全量切片已生成,总体积:%d 个文本块。\n", totalSegmentSize);

// 6. 生产级分批平滑灌入(既保留批量提交的高效,又避免单次提交过大拖垮本地 Ollama)
int batchSize = 2;
System.out.println("[向量化] 启动平滑灌入队列,策略:每 "+ batchSize +" 条切片打包一次...");
for (int i = 0; i < totalSegmentSize; i += batchSize) {
List<TextSegment> batchSegments = allSegments.subList(i, Math.min(i + batchSize, totalSegmentSize));

// 批量计算这 5 条数据的 Embedding (Ollama 几十毫秒即可算完)
var embeddings = embeddingModel.embedAll(batchSegments).content();

// 强力持久化到远端的 Qdrant 数据库中
embeddingStore.addAll(embeddings, batchSegments);
System.out.printf(" └ 已成功将切片区间 [%d ~ %d]/%d 同步持久化至 Qdrant 数据库。\n",
i, i + batchSegments.size() - 1, totalSegmentSize);
}
System.out.println("\n--- [SUCCESS] 离线知识库向量化成功! ---");
}
}

控制台日志输出:

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
[数据清洗] 正在加载物理文档...
[数据清洗] 文档解析完毕,全量切片已生成,总体积:215 个文本块。
[向量化] 启动平滑灌入队列,策略:每 2 条切片打包一次...
00:00:26.725 [main] INFO d.l.h.client.log.LoggingHttpClient - HTTP request:
- method: POST
- url: http://localhost:11434/v1/embeddings
- headers: [Authorization: Beare...xx], [User-Agent: langchain4j-openai], [Content-Type: application/json]
- body: {
"model" : "qwen3-embedding",
"input" : [ "2025年全国高考报名人数为 1335万人,比2024年的1342万人减少7万人,是多年来报名人数首次下降。\n根据国家统计局公布的数据,2025年全国普通与职业本专科计划招生总人数为 1070.8万人。其中,本科录取人数约为480万人。", "广东省2025年本科普通类(历史)投档情况\n院校代码 院校名称 专业组代码 计划数 投档人数 投档最低分 投档最低排位\n\n10001 北京大学 205 24 24 669 28\n\n10002 中国人民大学 205 56 56 655 148\n\n10002 中国人民大学 208 ..." ]
}

00:01:45.630 [main] INFO d.l.h.client.log.LoggingHttpClient - HTTP response:
- status code: 200
- headers: [content-type: application/json], [date: Sat, 11 Jul 2026 16:01:45 GMT], [transfer-encoding: chunked]
- body: {"object":"list","data":[{"object":"embedding","embedding":[0.02671325,0.013727239,-0.018859694,...],"index":1}],"model":"qwen3-embedding","usage":{"prompt_tokens":510,"total_tokens":510}}

└ 已成功将切片区间 [0 ~ 1]/215 同步持久化至 Qdrant 数据库。

00:01:45.961 [main] INFO d.l.h.client.log.LoggingHttpClient - HTTP request:
- method: POST
- url: http://localhost:11434/v1/embeddings
- headers: [Authorization: Beare...xx], [User-Agent: langchain4j-openai], [Content-Type: application/json]
- body: {
"model" : "qwen3-embedding",
"input" : [ "10019 中国农业大学 209 7 7 605 4208\n\n10022 北京林业大学 220 12 12 595 6372\n\n10022 北京林业大学 221 4 4 591 7300\n\n10022 北京林业大学 224 2 2 596 6004\n\n10022 北京...北京体育大学(中外合作办学) 217 8 8 542 28624" ]
}
...
--- [SUCCESS] 离线知识库向量化成功! ---

查看本地 Qdrant 向量数据库,一切OK!


实时问答与检索

向量化和问答走两条线,互不干扰:

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
/**
* 生产解耦架构 - 实时问答与检索服务
*/
public class LiveRAGServer {
interface KnowledgeAssistant {
Flux<String> answer(String question);
}

public static void main(String[] args) throws InterruptedException {
// 1. 初始化用户对话模型(开启流式响应)
StreamingChatModel chatModel = OpenAiStreamingChatModel.builder()
.baseUrl("http://localhost:11434/v1")
.apiKey("api_key_xxx")
.modelName("qwen3:4b")
.temperature(0.0) // 严格遵循上下文,防止幻觉
.timeout(Duration.ofSeconds(1200))
.logRequests(true)
.logResponses(false)
.build();

// 2. 初始化用于“将问题转化为向量”的轻量 Embedding 模型
EmbeddingModel embeddingModel = OpenAiEmbeddingModel.builder()
.baseUrl("http://localhost:11434/v1")
.apiKey("api_key_xxx")
.modelName("qwen3-embedding")
.timeout(Duration.ofSeconds(600))
.maxRetries(3)
.logRequests(true)
.logResponses(true)
.build();

// 3. 直接连接生产环境已建立好的 Qdrant 向量数据存储(不再包含任何读文件及切片逻辑)
QdrantEmbeddingStore embeddingStore = QdrantEmbeddingStore.builder()
.host("localhost")
.port(6334)
.collectionName("owlias_knowledge_base")
.build();

// 4. 拼装智能检索器:提问时自动去 Qdrant 进行向量库内高维检索
ContentRetriever contentRetriever = EmbeddingStoreContentRetriever.builder()
.embeddingStore(embeddingStore)
.embeddingModel(embeddingModel)
.maxResults(3) // 允许召回得分最高的前 3 条关联上下文
.minScore(0.6) // 相似度分水岭
.build();

// 5. 动态代理生成业务 AI 服务
KnowledgeAssistant assistant = AiServices.builder(KnowledgeAssistant.class)
.streamingChatModel(chatModel)
.contentRetriever(contentRetriever)
.build();

// ========================================================
// ⚡ 模拟线上高并发/实时问答测试:用户提问
// ========================================================
String question = "2025年广东省本科普通类历史专业,清华大学的计划数和投档人数各是多少?请严格回答。";
System.out.println("[系统就绪] 正在向 Qdrant 毫秒级检索上下文并激活大模型...");
System.out.println("用户输入: " + question);
System.out.print("AI 响应: -> ");

long startTime = System.currentTimeMillis();
Flux<String> responseStream = assistant.answer(question);
CountDownLatch latch = new CountDownLatch(1);

responseStream.subscribe(
token -> {
System.out.print(token);
System.out.flush();
},
error -> {
System.err.println("\n[ERROR] 运行时发生异常: " + error.getMessage());
latch.countDown();
},
() -> {
long duration = System.currentTimeMillis() - startTime;
System.out.printf("\n\n--- 文本流传输结束 (首屏耗时及检索总响应: %d ms) ---\n", duration);
latch.countDown();
}
);

latch.await();
}
}

控制台日志:

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
[系统就绪] 正在向 Qdrant 毫秒级检索上下文并激活大模型...
用户输入: 2025年广东省本科普通类历史专业,清华大学的计划数和投档人数各是多少?请严格回答。
AI 响应: -> 00:29:02.745 [main] INFO d.l.h.client.log.LoggingHttpClient - HTTP request:
- method: POST
- url: http://localhost:11434/v1/embeddings
- headers: [Authorization: Beare...xx], [User-Agent: langchain4j-openai], [Content-Type: application/json]
- body: {
"model" : "qwen3-embedding",
"input" : [ "2025年广东省本科普通类历史专业,清华大学的计划数和投档人数各是多少?请严格回答。" ]
}

00:29:08.135 [main] INFO d.l.h.client.log.LoggingHttpClient - HTTP response:
- status code: 200
- headers: [content-type: application/json], [date: Sat, 11 Jul 2026 16:29:08 GMT], [transfer-encoding: chunked]
- body: {"object":"list","data":[{"object":"embedding","embedding":[0.02146144,0.022769632,0.0018570911,-0.010346769,...-0.006307611],"index":0}],"model":"qwen3-embedding","usage":{"prompt_tokens":27,"total_tokens":27}}

00:29:08.808 [main] INFO d.l.h.client.log.LoggingHttpClient - HTTP request:
- method: POST
- url: http://localhost:11434/v1/chat/completions
- headers: [Authorization: Beare...xx], [User-Agent: langchain4j-openai], [Content-Type: application/json]
- body: {
"model" : "qwen3:4b",
"messages" : [ {
"role" : "user",
"content" : "2025年广东省本科普通类历史专业,清华大学的计划数和投档人数各是多少?请严格回答。\n\nAnswer using the following information:\n广东省2025年本科普通类(历史)投档情况\n院校代码 院校名称 专业组代码 计划数 投档人数 投档最低分 投档最低排位\n\n10001 北京大学 205 24 24 669 28\n\n10002 中国人民大学 205 56 56 655 148\n\n10002 中国人民大学 208 6 6 666 47\n\n10003 清华大学 203 4 4 675 13\n\n10004 北京交通大学 ...\n\n10107 石家庄铁道大学 206 2 2 536 32215"
} ],
"temperature" : 0.0,
"stream" : true,
"stream_options" : {
"include_usage" : true
}
}

嗯,用户问的是2025年广东省本科普通类历史专业中清华大学的计划数和投档人数。这个问题需要仔细核对提供的数据。...
</think>

根据提供的广东省2025年本科普通类(历史)投档数据表,清华大学的计划数和投档人数如下:

**院校代码**:10003
**院校名称**:清华大学
**专业组代码**:203
**计划数**:4
**投档人数**:4

**严格回答**:
清华大学2025年广东省本科普通类(历史)专业计划数为 **4**,投档人数为 **4**。

--- 文本流传输结束 (首屏耗时及检索总响应: 207411 ms) ---

OK,一切安好。从以上日志输出我们也可以得出 RAG 的完整分工图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#1.文本向量化过程:
parser + splitter + qwen3-embedding(Embedding Model) + qdrant(VecStore)
向量数据库不会、也不可能完成文本向量化,它只认向量,不认文本。
只要系统里出现向量数据库,就必定伴随一个 Embedding 模型。区别只是你显式管理还是云厂商隐式托管。

#2.检索和生成过程:
用户问题

向量数据库(检索 Top-K 相关片段)

Prompt = 问题 + 检索结果 + 指令

LLM 大模型(理解 + 推理 + 生成)👈🏻 这才是核心,VecStore 是做不到这三点的!

最终答案


接口服务化

向量化和实时问答两条路都跑通了。下面就该向外暴露调用接口了。

启动类

1
2
3
4
5
6
@SpringBootApplication
public class App {
public static void main(String[] args) {
SpringApplication.run(App.class, args);
}
}


配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
server:
port: 8080
servlet:
encoding:
# 避免流式输出中文乱码
charset: UTF-8
enabled: true
force: true

logging:
level:
root: INFO
dev.langchain4j: ERROR


AiService 接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public interface KnowledgeAssistant {

@SystemMessage("""
# Role
你是 Owlias 官方教育知识小助手。你的核心职责是为用户提供专业、准确、耐心的知识解答。

# Severe Restrictions (硬性防线)
1. 严格基于召回的上下文进行回答。如果上下文中没有提到、或者你无法从中推导出来,请直接回答:“抱歉,在 Owlias 现有知识库中未检索到相关信息,我无法为您解答。”,绝对不允许发挥想象力瞎编或瞎猜。
2. 当用户询问项目架构、响应码(例如 00001)时,必须严格比对上下文中的技术定义,确保术语的工程严谨性。

# Tone & Style
- 保持温暖、亲切且专业的专业导师语气。
- 逻辑清晰,善于使用 Markdown 的列表(-)或粗体(**)来梳理结构,避免大段黏稠的文字。
- 涉及代码或技术架构时,请直接给出具体、可直接阅读的结构。
""")
Flux<String> answer(@MemoryId Long userId, @UserMessage String question);
}


组装配置类

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
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import dev.langchain4j.model.chat.StreamingChatModel;
import dev.langchain4j.model.openai.OpenAiEmbeddingModel;
import dev.langchain4j.model.openai.OpenAiStreamingChatModel;
import dev.langchain4j.rag.content.retriever.ContentRetriever;
import dev.langchain4j.rag.content.retriever.EmbeddingStoreContentRetriever;
import dev.langchain4j.service.AiServices;
import dev.langchain4j.store.embedding.qdrant.QdrantEmbeddingStore;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Duration;

@Configuration
public class LLMConfig {

@Bean
public StreamingChatModel localQwen3StreamingChatModel() {
return OpenAiStreamingChatModel.builder()
.baseUrl("http://localhost:11434/v1")
.apiKey("api_key_xxx")
.modelName("qwen3:4b")
.temperature(0.0) // 严格遵循上下文,防止幻觉
.timeout(Duration.ofSeconds(1200))
.logRequests(false)
.logResponses(false)
.build();
}

@Bean
public OpenAiEmbeddingModel localQwenEmbeddingModel() {
return OpenAiEmbeddingModel.builder()
.baseUrl("http://localhost:11434/v1")
.apiKey("api_key_xxx")
.modelName("qwen3-embedding")
.timeout(Duration.ofSeconds(600))
.maxRetries(3)
.logRequests(false)
.logResponses(false)
.build();
}

@Bean
public QdrantEmbeddingStore qdrantEmbeddingStore() {
return QdrantEmbeddingStore.builder()
.host("localhost")
.port(6334)
.collectionName("owlias_knowledge_base")
.build();
}

@Bean
public ContentRetriever contentRetriever(
QdrantEmbeddingStore embeddingStore,
OpenAiEmbeddingModel embeddingModel) {
return EmbeddingStoreContentRetriever.builder()
.embeddingStore(embeddingStore)
.embeddingModel(embeddingModel)
.maxResults(3) // 允许召回得分最高的前 3 条关联上下文
.minScore(0.6) // 相似度分水岭
.build();
}

@Bean
public KnowledgeAssistant knowledgeAssistant(
@Qualifier("localQwen3StreamingChatModel") StreamingChatModel streamingChatModel,
ContentRetriever contentRetriever) {
return AiServices.builder(KnowledgeAssistant.class)
.streamingChatModel(streamingChatModel)
.contentRetriever(contentRetriever)
.chatMemoryProvider(memoryId -> MessageWindowChatMemory.builder()
.id("chat:" + memoryId)
.alwaysKeepSystemMessageFirst(true)
.dynamicMaxMessages(userId -> 10) // 每个用户格子只保留最近 10 条对话,等价于 .maxMessages(10)
// .chatMemoryStore(chatMemoryStore) // 🔌 绑定我们基于 Spring Data Redis 的存储器
.build())
.build();
}
}


业务测试类

1
2
3
4
5
6
7
8
9
10
11
12
@RestController
public class EduAssistantController {

@Resource
private KnowledgeAssistant knowledgeAssistant;

@GetMapping(value = "/answer", produces = MediaType.TEXT_PLAIN_VALUE)
public Flux<String> answer(@RequestParam(value = "userId") Long userId,
@RequestParam(value = "question") String question) {
return knowledgeAssistant.answer(userId, question);
}
}

访问接口,我们就可以看到浏览器那熟悉的打字机响应:

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
$ curl -N -G 'http://localhost:8080/answer' \
--data-urlencode 'userId=1' \
--data-urlencode 'question=我是张三,想知道北京大学2025年历史专业的报考情况'

... ...
看起来OK。
</think>

你好,张三!

根据Owlias知识库中检索到的**广东省2025年本科普通类(历史)投档数据**(注:此数据仅适用于广东省考生,全国报考情况需结合各省份招生政策),北京大学的报考情况如下:

- **院校代码**:10001
- **院校名称**:北京大学
- **专业组代码**:205
- **计划数**:24人
- **投档人数**:24人(全部投档)
- **投档最低分**:669分
- **投档最低排位**:28(广东省内)

> 📌 **重要提示**:
> 1. 以上数据为**广东省2025年历史类投档情况**(普通类历史),实际报考时需以北京大学官方招生章程为准,不同省份、不同专业组的录取规则可能有差异。
> 2. “历史专业”在高考中通常指历史类考生(普通类历史),但具体专业组(如205)需结合当年招生计划确认。

建议你直接咨询北京大学招生办(电话:010-62373200)或广东省教育考试院(电话:0755-26018000),获取最精准的2025年报考信息。

祝你备考顺利,金榜题名! 🌟


RAG 方案的劣势

第一个也是本质的缺点:RAG 本质上是 “查资料”,不是 “长记性”,模型不会因为你加了文档就变聪明。 RAG 的知识始终外挂于模型参数,模型权重在推理期静态不变,因此不具备真正意义上的知识内化与自演化能力。例如,企业制度分散在 50 个文档里,且每年微调,RAG 能答单文档问题,但答不出 “过去三年里,年假政策演变趋势是什么?”

第二,RAG 的检索质量决定天花板。如果检索质量做到额不好,那么就会出现: 召回不准导致的答非所问、切片策略差导致的上下文断裂、多跳问题引起的遗漏关键片段等问题。而检索问题模型本身无法修复,因为它“看不见”全库。

那么 “知识自增长” 在企业中通常怎么做呢?现实工业界不是二选一,而是分层:

层级 机制 是否“知识内化”
热知识 RAG(向量库)
中频知识 Fine-tuning / LoRA 是:参数级
核心知识 预训练 / 继续预训练 是:深度内化
用户偏好 RLHF / DPO 是:行为内化

典型的实现路径:

1
2
3
4
新文档
├─ 实时 → 向量库(RAG)
├─ 周级 → LoRA 微调
└─ 季度 → 全量评估 / 重训