前言

当前 AI 技术生态以 Python 为主导,这几天在研究用 Java 搭建知识库使用,最终都避不开 Python,于是打算记录下结果,目前是有 2 个方案,第一个方案是 在 Python 中使用 embedding 嵌入模型,完成数据向量化与向量搜索,推荐使用这个方案,简单也方便。第二个方案是不使用 embedding 嵌入模型,使用 es 来完成向量存储,但仍需要 Python 来完成数据的向量化。

本文分为三部分,第一部分是接入 deepseek-r1,第二部分是接入联网搜索,第三部分是使用自建知识库(两个实现方案),知识库为可选功能,并且实现起来也挺麻烦,不需要的可以直接看前两部分即可。

前置准备

首先介绍一下本次的开发环境:

Java17 + SpringBoot 3.3.2

Python 3.11

deepseek 的 APIkeys(在官网上买就可以了)

tavily(搜索引擎,通过这个实现联网搜索,在第二部分会具体说)

项目依赖

我们需要一个 SpringBoot 的项目,具体依赖如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<dependencies>
<!--springBoot依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

<!--联网搜索部分所需依赖-->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.9.3</version>
</dependency>
</dependencies>

第一部分 - 接入 deepseek-r1

获取 ApiKeys

操作步骤如下,进入 deepseek 官网,充值余额,生成 key 即可。

image-20250306163853577

编写基本代码

封裝聊天请求类,包含四个基本参数。

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
public class ChatRequest {
// 用户的问题
private String message;
// 是否启用联网搜索
private boolean useSearch;
// 是否使用知识库
private boolean useRAG;
// 是否启用知识库最大阈值
private boolean maxToggle;

public ChatRequest() {
}

public ChatRequest(String message, boolean useSearch, boolean useRAG, boolean maxToggle) {
this.message = message;
this.useSearch = useSearch;
this.useRAG = useRAG;
this.maxToggle = maxToggle;
}

public String getMessage() {
return message;
}

public void setMessage(String message) {
this.message = message;
}

public boolean isUseSearch() {
return useSearch;
}

public void setUseSearch(boolean useSearch) {
this.useSearch = useSearch;
}

public boolean isUseRAG() {
return useRAG;
}

public void setUseRAG(boolean useRAG) {
this.useRAG = useRAG;
}

public boolean isMaxToggle() {
return maxToggle;
}

public void setMaxToggle(boolean maxToggle) {
this.maxToggle = maxToggle;
}
}

这里是核心功能类、实现了基本对话功能的代码,只需要配置 API_KEY 变量就可以启动测试,默认使用 deepseek-r1,你也可以改为 v3。

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
@RestController
@RequestMapping("/api")
public class DeepSeekController {

// 存储上下文信息
private final Deque<Map<String, String>> conversationHistory = new ArrayDeque<>();

// 序列化参数
private final ObjectMapper objectMapper = new ObjectMapper();

// 设置最大的上下文信息
private final int MAX_HISTORY = 10;

// deepseek 的 API_KEY
private final String API_KEY = "Bearer sk-xxxxxxxxxxxxx";

@PostMapping("/chat")
public SseEmitter chat(@RequestBody ChatRequest request) {
SseEmitter emitter = new SseEmitter();
try {
// 创建用户消息
Map<String, String> userMessage = new HashMap<>();
userMessage.put("role", "user");
userMessage.put("content", request.getMessage());
conversationHistory.add(userMessage);

// 准备请求体
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("model", "deepseek-reasoner");
// requestBody.put("model", "deepseek-chat");
requestBody.put("messages", new ArrayList<>(conversationHistory));
requestBody.put("stream", true);

// 创建 HTTP 客户端
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(600))
.build();
HttpRequest httpRequest = HttpRequest.newBuilder()
.uri(URI.create("https://api.deepseek.com/chat/completions"))
.header("Content-Type", "application/json")
.header("Authorization", API_KEY)
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(requestBody)))
.build();

// 发送请求并处理响应流
StringBuilder aiResponseBuilder = new StringBuilder();
StringBuilder reasoningBuilder = new StringBuilder();
System.out.println("\n\n" + "=".repeat(20) + "思考过程" + "=".repeat(20) + "\n");
client.send(httpRequest, HttpResponse.BodyHandlers.ofLines())
.body()
.forEach(line -> {
try {
if (line.startsWith("data: ")) {
String jsonData = line.substring(6);
if (!"[DONE]".equals(jsonData)) {
Map<String, Object> response = objectMapper.readValue(jsonData, Map.class);
Map<String, Object> delta = extractDeltaContent(response);

// 处理思考过程
if (delta != null && delta.containsKey("reasoning_content") && delta.get("reasoning_content") != null) {
String reasoningContent = (String) delta.get("reasoning_content");
reasoningBuilder.append(reasoningContent);
// 直接打印思考过程
System.out.print(reasoningContent);
System.out.flush(); // 确保立即打印
// 发送思考过程,使用不同的事件类型
emitter.send(SseEmitter.event()
.name("reasoning")
.data(Map.of("reasoning_content", reasoningContent)));
}

// 处理回答内容
if (delta != null && delta.containsKey("content") && delta.get("content") != null) {
// 如果是第一个回答内容,先打印分隔线
if (aiResponseBuilder.isEmpty()) {
System.out.println("\n\n" + "=".repeat(20) + "思考结束" + "=".repeat(20) + "\n");
}
String content = (String) delta.get("content");
aiResponseBuilder.append(content);
// 直接打印回答内容
System.out.print(content);
System.out.flush(); // 确保立即打印
emitter.send(SseEmitter.event()
.name("answer")
.data(Map.of("content", content)));
}
}
}
} catch (Exception e) {
emitter.completeWithError(e);
}
});

// 创建AI响应消息并添加到历史记录
Map<String, String> aiMessage = new HashMap<>();
aiMessage.put("role", "assistant");
aiMessage.put("content", aiResponseBuilder.toString());
conversationHistory.add(aiMessage);

// 如果历史记录超过最大限制,移除最早的消息
while (conversationHistory.size() > MAX_HISTORY * 2) {
conversationHistory.pollFirst();
}

emitter.complete();
} catch (Exception e) {
emitter.completeWithError(e);
}
return emitter;
}

// 解析响应
private Map<String, Object> extractDeltaContent(Map<String, Object> response) {
List<Map<String, Object>> choices = (List<Map<String, Object>>) response.get("choices");
if (choices != null && !choices.isEmpty()) {
return (Map<String, Object>) choices.get(0).get("delta");
}
return null;
}

// 清除上下文信息
@PostMapping("/clean")
public void clearHistory() {
conversationHistory.clear();
}
}

第二部分 - 接入 联网搜索

联网搜索我们需要借助一个免费的 ai 搜索引擎实现,每个月有 1000 次搜索次数,已经足够日常使用了。官网: Tavily 注册完成后创建一个 ApiKey 即可。

image-20250311141709755

添加一个搜索类,负责实现我们的搜索逻辑,同样只需要替换 apiKey 的变量即可。

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
@Component
public class SearchUtils {
// 搜索引擎
private String baseUrl = "https://api.tavily.com/search";
// apikey
private String apiKey = "tvly-dev-xxxxxxxxxx";
private final OkHttpClient client;
private final ObjectMapper objectMapper;

public SearchUtils() {
this.client = new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build();
this.objectMapper = new ObjectMapper();
}

public List<Map<String, String>> tavilySearch(String query) {
List<Map<String, String>> results = new ArrayList<>();
try {
Map<String,String> requestBody = new HashMap<String, String>();
requestBody.put("query", query);
Request request = new Request.Builder()
.url(baseUrl)
.post(RequestBody.create(MediaType.parse("application/json"), objectMapper.writeValueAsString(requestBody)))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer" + apiKey)
.build();


try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful()) throw new IOException("请求失败: " + response);

JsonNode jsonNode = objectMapper.readTree(response.body().string()).get("results");

if (!jsonNode.isEmpty()) {
jsonNode.forEach(data -> {
Map<String, String> processedResult = new HashMap<>();
processedResult.put("title", data.get("title").toString());
processedResult.put("url", data.get("url").toString());
processedResult.put("content", data.get("content").toString());
results.add(processedResult);
});
}
}
} catch (Exception e) {
System.err.println("搜索时发生错误: " + e.getMessage());
}
return results;
}
}

然后在核心类中引入搜索类,并在向 ai 提问前先去搜索并提前加入到 prompt 中,此时核心类如下:

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
@RestController
@RequestMapping("/api")
public class DeepSeekController {

// 存储上下文信息
private final Deque<Map<String, String>> conversationHistory = new ArrayDeque<>();

// 序列化参数
private final ObjectMapper objectMapper = new ObjectMapper();

// 设置最大的上下文信息
private final int MAX_HISTORY = 10;

// deepseek 的 apikey
private final String API_KEY = "Bearer sk-xxxxxxxxxxxxxxxxx";

private final SearchUtils searchUtils;

public DeepSeekController(SearchUtils searchUtils) {
this.searchUtils = searchUtils;
}

@PostMapping("/chat")
public SseEmitter chat(@RequestBody ChatRequest request) {
SseEmitter emitter = new SseEmitter();
try {
// 获取搜索结果
StringBuilder context = new StringBuilder();
if (request.isUseSearch()) {
List<Map<String, String>> searchResults = searchUtils.tavilySearch(request.getMessage());
if (!searchResults.isEmpty()) {
System.out.println("search results size(联网搜索个数): " + searchResults.size());
context.append("\n\n联网搜索结果:\n");
for (int i = 0; i < searchResults.size(); i++) {
Map<String, String> result = searchResults.get(i);
context.append(String.format("\n%d. %s\n", i + 1, result.get("title")));
context.append(String.format(" %s\n", result.get("content")));
context.append(String.format(" 来源: %s\n", result.get("url")));
}
}
}
// 如果有上下文,添加系统消息
if (context.length() > 0) {
Map<String, String> systemMessage = new HashMap<>();
systemMessage.put("role", "system");
systemMessage.put("content", "请基于以下参考信息回答用户问题:\n" + context.toString());
conversationHistory.add(systemMessage);
}

// 创建用户消息
Map<String, String> userMessage = new HashMap<>();
userMessage.put("role", "user");
userMessage.put("content", request.getMessage());
conversationHistory.add(userMessage);

// 准备请求体
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("model", "deepseek-reasoner");
// requestBody.put("model", "deepseek-chat");
requestBody.put("messages", new ArrayList<>(conversationHistory));
requestBody.put("stream", true);

// 创建 HTTP 客户端
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(600))
.build();
HttpRequest httpRequest = HttpRequest.newBuilder()
.uri(URI.create("https://api.deepseek.com/chat/completions"))
.header("Content-Type", "application/json")
.header("Authorization", API_KEY)
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(requestBody)))
.build();

// 发送请求并处理响应流
StringBuilder aiResponseBuilder = new StringBuilder();
StringBuilder reasoningBuilder = new StringBuilder();
System.out.println("\n\n" + "=".repeat(20) + "思考过程" + "=".repeat(20) + "\n");
client.send(httpRequest, HttpResponse.BodyHandlers.ofLines())
.body()
.forEach(line -> {
try {
if (line.startsWith("data: ")) {
String jsonData = line.substring(6);
if (!"[DONE]".equals(jsonData)) {
Map<String, Object> response = objectMapper.readValue(jsonData, Map.class);
Map<String, Object> delta = extractDeltaContent(response);

// 处理思考过程
if (delta != null && delta.containsKey("reasoning_content") && delta.get("reasoning_content") != null) {
String reasoningContent = (String) delta.get("reasoning_content");
reasoningBuilder.append(reasoningContent);
// 直接打印思考过程
System.out.print(reasoningContent);
System.out.flush(); // 确保立即打印
// 发送思考过程,使用不同的事件类型
emitter.send(SseEmitter.event()
.name("reasoning")
.data(Map.of("reasoning_content", reasoningContent)));
}

// 处理回答内容
if (delta != null && delta.containsKey("content") && delta.get("content") != null) {
// 如果是第一个回答内容,先打印分隔线
if (aiResponseBuilder.isEmpty()) {
System.out.println("\n\n" + "=".repeat(20) + "思考结束" + "=".repeat(20) + "\n");
}
String content = (String) delta.get("content");
aiResponseBuilder.append(content);
// 直接打印回答内容
System.out.print(content);
System.out.flush(); // 确保立即打印
emitter.send(SseEmitter.event()
.name("answer")
.data(Map.of("content", content)));
}
}
}
} catch (Exception e) {
emitter.completeWithError(e);
}
});

// 创建AI响应消息并添加到历史记录
Map<String, String> aiMessage = new HashMap<>();
aiMessage.put("role", "assistant");
aiMessage.put("content", aiResponseBuilder.toString());
conversationHistory.add(aiMessage);

// 如果历史记录超过最大限制,移除最早的消息
while (conversationHistory.size() > MAX_HISTORY * 2) {
conversationHistory.pollFirst();
}

emitter.complete();
} catch (Exception e) {
emitter.completeWithError(e);
}
return emitter;
}

// 解析响应
private Map<String, Object> extractDeltaContent(Map<String, Object> response) {
List<Map<String, Object>> choices = (List<Map<String, Object>>) response.get("choices");
if (choices != null && !choices.isEmpty()) {
return (Map<String, Object>) choices.get(0).get("delta");
}
return null;
}

// 清除上下文信息
@PostMapping("/clean")
public void clearHistory() {
conversationHistory.clear();
}
}

此时可以看到,在创建用户消息前,请求参数中就已经存在联网搜索的结果。

image-20250311142723629

第三部分 - 接入 自建知识库

知识库部分的实现有 2 种方式,推荐采用 embedding 方案(效果更优且维护成本低),ES 方案适用于已有 ES 技术栈的场景

通过 embedding 嵌入模型 实现

首先我们要先搞明白,什么是 embedding 嵌入模型?embedding 是机器学习的核心技术之一,通过将离散(文字、图片等)的数据转为连续的向量空间,并捕获数据特征。例如我们搜索的时候, 输入可爱的猫图,那么 embedding 就会把这五个字转为数字数组得到向量,再根据这个向量和所有的数据向量距离进行对比,数值越近说明越相关。最后按照相似度把数据返回给我们。

数据向量化我们需要借助 python 实现,python 项目结构如下,忽略 Dockerfile。app.py 是代码的主体,config.json 是配置文件,我们只需要改动这个地方即可。data 下存放的是我们知识库元文件及索引文件(刚开始没有 index 文件属于正常的,因为 index 是基于 json 格式的知识库生成的)。model 则是我下载的 embedding 模型,最后 requirements 则是依赖表。

image-20250311150230554

下面介绍具体的使用方式:

创建项目,下载 m3e-base 向量模型

1
git clone https://huggingface.co/moka-ai/m3e-base ./model/m3e-base

执行完成后注意,需额外手动下载两个配置文件。进入 huggingface 的地址:m3e-base,手动将这两个文件下载下来,放到 model 目录内。

image-20250311154933262

创建 app.py 文件,不需要改任何地方

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
178
179
180
181
182
183
184
185
186
187
188
189
190
191
import os
import json
import tempfile

import faiss
from flask import Flask, request, jsonify, send_file
from sentence_transformers import SentenceTransformer
from pathlib import Path
from typing import List, Dict

app = Flask(__name__)

# 加载配置文件
with open('config.json', 'r', encoding='utf-8') as f:
CONFIG = json.load(f)

data_fields = CONFIG["data_fields"]
required_data_fields = ["metadata_fields", "content_field"]
for field in required_data_fields:
if field not in data_fields:
raise KeyError(f"Missing required data_fields config: {field}")
metadata_fields = data_fields["metadata_fields"]
content_field = data_fields["content_field"]

# 初始化模型
if not Path(CONFIG["model_path"]).exists():
raise FileNotFoundError(f"模型未找到: {CONFIG['model_path']}")
model = SentenceTransformer(CONFIG["model_path"])

class VectorSearchSystem:
def __init__(self):
self.index = None
self.documents = []
self._auto_load()

def _auto_load(self):
"""自动加载持久化数据"""
try:
# 加载FAISS索引
if os.path.exists(CONFIG["index_file"]):
self.index = faiss.read_index(CONFIG["index_file"])
else:
self.initialize_index()

# 加载文档元数据
if os.path.exists(CONFIG["json_data_file"]):
with open(CONFIG["json_data_file"], 'r', encoding='utf-8') as f:
self.documents = json.load(f)
else:
self.documents = []

except Exception as e:
print(f"[ERROR] 数据加载失败: {str(e)}")
self.initialize_index()
self.documents = []

def initialize_index(self):
"""创建新索引"""
self.index = faiss.IndexFlatIP(CONFIG["vector_dim"])

def search(self, query: str, top_k: int = None) -> List[Dict]:
top_k = top_k or CONFIG["default_top_k"]
query_vector = model.encode([query], normalize_embeddings=True).astype('float32')
distances, indices = self.index.search(query_vector, top_k*2) # 扩大召回范围
results = []
for idx, score in zip(indices[0], distances[0]):
if score < CONFIG["similarity_threshold"]:
continue # 关键点:严格阈值过滤
if 0 <= idx < len(self.documents):
results.append({
**self.documents[idx],
"similarity_score": float(score)
})

# 二次排序并截断
return sorted(results, key=lambda x: x["similarity_score"], reverse=True)[:top_k]

# 初始化系统
search_system = VectorSearchSystem()

def format_search_result(result: Dict) -> str:
"""格式化单个搜索结果"""
content_text = result.get(content_field, "")

# 处理元数据字段
metadata_lines = []
for field in metadata_fields:
value = result.get(field, "")
if value:
metadata_lines.append(f"{value}\n")

# 组合元数据和内容
formatted_metadata = "".join(metadata_lines)
formatted_content = f"{content_text}\n" if content_text else ""

return f"{formatted_metadata}{formatted_content}"

@app.route('/api/search', methods=['GET'])
def handle_search():
"""搜索接口"""
query = request.args.get('query')
top_k = request.args.get('top_k', type=int)

if not query:
return jsonify({"error": "Missing query parameter"}), 400

try:
results = search_system.search(query, top_k=top_k)
# 按照相似度分数排序
sorted_results = sorted(results, key=lambda x: x["similarity_score"], reverse=True)
# 格式化输出
formatted_output = "\n".join([format_search_result(r) for r in sorted_results])
return app.response_class(
response=formatted_output,
status=200,
mimetype='text/plain'
)
except Exception as e:
return jsonify({"error": str(e)}), 500

@app.route('/api/generate-index', methods=['POST'])
def generate_index():
"""
生成临时索引接口
接收JSON文件 → 生成FAISS索引 → 返回索引文件和对应的处理后的JSON
"""
if 'file' not in request.files:
return jsonify({"error": "No file uploaded"}), 400

file = request.files['file']
if file.filename == '':
return jsonify({"error": "Empty filename"}), 400

try:
# 使用临时目录处理
with tempfile.TemporaryDirectory() as tmp_dir:
# 解析输入数据
documents = json.load(file)

# 验证数据格式
required_fields = metadata_fields + [content_field]
for doc in documents:
if not all(field in doc for field in required_fields):
missing = [field for field in required_fields if field not in doc]
raise ValueError(f"Document missing fields: {missing}")

# 生成向量
contents = [doc[content_field] for doc in documents]
vectors = model.encode(contents, normalize_embeddings=True).astype('float32')

# 创建临时索引
tmp_index_path = Path(tmp_dir) / "temp_index.index"
index = faiss.IndexFlatIP(CONFIG["vector_dim"])
index.add(vectors)
faiss.write_index(index, str(tmp_index_path))

# 生成带ID的元数据
processed_data = [
{**{field: doc[field] for field in metadata_fields},
content_field: doc[content_field],
"vector_id": idx}
for idx, doc in enumerate(documents)
]

# 保存临时JSON
tmp_json_path = Path(tmp_dir) / "processed_data.json"
with open(tmp_json_path, 'w', encoding='utf-8') as f:
json.dump(processed_data, f, ensure_ascii=False)


index = faiss.IndexFlatIP(CONFIG["vector_dim"])
index.add(vectors)
faiss.write_index(index, str(CONFIG['faiss_dir']))

# 打包返回文件(示例保留索引文件)
return "200"
# return send_file(
# tmp_index_path,
# mimetype='application/octet-stream',
# as_attachment=True,
# download_name="generated_index.index"
# )

except json.JSONDecodeError:
return jsonify({"error": "Invalid JSON format"}), 400
except Exception as e:
return jsonify({"error": str(e)}), 500

if __name__ == '__main__':
os.makedirs('data', exist_ok=True)
app.run(host='0.0.0.0', port=CONFIG["server_port"], debug=CONFIG["debug"])

创建 config.json 文件,下面是每个参数具体的意思:

  • model_path:向量的预训练模型的路径

  • index_file:搜索时使用的向量索引的文件路径

  • json_data_file:搜索时使用的原始数据的 JSON 文件路径

  • faiss_dir:根据原始数据 JSON 生成的 faiss 文件存储路径

  • vector_dim:向量的维度大小

  • default_top_k:默认情况下返回的最相似结果的数量

  • similarity_threshold:相似度阈值(仅返回高于该值的结果)

  • server_port:服务端口号

  • debug:调试模式

  • result_format:

    • include_score:返回的结果中是否包含相似度得分
    • max_content_length:返回结果中内容的最大长度,超出部分会被截断
  • data_fields:

    • metadata_fields:原始数据中,除向量字段外的所有字段
    • content_field:原始数据中的向量字段(只能有一个)

下面我简单说一下要如何配置,model_path 是我们的向量模型路径,这里我用的是 m3e-base 模型,模型小而且效果不错,后面会使用该模型做演示并下载到本地,index_filejson_data_file 是我们在做向量查询时使用的文件。其中 index 作为索引,json 作为元数据使用。faiss_dir 则是我们调用 generate-index 接口后生成的索引文件路径。vector_dim 向量维度是跟向量模型本身挂钩的,例如 m3e-base 这个模型的维度就是 768,不可以设置别的值。下面几个参数上面也说的很清楚了。最后一个参数是重点,这个是负责匹配我们知识库元文件字段的,目前大部分源文件格式都是 json,但是字段不可能都一样,所以这里需要配置各自的字段,例如我的元文件字段有四个,需要按照 content 字段做搜索,那么这个字段就是向量字段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"model_path": "./model/m3e-base",
"index_file": "./data/generated_index.index",
"json_data_file": "./data/jsonData.json",
"faiss_dir": "./data/faiss_temp.index",
"vector_dim": 768,
"default_top_k": 5,
"similarity_threshold": 0.7,
"server_port": 5001,
"debug": true,
"result_format": {
"include_score": false,
"max_content_length": 1000
},
"data_fields": {
"metadata_fields": ["doc_name", "chapter", "item_number"],
"content_field": "content"
}
}

最后则是 requirements 文件,创建后,直接 pip install -r requirements.txt 安装依赖即可。

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
blinker==1.9.0
certifi==2025.1.31
charset-normalizer==3.4.1
click==8.1.8
faiss-cpu==1.7.4
filelock==3.17.0
Flask==3.0.2
fsspec==2025.3.0
huggingface-hub==0.29.2
idna==3.10
itsdangerous==2.2.0
Jinja2==3.1.6
joblib==1.4.2
MarkupSafe==3.0.2
mpmath==1.3.0
networkx==3.4.2
nltk==3.9.1
numpy==1.26.4
packaging==24.2
pillow==11.1.0
PyYAML==6.0.2
regex==2024.11.6
requests==2.32.3
safetensors==0.5.3
scikit-learn==1.6.1
scipy==1.15.2
sentence-transformers==3.4.1
sentencepiece==0.2.0
sympy==1.13.1
threadpoolctl==3.5.0
tokenizers==0.21.0
torch==2.6.0
torchvision==0.21.0
tqdm==4.67.1
transformers==4.49.0
typing_extensions==4.12.2
urllib3==2.3.0
Werkzeug==3.1.3

现在我们可以开始启动了,刚刚我们已经安装了 m3e 向量模型,并下载了依赖。先简单介绍下逻辑和使用方法:

项目在启动时会加载当前目录下的 config.json 配置文件,随后读取 data 目录下的索引和元数据,启动成功后对外暴露 2 个接口,分别是 /api/search 向量查询接口 和 /api/generate-index 生成 index 索引接口。前者为 get 请求,参数为 query,返回跟 query 相近的数据。后者参数为文件,入参名为 file,传入元数据后,生成对应的 index 索引。

使用方法: 配置 config.json,项目启动后,调用 /api/generate-index 接口,把生成的 index 索引和 json 元文件放到 data 目录下,修改 config.json 中的 index_filejson_data_file,将这两个变量指向 data 目录下的索引和元文件(在文章的最后提供了测试用的元数据)

到这里,python 的部分就完成了,下面只需要在 Java 中调用 Python 接口就可以启用知识库了

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
// 获取搜索结果(如果已经添加了联网搜索,不要加入这一行代码)
StringBuilder context = new StringBuilder();
// 是否启用知识库
if (request.isUseRAG()) {
HttpClient client = HttpClient.newHttpClient();
String encodedMsg = URLEncoder.encode(request.getMessage(), StandardCharsets.UTF_8);
HttpRequest vectorRequest = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:5001/api/search?query=" + encodedMsg + "&top_k=" + (request.isMaxToggle() ? 10 : 5)))
.build();

HttpResponse<String> response = client.send(vectorRequest, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IOException("Failed to get vector: HTTP " + response.statusCode());
}
String body = response.body();

System.out.println("知识库参考: " + body);
if (!body.isEmpty()) {
context.append("\n\n知识库参考:\n");
context.append(body);
}
}

// 如果有上下文,添加系统消息(如果已经添加了联网搜索,不要加入下面这一段代码)
if (context.length() > 0) {
Map<String, String> systemMessage = new HashMap<>();
systemMessage.put("role", "system");
systemMessage.put("content", "请基于以下参考信息回答用户问题:\n" + context.toString());
conversationHistory.add(systemMessage);
}

接口测试,入参为 蒙娜丽莎是谁? 成功匹配知识库内容

image-20250311160405842

通过 elasticsearch 向量搜索实现

首先我们需要安装好 Elasticsearch 8.17.2 + Kibana 8.17.2

Java 中引入 es 依赖:

1
2
3
4
5
6
7
8
9
10
11
	<!--知识库依赖(注意 es 的版本与自己的对应)-->
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-client</artifactId>
<version>8.17.2</version>
</dependency>
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
<version>7.17.0</version>
</dependency>

继续新增 es 搜索类

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
@Configuration
public class ElasticsearchKnnSearch {

private final RestHighLevelClient esClient;
private final ObjectMapper objectMapper = new ObjectMapper();

// es 索引名
@Value("${esKnn.index-name:sora_vector_index}")
private String indexName;
// es 匹配的字段名
@Value("${esKnn.es-field:content}")
private String content;
// es 匹配的向量字段名
@Value("${esKnn.es-vector-field:content_vector}")
private String contentVector;
// 匹配方式,使用 match 匹配
@Value("${esKnn.match:match}")
private String match;
// 单词匹配比例 一句话中 45% 以上的单词匹配
@Value("${esKnn.work-check:45}")
private String workCheck;
// 匹配逻辑,使用 and
@Value("${esKnn.rule:and}")
private String rule;


public ElasticsearchKnnSearch() {
// 初始化带认证的ES客户端
CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
credentialsProvider.setCredentials(
AuthScope.ANY,
new UsernamePasswordCredentials("xx", "xxxxx")
);

RestClientBuilder builder = RestClient.builder(
new HttpHost("localhost", 9200, "http"))
.setHttpClientConfigCallback(httpClientBuilder -> httpClientBuilder
.setDefaultCredentialsProvider(credentialsProvider));

this.esClient = new RestHighLevelClient(builder);
}

// 从接口获取向量数组
private List<Float> getVectorFromAPI(String message) throws IOException, InterruptedException {
HttpClient client = HttpClient.newHttpClient();
String encodedMsg = URLEncoder.encode(message, StandardCharsets.UTF_8);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:5001/msg_to_vector?msg=" + encodedMsg))
.build();

HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IOException("Failed to get vector: HTTP " + response.statusCode());
}

JsonNode root = objectMapper.readTree(response.body());
JsonNode vectorNode = root.get("vector");
List<Float> vector = new ArrayList<>(vectorNode.size());
for (JsonNode value : vectorNode) {
vector.add(value.floatValue());
}
return vector;
}

// 执行kNN搜索
public SearchResponse executeKnnSearch(int k, String msg) throws Exception {
// 1. 获取查询向量
List<Float> queryVector = getVectorFromAPI(msg);

// 2. 构建搜索请求
SearchRequest searchRequest = new SearchRequest(indexName);
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();

// 3. 构建kNN查询
// 使用 XContentBuilder 安全构建
XContentBuilder xContentBuilder = XContentFactory.jsonBuilder();
xContentBuilder.startObject()
.startObject("knn")
.field("field", contentVector)
.array("query_vector", queryVector.toArray())
.field("k", k)
.field("num_candidates", 50)
.startObject("filter")
.startObject(match)
.startObject(content)
.field("query", msg)
.field("operator", rule)
.field("minimum_should_match", workCheck + "%")
.endObject()
.endObject()
.endObject()
.endObject()
.endObject();

// 打印生成的JSON
String queryJson = Strings.toString(xContentBuilder);
System.out.println("Generated Query:\n" + queryJson);

sourceBuilder.query(QueryBuilders.wrapperQuery(queryJson));

searchRequest.source(sourceBuilder);

// 4. 执行搜索
return esClient.search(searchRequest, RequestOptions.DEFAULT);
}

public void close() throws IOException {
esClient.close();
}

// List<EsVectorResponse>
public List<String> vectorSearch(int k, String msg) throws Exception {
ArrayList<String> vectorList = new ArrayList<>();
try {
SearchResponse response = executeKnnSearch(k,msg);
// 处理搜索结果
System.out.println("Search hits: " + response.getHits().getTotalHits().value);
response.getHits().forEach(hit ->
System.out.println("Hit: " + hit.getSourceAsString()));


// 遍历搜索结果
for (SearchHit hit : response.getHits().getHits()) {
Map<String, Object> sourceMap = hit.getSourceAsMap();
if (sourceMap.containsKey(content)) {
// 这里是汇总所有的信息,请灵活修改,对应 es 的字段
Object contentText = sourceMap.get(content);
String doc_name = sourceMap.get("doc_name") == null ? "" : sourceMap.get("doc_name") + "\n";
String chapter = sourceMap.get("chapter") == null ? "" : sourceMap.get("chapter") + "\n";
String item_number = sourceMap.get("item_number") == null ? "" : sourceMap.get("item_number") + "\n";
if (contentText != null) {
String result = doc_name + chapter + item_number + contentText + "\n";
vectorList.add(result);
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
return vectorList;
}
}

搜索类加入知识库逻辑(注意位置):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 是否启用知识库
if (request.isUseRAG()) {
List<String> vectorSearch = elasticsearchKnnSearch.vectorSearch(request.isMaxToggle() ? 10 : 5, request.getMessage());
System.out.println("知识库参考个数: " + vectorSearch.size());
if (!vectorSearch.isEmpty()) {
context.append("\n\n知识库参考:\n");
}
vectorSearch.forEach(data -> {
context.append(data + "\n");
});
}

// 如果有上下文,添加系统消息
if (context.length() > 0) {
Map<String, String> systemMessage = new HashMap<>();
systemMessage.put("role", "system");
systemMessage.put("content", "请基于以下参考信息回答用户问题:\n" + context.toString());
conversationHistory.add(systemMessage);
}

python:

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
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
import json

from flask import Flask, request, jsonify
import uuid
import numpy as np
import faiss
from sentence_transformers import SentenceTransformer
from elasticsearch.helpers import bulk
from elasticsearch import Elasticsearch
import os
import tempfile
app = Flask(__name__)

# 全局初始化组件 灵活配置
es = Elasticsearch(
hosts=["http://localhost:9200"],
basic_auth=("xx", "xxx")
)
model_path = "models/all-MiniLM-L6-v2"
# 使用模型
model = SentenceTransformer(model_path)

# 与模型输出维度一致
dimension = 384
# 索引名
index_name = "sora_vector_index"

# 初始化FAISS索引
faiss_index = faiss.IndexFlatL2(dimension)

# 确保索引存在
if not es.indices.exists(index=index_name):
es.indices.create(index=index_name, body={
"settings": {
"analysis": {
"analyzer": {
"ik_analyzer": {"type": "custom", "tokenizer": "ik_max_word"}
}
}
},
"mappings": {
"properties": {
"doc_name": {"type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart"},
"chapter": {"type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart"},
"item_number": {"type": "keyword"},
"content": {"type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart"},
"content_vector": {"type": "dense_vector", "dims": dimension}
}
}
})
print(f"{index_name}索引不存在,已创建")

# 将解析后 txt 文件上传到 es 里
def txt_uploaded_file(file_path):
"""处理上传文件的核心逻辑(按四行结构解析)"""
with open(file_path, 'r', encoding='utf-8') as f:
text = f.read()

# 按行处理,过滤空行并去除首尾空格
lines = [line.strip() for line in text.split('\n') if line.strip()]

documents = []
# 按每四行分割为一条记录
for i in range(0, len(lines), 4):
# 确保有足够四行数据
if i + 3 >= len(lines):
break # 跳过不完整的记录

doc_name = lines[i]
chapter = lines[i+1]
item_number = lines[i+2]
content = lines[i+3]

documents.append({
"doc_name": doc_name,
"chapter": chapter,
"item_number": item_number,
"content": content
})

# 生成向量并更新FAISS
contents = [doc["content"] for doc in documents]
embeddings = model.encode(contents)
faiss_index.add(embeddings.astype(np.float32))

# 添加向量到文档数据
for doc, vector in zip(documents, embeddings):
doc["content_vector"] = vector.tolist()

# 批量导入ES
actions = [{
"_index": index_name,
"_source": {
**doc,
"doc_id": str(uuid.uuid4()) # 添加唯一ID
}
} for doc in documents]

success, _ = bulk(es, actions)
return success, len(documents)

@app.route('/upload_save_to_es', methods=['POST'])
def upload_save_to_es():
"""文件上传处理端点"""
if 'file' not in request.files:
return jsonify({"error": "No file uploaded"}), 400

file = request.files['file']
if file.filename == '':
return jsonify({"error": "Empty filename"}), 400

# 保存临时文件
_, temp_path = tempfile.mkstemp()
file.save(temp_path)

try:
success_count, total_count = txt_uploaded_file(temp_path)
return jsonify({
"status": "success",
"ingested": success_count,
"total": total_count
})
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
os.remove(temp_path)



@app.route('/save_to_es', methods=['POST'])
def upload_json():
"""处理JSON文件上传(自动补充向量字段)"""
if 'json' not in request.files:
return jsonify({"error": "No JSON file uploaded"}), 400

file = request.files['json']

try:
# 解析JSON文件
documents = json.load(file)
except Exception as e:
return jsonify({"error": f"无效的JSON格式: {str(e)}"}), 400

try:
# 校验基础字段
required_fields = {'doc_name', 'chapter', 'item_number', 'content'}
need_vectors = [] # 需要生成向量的文档索引

for idx, doc in enumerate(documents):
# 检查必需字段
missing = required_fields - doc.keys()
if missing:
return jsonify({"error": f"文档 {idx} 缺少字段: {', '.join(missing)}"}), 400

# 标记需要生成向量的文档
if 'content_vector' not in doc or not isinstance(doc['content_vector'], list):
need_vectors.append(idx)

# 批量生成缺失的向量
if need_vectors:
contents = [documents[i]['content'] for i in need_vectors]
embeddings = model.encode(contents)

# 更新FAISS索引
faiss_index.add(embeddings.astype(np.float32))

# 回填向量到文档
for vec_idx, doc_idx in enumerate(need_vectors):
documents[doc_idx]['content_vector'] = embeddings[vec_idx].tolist()

# 准备ES数据
actions = [{
"_index": index_name,
"_source": {
**doc,
"doc_id": str(uuid.uuid4()) # 始终生成新ID
}
} for doc in documents]

# 批量写入ES
success, _ = bulk(es, actions)
return jsonify({
"status": "success",
"ingested": success,
"total": len(documents),
"vectors_generated": len(need_vectors)
})

except Exception as e:
return jsonify({"error": f"处理失败: {str(e)}"}), 500

# 外部调用使用
@app.route('/msg_to_vector', methods=['GET', 'POST'])
def encode_text():
"""将消息文本转换为向量"""
msg = request.args.get('msg') if request.method == 'GET' else request.json.get('msg')

if not msg:
return jsonify({"error": "Missing 'msg' parameter"}), 400

try:
vector = model.encode(msg).tolist()
return jsonify({"vector": vector, "dimension": len(vector)}), 200
except Exception as e:
return jsonify({"error": str(e)}), 500

if __name__ == '__main__':
app.run(host='0.0.0.0', port=5001, debug=True)

python 依赖:

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
aiohappyeyeballs==2.5.0
aiohttp==3.11.13
aiosignal==1.3.2
annotated-types==0.7.0
anyio==4.8.0
asgiref==3.8.1
attrs==25.1.0
backoff==2.2.1
bcrypt==4.3.0
blinker==1.9.0
build==1.2.2.post1
cachetools==5.5.2
certifi==2025.1.31
charset-normalizer==3.4.1
chroma-hnswlib==0.7.6
chromadb==0.6.3
click==8.1.8
coloredlogs==15.0.1
dataclasses-json==0.6.7
Deprecated==1.2.18
distro==1.9.0
document==1.0
durationpy==0.9
elastic-transport==8.17.0
elasticsearch==8.17.1
faiss-cpu==1.9.0
fastapi==0.115.11
filelock==3.17.0
Flask==3.1.0
flatbuffers==25.2.10
frozenlist==1.5.0
fsspec==2025.2.0
google-auth==2.38.0
googleapis-common-protos==1.69.1
grpcio==1.70.0
h11==0.14.0
httpcore==1.0.7
httptools==0.6.4
httpx==0.28.1
huggingface-hub==0.29.1
humanfriendly==10.0
idna==3.10
importlib_metadata==8.5.0
importlib_resources==6.5.2
itsdangerous==2.2.0
Jinja2==3.1.5
joblib==1.4.2
jsonpatch==1.33
jsonpointer==3.0.0
kubernetes==32.0.1
langchain-community==0.0.28
langchain-core==0.3.41
langsmith==0.1.147
markdown-it-py==3.0.0
MarkupSafe==3.0.2
marshmallow==3.26.1
mdurl==0.1.2
mmh3==5.1.0
monotonic==1.6
mpmath==1.3.0
multidict==6.1.0
mypy-extensions==1.0.0
networkx==3.4.2
numpy==1.26.4
oauthlib==3.2.2
onnxruntime==1.20.1
opentelemetry-api==1.30.0
opentelemetry-exporter-otlp-proto-common==1.30.0
opentelemetry-exporter-otlp-proto-grpc==1.30.0
opentelemetry-instrumentation==0.51b0
opentelemetry-instrumentation-asgi==0.51b0
opentelemetry-instrumentation-fastapi==0.51b0
opentelemetry-proto==1.30.0
opentelemetry-sdk==1.30.0
opentelemetry-semantic-conventions==0.51b0
opentelemetry-util-http==0.51b0
orjson==3.10.15
overrides==7.7.0
packaging==23.2
pillow==11.1.0
posthog==3.19.0
propcache==0.3.0
protobuf==5.29.3
pyasn1==0.6.1
pyasn1_modules==0.4.1
pydantic==2.10.6
pydantic_core==2.27.2
Pygments==2.19.1
PyPika==0.48.9
pyproject_hooks==1.2.0
python-dateutil==2.9.0.post0
python-dotenv==1.0.0
PyYAML==6.0.2
regex==2024.11.6
requests==2.32.3
requests-oauthlib==2.0.0
requests-toolbelt==1.0.0
rich==13.9.4
rsa==4.9
safetensors==0.5.3
scikit-learn==1.6.1
scipy==1.15.2
sentence-transformers==3.4.1
shellingham==1.5.4
six==1.17.0
sniffio==1.3.1
SQLAlchemy==2.0.38
starlette==0.46.0
sympy==1.13.1
tenacity==8.5.0
threadpoolctl==3.5.0
tokenizers==0.21.0
torch==2.6.0
tqdm==4.67.1
transformers==4.49.0
typer==0.15.2
typing-inspect==0.9.0
typing_extensions==4.12.2
urllib3==2.3.0
uvicorn==0.34.0
uvloop==0.21.0
watchfiles==1.0.4
websocket-client==1.8.0
websockets==15.0.1
Werkzeug==3.1.3
wrapt==1.17.2
yarl==1.18.3
zipp==3.21.0

使用方法:

  1. 修改 es 搜索类和 Python 脚本中的 es 地址
  2. 调用 Python 的 save_to_es 接口,参数名为 file,类型是 json 文件。将测试文件加入到 es 的索引中(测试数据放下面)
  3. 调用 Python 的 msg_to_vector 接口,参数为 msg,查看是否正常
  4. 正常使用,知识库接入完成

测试元数据

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
178
179
180
181
182
[
{
"doc_name": "量子物理导论",
"chapter": "第一章 波粒二象性",
"item_number": "1.1a",
"content": "薛定谔方程描述了微观粒子的波函数演化,其数学形式为iℏ∂ψ/∂t = Ĥψ。该方程在量子力学中的地位相当于经典力学中的牛顿第二定律。"
},
{
"doc_name": "文艺复兴艺术史",
"chapter": "第三章 达芬奇研究",
"item_number": "MonaLisa",
"content": "蒙娜丽莎的微笑因其微妙的表情变化闻名,X光扫描显示画作下方存在多个草稿层,证明达芬奇曾多次修改人物面部结构。"
},
{
"doc_name": "加密货币白皮书",
"chapter": "附录B 共识算法",
"item_number": "PoS-2023",
"content": "权益证明(PoS)通过验证者抵押代币来维护网络安全,相比工作量证明(PoW)可降低99.95%的能源消耗,但可能引发富者愈富的中心化问题。"
},
{
"doc_name": "南极科考日志",
"chapter": "极端环境生存",
"item_number": "EM-0042",
"content": "在-89.2℃的低温条件下,普通润滑油会完全凝固,必须使用特制的氟化液体系润滑剂。科考站门锁需要每日加热除冰三次以上。"
},
{
"doc_name": "分子美食手册",
"chapter": "液氮应用",
"item_number": "LN2-7",
"content": "使用液氮(-196℃)瞬间冷冻芒果泥可形成直径小于50μm的冰晶,配合超声波震荡可获得类似鱼子酱的爆浆口感。"
},
{
"doc_name": "甲骨文破译笔记",
"chapter": "商代祭祀",
"item_number": "甲-2317",
"content": "‘’字经红外扫描确认描绘了三人持戈环绕祭坛的场景,可能与《周礼》记载的‘大傩’驱疫仪式存在渊源关系。"
},
{
"doc_name": "火星地质报告",
"chapter": "奥林匹斯山",
"item_number": "MARS-OL-01",
"content": "太阳系最高火山奥林匹斯山基底直径达600公里,高度21.9公里,其缓坡结构表明火星曾存在低粘度玄武质熔岩流。"
},
{
"doc_name": "歌剧演唱技巧",
"chapter": "呼吸控制",
"item_number": "BELCANTO-3",
"content": "横膈膜下沉式呼吸可使肺活量提升40%,配合喉头稳定技术,能持续发出110分贝的强共鸣音而不损伤声带。"
},
{
"doc_name": "古生物图谱",
"chapter": "寒武纪大爆发",
"item_number": "CB-009",
"content": "奇虾(Anomalocaris)化石显示其复眼由16000个晶状体组成,视敏度是现代蜻蜓的3倍,是已知最早的高阶捕食者。"
},
{
"doc_name": "人工智能伦理",
"chapter": "自主武器系统",
"item_number": "AWS-ETHICS",
"content": "致命性自主武器(LAWS)的敌我识别错误率超过0.7%即可能违反国际人道法,需建立全球性的算力追踪监管体系。"
},
{
"doc_name": "中世纪炼金术",
"chapter": "贤者之石",
"item_number": "PHIL-λ",
"content": "牛顿手稿显示其相信通过汞-硫二元体系在七阶蒸馏过程中可制备出‘红色方解石’,即传说中的物质转化媒介。"
},
{
"doc_name": "深海生物图鉴",
"chapter": "超深渊带",
"item_number": "Hadal-888",
"content": "马里亚纳狮子鱼在11000米深度进化出凝胶状身体,骨骼孔隙率高达90%,可承受1.1吨/平方厘米的水压。"
},
{
"doc_name": "纳米材料学报",
"chapter": "石墨烯应用",
"item_number": "GR-2D-45",
"content": "缺陷工程处理的氧化石墨烯薄膜可实现97%的光子透过率与85%的导电率,适合用作柔性触摸屏的透明电极。"
},
{
"doc_name": "敦煌壁画研究",
"chapter": "飞天形象演变",
"item_number": "DH-飞-09",
"content": "北魏时期的飞天多呈现V型强烈动态,至唐代逐渐发展为C型优雅曲线,反映佛教艺术本土化过程中的审美变迁。"
},
{
"doc_name": "疫苗研发日志",
"chapter": "mRNA技术",
"item_number": "VAC-mRNA-2020",
"content": "核苷酸修饰使mRNA的半衰期从2小时延长至24小时以上,LNP包裹效率达到98.3%,有效提升抗原表达量。"
},
{
"doc_name": "暗物质探测报告",
"chapter": "液氙实验",
"item_number": "XENON1T-2022",
"content": "1.3吨超纯液氙探测器观测到电子反冲异常信号,可能与轴子粒子相关,置信度3.5σ,需进一步排除氚污染可能。"
},
{
"doc_name": "茶叶品鉴指南",
"chapter": "普洱茶发酵",
"item_number": "TEA-7749",
"content": "渥堆过程中嗜热菌属占比超过60%,分泌的果胶酶使茶多酚转化率高达80%,形成独特的陈香和红褐汤色。"
},
{
"doc_name": "空间站设计手册",
"chapter": "辐射防护",
"item_number": "ISS-Φ12",
"content": "10厘米厚聚乙烯防护层可将银河宇宙射线剂量降低75%,结合水墙和选择性磁屏蔽可满足长期驻留安全标准。"
},
{
"doc_name": "恐龙灭绝假说",
"chapter": "希克苏鲁伯撞击",
"item_number": "K-Pg-1980",
"content": "铱异常层厚度分析表明,小行星撞击瞬间释放4.2×10²³焦耳能量,引发持续数十年的‘撞击冬天’,地表温度下降20℃。"
},
{
"doc_name": "脑机接口进展",
"chapter": "神经解码",
"item_number": "BCI-007",
"content": "使用128通道微电极阵列可实时解码初级运动皮层中手指运动的θ波段(4-8Hz)神经振荡信号,准确率达92%。"
},
{
"doc_name": "香料贸易史",
"chapter": "黑胡椒战争",
"item_number": "SP-1498",
"content": "15世纪威尼斯商人通过垄断印度胡椒贸易获取400%利润,直接推动葡萄牙探索绕道非洲的新航路。"
},
{
"doc_name": "超导材料研究",
"chapter": "高压氢化物",
"item_number": "SC-275GPa",
"content": "碳质硫氢化物在275GPa压力下实现15℃超导,但亚稳态维持时间不足1微秒,距实用化仍有量级差距。"
},
{
"doc_name": "甲骨病诊疗",
"chapter": "马蹄形病变",
"item_number": "HOOF-EM",
"content": "马属动物第三趾骨缺血性坏死可通过热成像早期诊断,配合高压氧舱治疗可使痊愈率从35%提升至78%。"
},
{
"doc_name": "虚拟现实心理学",
"chapter": "恐怖谷效应",
"item_number": "VR-UN-03",
"content": "当虚拟人像面部保真度达到92%时,用户焦虑指数骤增300%,但超过97%后接受度又会回升至正常水平。"
},
{
"doc_name": "古罗马建筑",
"chapter": "混凝土技术",
"item_number": "ROM-CON",
"content": "维苏威火山灰与石灰反应生成的钙长石晶体,使罗马混凝土经过2000年海水侵蚀后强度反而提升50%。"
},
{
"doc_name": "蜂群崩溃研究",
"chapter": "新烟碱类农药",
"item_number": "CCD-2021",
"content": "吡虫啉暴露使蜜蜂舞蹈通讯错误率增加40%,蜂群觅食效率下降75%,是导致群体崩溃失调的重要因素。"
},
{
"doc_name": "超音速客机设计",
"chapter": "音爆控制",
"item_number": "SST-2025",
"content": "采用30米级细长机身设计可将地面感知噪音从105PLdB降至75PLdB,满足FAA的日间运营标准。"
},
{
"doc_name": "玛雅天文研究",
"chapter": "金星历法",
"item_number": "MAYA-VEN",
"content": "德累斯顿抄本显示玛雅人计算出金星会合周期为583.92天,与现代测量值583.92天完全一致。"
},
{
"doc_name": "仿生机器人",
"chapter": "猎豹运动控制",
"item_number": "BIO-CHEETAH",
"content": "基于中枢模式发生器的控制算法,配合碳纤维肌腱可实现3Hz的腿部摆动频率,最高时速达38km/h。"
},
{
"doc_name": "葡萄酒酿造",
"chapter": "橡木桶陈化",
"item_number": "WINE-OAK",
"content": "中度烘烤的法国橡木每升酒液贡献2.1mg香草醛,同时促进单宁聚合,使酒体更加柔顺饱满。"
}
]