在java中使用deepseek并接入联网搜索和知识库_java deepseek
前言
当前AI技术生态以 Python 为主导,这几天在研究用 Java 搭建知识库使用,最终都避不开 Python,于是打算记录下结果,目前是有 2 个方案,第一个方案是 在 Python 中使用 embedding嵌入模型,完成数据向量化与向量搜索,推荐使用这个方案,简单也方便。第二个方案是不使用 embedding嵌入模型,使用 es 来完成向量存储,但仍需要 Python 来完成数据的向量化。
本文分为三部分,第一部分是接入 deepseek-r1,第二部分是接入联网搜索,第三部分是使用自建知识库(两个实现方案),知识库为可选功能,并且实现起来也挺麻烦,不需要的可以直接看前两部分即可。
同时,本次的代码也已经放在了 GitHub 上,deepseek-java
前置准备
首先介绍一下本次的开发环境:
Java17 + SpringBoot 3.3.2
Python 3.11
deepseek 的 APIkeys(在官网上买就可以了)
tavily(搜索引擎,通过这个实现联网搜索,在第二部分会具体说)
项目依赖
我们需要一个 SpringBoot 的项目,具体依赖如下
<dependencies> <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 即可。
编写基本代码
封裝聊天请求类,包含四个基本参数。
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。
@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 即可。
添加一个搜索类,负责实现我们的搜索逻辑,同样只需要替换 apiKey 的变量即可。
@Componentpublic 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 中,此时核心类如下:
@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(); }}
此时可以看到,在创建用户消息前,请求参数中就已经存在联网搜索的结果。
第三部分-接入 自建知识库
知识库部分的实现有 2 种方式,推荐采用 embedding方案(效果更优且维护成本低),ES方案适用于已有ES技术栈的场景
通过 embedding嵌入模型 实现
首先我们要先搞明白,什么是embedding嵌入模型?embedding 是机器学习的核心技术之一,通过将离散(文字、图片等)的数据转为连续的向量空间,并捕获数据特征。例如我们搜索的时候, 输入可爱的猫图,那么 embedding 就会把这五个字转为数字数组得到向量,再根据这个向量和所有的数据向量距离进行对比,数值越近说明越相关。最后按照相似度把数据返回给我们。
数据向量化我们需要借助 python 实现,python 项目结构如下,忽略 Dockerfile。app.py 是代码的主体,config.json 是配置文件,我们只需要改动这个地方即可。data 下存放的是我们知识库元文件及索引文件(刚开始没有 index 文件属于正常的,因为 index 是基于 json 格式的知识库生成的)。model 则是我下载的embedding 模型,最后 requirements 则是依赖表。
下面介绍具体的使用方式:
创建项目,下载 m3e-base 向量模型
git clone https://huggingface.co/moka-ai/m3e-base ./model/m3e-base
执行完成后注意,需额外手动下载两个配置文件。进入 huggingface 的地址:m3e-base,手动将这两个文件下载下来,放到 model 目录内。
创建 app.py 文件,不需要改任何地方
import osimport jsonimport tempfileimport faissfrom flask import Flask, request, jsonify, send_filefrom sentence_transformers import SentenceTransformerfrom pathlib import Pathfrom typing import List, Dictapp = 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)}), 500if __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_file 和 json_data_file 是我们在做向量查询时使用的文件。其中 index 作为索引,json 作为元数据使用。faiss_dir 则是我们调用 generate-index 接口后生成的索引文件路径。vector_dim 向量维度是跟向量模型本身挂钩的,例如 m3e-base 这个模型的维度就是 768,不可以设置别的值。下面几个参数上面也说的很清楚了。最后一个参数是重点,这个是负责匹配我们知识库元文件字段的,目前大部分源文件格式都是 json,但是字段不可能都一样,所以这里需要配置各自的字段,例如我的元文件字段有四个,需要按照 content 字段做搜索,那么这个字段就是向量字段。
{ \"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
安装依赖即可。
blinker==1.9.0certifi==2025.1.31charset-normalizer==3.4.1click==8.1.8faiss-cpu==1.7.4filelock==3.17.0Flask==3.0.2fsspec==2025.3.0huggingface-hub==0.29.2idna==3.10itsdangerous==2.2.0Jinja2==3.1.6joblib==1.4.2MarkupSafe==3.0.2mpmath==1.3.0networkx==3.4.2nltk==3.9.1numpy==1.26.4packaging==24.2pillow==11.1.0PyYAML==6.0.2regex==2024.11.6requests==2.32.3safetensors==0.5.3scikit-learn==1.6.1scipy==1.15.2sentence-transformers==3.4.1sentencepiece==0.2.0sympy==1.13.1threadpoolctl==3.5.0tokenizers==0.21.0torch==2.6.0torchvision==0.21.0tqdm==4.67.1transformers==4.49.0typing_extensions==4.12.2urllib3==2.3.0Werkzeug==3.1.3
现在我们可以开始启动了,刚刚我们已经安装了 m3e 向量模型,并下载了依赖。先简单介绍下逻辑和使用方法:
项目在启动时会加载当前目录下的 config.json 配置文件,随后读取 data 目录下的索引和元数据,启动成功后对外暴露 2 个接口,分别是 /api/search 向量查询接口 和 /api/generate-index 生成 index 索引接口。前者为 get 请求,参数为 query,返回跟 query 相近的数据。后者参数为文件,入参名为 file,传入元数据后,生成对应的 index 索引。
使用方法: 配置config.json,项目启动后,调用 /api/generate-index 接口,参数是你的知识库 json 元文件,然后把生成的 index 索引以及你的 json 原文件放到 data 目录下,修改config.json 中的 index_file 和 json_data_file,将这两个变量指向 data 目录下的索引和元文件**(在文章的最后提供了测试用的元数据)**
到这里,python 的部分就完成了,下面只需要在 Java 核心类中调用 Python 的 /api/search 向量化查询用户的问题,就可以启用知识库了
// 获取搜索结果(如果已经添加了联网搜索,不要加入这一行代码)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);}
知识库查询测试,入参为 蒙娜丽莎是谁? 成功匹配知识库内容
通过 elasticsearch 向量搜索实现
首先我们需要安装好 Elasticsearch 8.17.2 + Kibana 8.17.2
Java 中引入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 搜索类
@Configurationpublic 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 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; }}
搜索类加入知识库逻辑(注意位置):
// 是否启用知识库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代码如下,完成对 json 原数据存入 es 以及查询向量化
import jsonfrom flask import Flask, request, jsonifyimport uuidimport numpy as npimport faissfrom sentence_transformers import SentenceTransformerfrom elasticsearch.helpers import bulkfrom elasticsearch import Elasticsearchimport osimport tempfileapp = 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)}), 500if __name__ == \'__main__\': app.run(host=\'0.0.0.0\', port=5001, debug=True)
python 依赖:
aiohappyeyeballs==2.5.0aiohttp==3.11.13aiosignal==1.3.2annotated-types==0.7.0anyio==4.8.0asgiref==3.8.1attrs==25.1.0backoff==2.2.1bcrypt==4.3.0blinker==1.9.0build==1.2.2.post1cachetools==5.5.2certifi==2025.1.31charset-normalizer==3.4.1chroma-hnswlib==0.7.6chromadb==0.6.3click==8.1.8coloredlogs==15.0.1dataclasses-json==0.6.7Deprecated==1.2.18distro==1.9.0document==1.0durationpy==0.9elastic-transport==8.17.0elasticsearch==8.17.1faiss-cpu==1.9.0fastapi==0.115.11filelock==3.17.0Flask==3.1.0flatbuffers==25.2.10frozenlist==1.5.0fsspec==2025.2.0google-auth==2.38.0googleapis-common-protos==1.69.1grpcio==1.70.0h11==0.14.0httpcore==1.0.7httptools==0.6.4httpx==0.28.1huggingface-hub==0.29.1humanfriendly==10.0idna==3.10importlib_metadata==8.5.0importlib_resources==6.5.2itsdangerous==2.2.0Jinja2==3.1.5joblib==1.4.2jsonpatch==1.33jsonpointer==3.0.0kubernetes==32.0.1langchain-community==0.0.28langchain-core==0.3.41langsmith==0.1.147markdown-it-py==3.0.0MarkupSafe==3.0.2marshmallow==3.26.1mdurl==0.1.2mmh3==5.1.0monotonic==1.6mpmath==1.3.0multidict==6.1.0mypy-extensions==1.0.0networkx==3.4.2numpy==1.26.4oauthlib==3.2.2onnxruntime==1.20.1opentelemetry-api==1.30.0opentelemetry-exporter-otlp-proto-common==1.30.0opentelemetry-exporter-otlp-proto-grpc==1.30.0opentelemetry-instrumentation==0.51b0opentelemetry-instrumentation-asgi==0.51b0opentelemetry-instrumentation-fastapi==0.51b0opentelemetry-proto==1.30.0opentelemetry-sdk==1.30.0opentelemetry-semantic-conventions==0.51b0opentelemetry-util-http==0.51b0orjson==3.10.15overrides==7.7.0packaging==23.2pillow==11.1.0posthog==3.19.0propcache==0.3.0protobuf==5.29.3pyasn1==0.6.1pyasn1_modules==0.4.1pydantic==2.10.6pydantic_core==2.27.2Pygments==2.19.1PyPika==0.48.9pyproject_hooks==1.2.0python-dateutil==2.9.0.post0python-dotenv==1.0.0PyYAML==6.0.2regex==2024.11.6requests==2.32.3requests-oauthlib==2.0.0requests-toolbelt==1.0.0rich==13.9.4rsa==4.9safetensors==0.5.3scikit-learn==1.6.1scipy==1.15.2sentence-transformers==3.4.1shellingham==1.5.4six==1.17.0sniffio==1.3.1SQLAlchemy==2.0.38starlette==0.46.0sympy==1.13.1tenacity==8.5.0threadpoolctl==3.5.0tokenizers==0.21.0torch==2.6.0tqdm==4.67.1transformers==4.49.0typer==0.15.2typing-inspect==0.9.0typing_extensions==4.12.2urllib3==2.3.0uvicorn==0.34.0uvloop==0.21.0watchfiles==1.0.4websocket-client==1.8.0websockets==15.0.1Werkzeug==3.1.3wrapt==1.17.2yarl==1.18.3zipp==3.21.0
使用方法:
- 修改 es 搜索类和 Python 脚本中的 es 地址
- 调用 Python 的 save_to_es 接口,参数名为 file,类型是 json 文件。将测试文件加入到 es 的索引中(测试数据放下面)
- 调用 Python 的 msg_to_vector 接口,参数为 msg,查看是否正常
- 正常使用,知识库接入完成
测试元数据
[ { \"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香草醛,同时促进单宁聚合,使酒体更加柔顺饱满。\" }]
更多知识请移步个人博客:33sora.com