Kaynağa Gözat

init 入库es

nbabob520 1 ay önce
ebeveyn
işleme
fb7ff50c9b
46 değiştirilmiş dosya ile 8717 ekleme ve 167 silme
  1. 1 0
      pom.xml
  2. 262 0
      schedule-embedding-api/README.md
  3. 35 0
      schedule-embedding-api/complete_test_results.txt
  4. 1225 0
      schedule-embedding-api/docs/API接口文档.md
  5. 73 0
      schedule-embedding-api/docs/init.sql
  6. 2502 0
      schedule-embedding-api/docs/向量化入库方案.md
  7. 171 0
      schedule-embedding-api/pom.xml
  8. 18 0
      schedule-embedding-api/src/main/java/cn/com/yusys/manager/EmbeddingApplication.java
  9. 205 0
      schedule-embedding-api/src/main/java/cn/com/yusys/manager/client/EmbeddingClient.java
  10. 34 0
      schedule-embedding-api/src/main/java/cn/com/yusys/manager/config/ChunkConfig.java
  11. 85 0
      schedule-embedding-api/src/main/java/cn/com/yusys/manager/config/ElasticsearchConfig.java
  12. 39 0
      schedule-embedding-api/src/main/java/cn/com/yusys/manager/config/EmbeddingConfig.java
  13. 60 0
      schedule-embedding-api/src/main/java/cn/com/yusys/manager/config/KafkaConsumerConfig.java
  14. 166 0
      schedule-embedding-api/src/main/java/cn/com/yusys/manager/controller/DocumentController.java
  15. 166 0
      schedule-embedding-api/src/main/java/cn/com/yusys/manager/controller/TextEmbeddingController.java
  16. 133 0
      schedule-embedding-api/src/main/java/cn/com/yusys/manager/controller/VectorSearchController.java
  17. 26 0
      schedule-embedding-api/src/main/java/cn/com/yusys/manager/exception/BusinessException.java
  18. 15 0
      schedule-embedding-api/src/main/java/cn/com/yusys/manager/exception/ElasticsearchException.java
  19. 15 0
      schedule-embedding-api/src/main/java/cn/com/yusys/manager/exception/EmbeddingException.java
  20. 98 0
      schedule-embedding-api/src/main/java/cn/com/yusys/manager/exception/GlobalExceptionHandler.java
  21. 117 0
      schedule-embedding-api/src/main/java/cn/com/yusys/manager/listener/EmbeddingTaskListener.java
  22. 27 0
      schedule-embedding-api/src/main/java/cn/com/yusys/manager/model/dto/BatchIndexRequest.java
  23. 61 0
      schedule-embedding-api/src/main/java/cn/com/yusys/manager/model/dto/EmbeddingTaskMessage.java
  24. 56 0
      schedule-embedding-api/src/main/java/cn/com/yusys/manager/model/dto/IndexRequest.java
  25. 36 0
      schedule-embedding-api/src/main/java/cn/com/yusys/manager/model/dto/SearchRequest.java
  26. 48 0
      schedule-embedding-api/src/main/java/cn/com/yusys/manager/model/dto/SearchResult.java
  27. 48 0
      schedule-embedding-api/src/main/java/cn/com/yusys/manager/model/entity/Chunk.java
  28. 110 0
      schedule-embedding-api/src/main/java/cn/com/yusys/manager/model/entity/KafkaProcessingLog.java
  29. 111 0
      schedule-embedding-api/src/main/java/cn/com/yusys/manager/model/entity/OperationLog.java
  30. 145 0
      schedule-embedding-api/src/main/java/cn/com/yusys/manager/model/entity/TextDocument.java
  31. 50 0
      schedule-embedding-api/src/main/java/cn/com/yusys/manager/model/entity/service/DocumentService.java
  32. 204 0
      schedule-embedding-api/src/main/java/cn/com/yusys/manager/model/entity/service/TextEmbeddingService.java
  33. 54 0
      schedule-embedding-api/src/main/java/cn/com/yusys/manager/model/vo/BatchIndexResult.java
  34. 60 0
      schedule-embedding-api/src/main/java/cn/com/yusys/manager/model/vo/IndexResult.java
  35. 512 0
      schedule-embedding-api/src/main/java/cn/com/yusys/manager/repository/ElasticsearchRepository.java
  36. 51 0
      schedule-embedding-api/src/main/java/cn/com/yusys/manager/repository/KafkaProcessingLogRepository.java
  37. 45 0
      schedule-embedding-api/src/main/java/cn/com/yusys/manager/repository/OperationLogRepository.java
  38. 170 0
      schedule-embedding-api/src/main/java/cn/com/yusys/manager/service/LogService.java
  39. 210 0
      schedule-embedding-api/src/main/java/cn/com/yusys/manager/splitter/TextSplitter.java
  40. 101 0
      schedule-embedding-api/src/main/resources/application.yml
  41. 287 0
      schedule-embedding-api/src/test/java/cn/com/yusys/manager/controller/ApiControllerTest.java
  42. 151 0
      schedule-embedding-api/src/test/java/cn/com/yusys/manager/service/DocumentServiceTest.java
  43. 220 0
      schedule-embedding-api/src/test/java/cn/com/yusys/manager/service/TextEmbeddingServiceTest.java
  44. 50 0
      schedule-embedding-api/src/test/java/cn/com/yusys/manager/splitter/TextSplitterTest.java
  45. 464 0
      schedule-embedding-api/test_complete.sh
  46. 0 167
      schedule-embedding-api/开发计划.md

+ 1 - 0
pom.xml

@@ -13,6 +13,7 @@
         <module>schedule-consumer</module>
         <module>schedule-manager</module>
         <module>schedule-monitor</module>
+        <module>schedule-embedding-api</module>
     </modules>
 
     <!-- 统一属性管理 -->

+ 262 - 0
schedule-embedding-api/README.md

@@ -0,0 +1,262 @@
+# schedule-embedding-api
+
+文本向量化与向量检索服务
+
+## 快速开始
+
+### 1. 环境要求
+
+- Java 17
+- Elasticsearch 8.x
+- MySQL 8.x (可选,用于日志记录)
+
+### 2. 启动前配置
+
+#### 2.1 初始化数据库
+
+```bash
+mysql -h 127.0.0.1 -P 3306 -uroot -p123456 < docs/init.sql
+```
+
+#### 2.2 修改配置
+
+编辑 `src/main/resources/application.yml`,根据需要修改配置:
+
+```yaml
+# Elasticsearch 配置
+elasticsearch:
+  host: http://localhost:9200
+  index:
+    name: contract_chunks
+
+# Embedding API 配置
+embedding:
+  api:
+    url: http://localhost:18081/v1
+    model: Qwen3-Embedding-8B
+
+# MySQL 配置(日志记录用)
+spring:
+  datasource:
+    url: jdbc:mysql://localhost:3306/schedule_embedding
+    username: root
+    password: 123456
+```
+
+### 3. 启动
+
+```bash
+mvn spring-boot:run
+```
+
+服务启动后访问:`http://localhost:8084/actuator/health`
+
+---
+
+## Kafka 开关说明
+
+### 同步模式(默认,推荐开发测试)
+
+```yaml
+kafka:
+  enabled: false
+```
+
+- 适用场景:单机部署、直接同步处理文档入库
+- 优点:启动快,不依赖 Kafka
+
+### 异步模式(生产环境)
+
+```yaml
+kafka:
+  enabled: true
+  topics:
+    embedding: embedding-topic
+
+spring:
+  kafka:
+    bootstrap-servers: localhost:9092
+```
+
+- 适用场景:需要消息队列解耦、异步处理大量文档
+- 优点:支持高并发、削峰填谷
+
+### 切换步骤
+
+1. 修改 `kafka.enabled` 为 `true` 或 `false`
+2. 如果启用 Kafka,需确保:
+   - Kafka 服务正常运行
+   - topic `embedding-topic` 已创建
+3. 重启应用
+
+---
+
+## API 接口
+
+| 接口 | 方法 | 路径 | 功能 |
+|------|------|------|------|
+| 健康检查 | GET | /actuator/health | 服务状态 |
+| 文档入库 | POST | /api/v1/documents/index | 单个文档向量化入库 |
+| 批量入库 | POST | /api/v1/documents/batch-index | 批量文档向量化入库 |
+| 向量搜索 | POST | /api/v1/search | 基于语义的向量搜索 |
+| 混合搜索 | POST | /api/v1/search/hybrid | 向量搜索+业务字段过滤 |
+| 查询文档 | GET | /api/v1/documents/{docId} | 查询文档的所有chunks |
+| 删除文档 | DELETE | /api/v1/documents/{docId} | 删除文档的所有chunks |
+
+详细接口文档见:`docs/API接口文档.md`
+
+---
+
+## 数据库
+
+数据库初始化脚本:`docs/init.sql`
+
+### 表说明
+
+| 表名 | 说明 |
+|------|------|
+| operation_log | 操作日志 - 记录 HTTP API 调用 |
+| kafka_processing_log | Kafka 处理日志 - 记录消息处理过程 |
+
+### 示例查询
+
+```sql
+-- 查看最近的操作日志
+SELECT * FROM schedule_embedding.operation_log ORDER BY create_time DESC LIMIT 10;
+
+-- 按操作类型统计
+SELECT operation_type, COUNT(*) FROM schedule_embedding.operation_log GROUP BY operation_type;
+```
+
+---
+
+## 验证测试
+
+### 1. 验证服务启动
+
+```bash
+# 检查服务健康状态
+curl http://localhost:8084/actuator/health
+```
+
+预期返回:
+```json
+{"status":"UP","components":{...}}
+```
+
+### 2. 验证 Elasticsearch 连接
+
+```bash
+# 检查 ES 集群状态
+curl http://localhost:9200/_cluster/health
+```
+
+或通过服务健康检查:
+```bash
+curl http://localhost:8084/actuator/health | grep elasticsearch
+```
+
+### 3. 简单功能测试
+
+#### 3.1 文档入库
+
+```bash
+curl -X POST http://localhost:8084/api/v1/documents/index \
+  -H "Content-Type: application/json" \
+  -d '{
+    "docId": "test-001",
+    "fileName": "测试文档.pdf",
+    "fullText": "这是一条测试数据,用于验证文档向量化入库功能是否正常。",
+    "fileType": "pdf"
+  }'
+```
+
+预期返回:
+```json
+{"success":true,"docId":"test-001","chunkCount":1,"message":"文档入库成功","error":null}
+```
+
+#### 3.2 向量搜索
+
+```bash
+curl -X POST http://localhost:8084/api/v1/search \
+  -H "Content-Type: application/json" \
+  -d '{
+    "query": "测试数据验证",
+    "topK": 3
+  }'
+```
+
+预期返回:
+```json
+[{"docId":"test-001","chunkId":"test-001_chunk_0","score":0.85,...}]
+```
+
+#### 3.3 查询文档
+
+```bash
+curl http://localhost:8084/api/v1/documents/test-001
+```
+
+#### 3.4 删除文档
+
+```bash
+curl -X DELETE http://localhost:8084/api/v1/documents/test-001
+```
+
+### 4. 查看 Elasticsearch 数据
+
+```bash
+# 查看索引中的文档数量
+curl http://localhost:9200/contract_chunks/_count
+
+# 查看所有文档
+curl http://localhost:9200/contract_chunks/_search?size=10
+
+# 查看指定文档
+curl http://localhost:9200/contract_chunks/_search \
+  -H 'Content-Type: application/json' \
+  -d '{"query":{"term":{"doc_id":"test-001"}}}'
+```
+
+### 5. 查看 MySQL 日志
+
+```bash
+# 查看操作日志
+mysql -h 127.0.0.1 -P 3306 -uroot -p123456 -e "
+SELECT operation_type, doc_id, status, duration_ms, create_time
+FROM schedule_embedding.operation_log
+ORDER BY create_time DESC LIMIT 5;"
+```
+
+### 6. 完整测试脚本
+
+也可以使用项目中的测试脚本:
+
+```bash
+# 简单测试
+bash test_api.sh
+
+# 完整测试
+bash test_complete.sh
+```
+
+---
+
+## 项目结构
+
+```
+src/main/java/cn/com/yusys/manager/
+├── config/           # 配置类
+├── controller/       # REST 控制器
+├── service/         # 业务服务
+├── repository/      # 数据访问层
+├── client/          # 外部 API 客户端
+├── model/           # 数据模型
+│   ├── entity/      # 实体类
+│   ├── dto/         # 请求/响应对象
+│   └── vo/          # 视图对象
+├── listener/        # Kafka 监听器
+├── splitter/        # 文本分割器
+└── exception/       # 异常处理
+```

Dosya farkı çok büyük olduğundan ihmal edildi
+ 35 - 0
schedule-embedding-api/complete_test_results.txt


+ 1225 - 0
schedule-embedding-api/docs/API接口文档.md

@@ -0,0 +1,1225 @@
+# schedule-embedding-api 接口文档
+
+## 📋 文档概述
+
+**项目名称**: schedule-embedding-api
+**文档版本**: v2.2
+**最后更新**: 2026-03-13
+**服务地址**: http://localhost:8084
+
+### 功能简介
+
+本系统提供基于 Elasticsearch 8.x 和 Embedding API 的智能文档向量化与检索服务,主要功能包括:
+
+- 📄 **文档入库**: 支持单个文档和批量文档的向量化入库
+- 🔍 **向量搜索**: 基于 4096 维向量的语义搜索
+- 🎯 **混合搜索**: 支持向量搜索与业务字段过滤相结合
+- 📊 **文档管理**: 提供文档查询和删除功能
+- 🔄 **完全动态**: 支持任意类型数据(合同、音频、视频、图片等)
+
+### 技术栈
+
+- **Embedding 模型**: Qwen3-Embedding-8B (4096维向量)
+- **搜索引擎**: Elasticsearch 8.x (原生 KNN 支持)
+- **开发框架**: Spring Boot 2.7.18
+- **JDK 版本**: Java 17
+
+### 核心设计理念
+
+**ES动态映射 + 业务字段完全动态**
+
+- ✅ **核心字段(固定)**: docId, chunkId, content, embedding, createTime, updateTime
+- ✅ **动态映射**: 使用 ES DynamicMapping.True,自动识别任意新字段
+- ✅ **metadata完全动态**: 所有业务数据通过 `metadata` 动态扩展,支持任意类型和字段
+- ✅ **零代码扩展**: 新增字段无需修改代码,完全动态化
+- ✅ **类型无关**: 支持合同、音频、视频、图片等任意数据类型
+
+---
+
+## 🔗 接口列表
+
+| 序号 | 接口名称 | 请求方法 | 接口路径 | 功能说明 |
+|-----|---------|---------|---------|---------|
+| 1 | 健康检查 | GET | /actuator/health | 检查服务健康状态 |
+| 2 | 单个文档入库 | POST | /api/v1/documents/index | 将单个文档向量化后存入ES |
+| 3 | 批量文档入库 | POST | /api/v1/documents/batch-index | 批量将文档向量化后存入ES |
+| 4 | 向量搜索 | POST | /api/v1/search | 基于语义的向量搜索 |
+| 5 | 混合搜索 | POST | /api/v1/search/hybrid | 向量搜索 + 业务字段过滤 |
+| 6 | 查询文档 | GET | /api/v1/documents/{docId} | 查询文档的所有chunks |
+| 7 | 删除文档 | DELETE | /api/v1/documents/{docId} | 删除文档的所有chunks |
+| 8 | 删除Chunk | DELETE | /api/v1/documents/chunk/{chunkId} | 删除单个chunk |
+
+---
+
+## 📖 接口详情
+
+### 1. 健康检查接口
+
+#### 基本信息
+
+- **接口名称**: 健康检查
+- **接口路径**: `/actuator/health`
+- **请求方法**: `GET`
+- **Content-Type**: 无需设置
+
+#### 请求示例
+
+```bash
+curl -X GET http://localhost:8084/actuator/health
+```
+
+#### 响应示例
+
+**状态码**: 200 OK
+
+```json
+{
+  "status": "UP",
+  "components": {
+    "diskSpace": {
+      "status": "UP",
+      "details": {
+        "total": 994662584320,
+        "free": 110714380288,
+        "threshold": 10485760,
+        "exists": true
+      }
+    },
+    "elasticsearch": {
+      "status": "UP",
+      "details": {
+        "cluster_name": "docker-cluster",
+        "status": "yellow",
+        "number_of_nodes": 1,
+        "number_of_data_nodes": 1
+      }
+    },
+    "ping": {
+      "status": "UP"
+    }
+  }
+}
+```
+
+#### 测试用例
+
+| 用例编号 | 测试场景 | 预期结果 |
+|---------|---------|---------|
+| TC-001 | 正常访问健康检查接口 | 返回200,status为UP |
+
+---
+
+### 2. 单个文档入库接口
+
+#### 基本信息
+
+- **接口名称**: 单个文档入库
+- **接口路径**: `/api/v1/documents/index`
+- **请求方法**: `POST`
+- **Content-Type**: `application/json`
+
+#### 请求参数
+
+| 参数名 | 类型 | 必填 | 说明 | 示例值 |
+|-------|------|------|------|--------|
+| docId | String | 是 | 文档唯一标识 | "contract-001" |
+| fileName | String | 否 | 文件名 | "合同.pdf" |
+| fullText | String | 是 | 文档全文内容 | "这是合同内容..." |
+| filePath | String | 否 | 文件路径 | "/data/contracts/001.pdf" |
+| fileSize | Long | 否 | 文件大小(字节) | 1024000 |
+| fileType | String | 否 | 文件类型 | "pdf" |
+| metadata | Map | 否 | **完全动态的业务元数据** | 见下方示例 |
+
+#### metadata 字段说明
+
+**重要**: `metadata` 是完全动态的,支持任意字段!
+
+**合同类文档示例**:
+
+```json
+{
+  "contractType": "消费贷贷款合同",
+  "partyA": "A公司",
+  "partyB": "B公司",
+  "contractAmount": 1000000.00,
+  "signDate": "2026-01-15"
+}
+```
+
+**音频类文档示例**:
+
+```json
+{
+  "duration": 1800,
+  "speaker": "张三",
+  "language": "zh-CN",
+  "sampleRate": 44100,
+  "bitrate": 128000,
+  "format": "MP3",
+  "transcript": "音频转写文本..."
+}
+```
+
+**视频类文档示例**:
+
+```json
+{
+  "duration": 3600,
+  "resolution": "1920x1080",
+  "frameRate": 30,
+  "codec": "H.264",
+  "bitrate": 5000000,
+  "subtitles": ["中文字幕", "英文字幕"]
+}
+```
+
+**图片类文档示例**:
+
+```json
+{
+  "width": 1920,
+  "height": 1080,
+  "format": "JPEG",
+  "colorSpace": "RGB",
+  "dpi": 300,
+  "description": "图片描述..."
+}
+```
+
+#### 请求示例
+
+**示例 1: 合同类文档入库**
+
+```bash
+curl -X POST http://localhost:8084/api/v1/documents/index \
+  -H "Content-Type: application/json" \
+  -d '{
+    "docId": "contract-001",
+    "fileName": "永续贷产品贷款合同.pdf",
+    "fullText": "永续贷产品贷款合同\n\n第一章:总则\n本合同由甲方A公司与乙方B公司签订,合同金额为100万元。\n\n第二章:贷款条款\n2.1 贷款利率按照央行基准利率执行。\n2.2 贷款期限为36个月。\n2.3 还款方式为等额本息。",
+    "filePath": "/home/data/contracts/contract-001.pdf",
+    "fileSize": 1289748,
+    "fileType": "pdf",
+    "metadata": {
+      "contractType": "消费贷贷款合同",
+      "partyA": "A公司",
+      "partyB": "B公司",
+      "contractAmount": 1000000.00,
+      "signDate": "2026-01-15"
+    }
+  }'
+```
+
+**示例 2: 音频类文档入库**
+
+```bash
+curl -X POST http://localhost:8084/api/v1/documents/index \
+  -H "Content-Type: application/json" \
+  -d '{
+    "docId": "audio-001",
+    "fileName": "会议录音.mp3",
+    "fullText": "这是会议的完整转写文本内容,包含所有发言人的讲话记录...",
+    "filePath": "/home/data/audio/meeting_001.mp3",
+    "fileSize": 5120000,
+    "fileType": "mp3",
+    "metadata": {
+      "duration": 1800,
+      "speaker": "张三",
+      "language": "zh-CN",
+      "sampleRate": 44100,
+      "format": "MP3"
+    }
+  }'
+```
+
+**示例 3: 视频类文档入库**
+
+```bash
+curl -X POST http://localhost:8084/api/v1/documents/index \
+  -H "Content-Type: application/json" \
+  -d '{
+    "docId": "video-001",
+    "fileName": "培训课程.mp4",
+    "fullText": "这是视频的字幕文本内容...",
+    "filePath": "/home/data/video/training_001.mp4",
+    "fileSize": 51200000,
+    "fileType": "mp4",
+    "metadata": {
+      "duration": 3600,
+      "resolution": "1920x1080",
+      "frameRate": 30,
+      "codec": "H.264",
+      "subtitles": ["中文字幕", "英文字幕"]
+    }
+  }'
+```
+
+#### 响应示例
+
+**成功响应** - 200 OK
+
+```json
+{
+  "success": true,
+  "docId": "contract-001",
+  "chunkCount": 1,
+  "message": "文档入库成功",
+  "error": null
+}
+```
+
+**失败响应** - 400 Bad Request
+
+```json
+{
+  "code": 400,
+  "success": false,
+  "message": "参数校验失败",
+  "errors": {
+    "docId": "文档ID不能为空"
+  },
+  "timestamp": 1773306699906
+}
+```
+
+```json
+{
+  "code": 400,
+  "success": false,
+  "message": "参数校验失败",
+  "errors": {
+    "fullText": "全文内容不能为空"
+  },
+  "timestamp": 1773306699937
+}
+```
+
+#### 测试用例
+
+| 用例编号 | 测试场景 | 请求参数 | 预期结果 |
+|---------|---------|---------|---------|
+| TC-002 | 正常数据入库(合同) | 完整的合同信息 | 200,chunkCount=1 |
+| TC-003 | 缺少docId | 不传docId字段 | 400,提示"文档ID不能为空" |
+| TC-004 | 空文本内容 | fullText为空字符串 | 400,提示"全文内容不能为空" |
+| TC-005 | 音频数据入库 | 音频元数据(duration、speaker等) | 200,正常入库 |
+| TC-006 | 视频数据入库 | 视频元数据(resolution、codec等) | 200,正常入库 |
+| TC-007 | 动态字段扩展 | 新增任意自定义字段 | 200,零代码扩展成功 |
+
+---
+
+### 3. 批量文档入库接口
+
+#### 基本信息
+
+- **接口名称**: 批量文档入库
+- **接口路径**: `/api/v1/documents/batch-index`
+- **请求方法**: `POST`
+- **Content-Type**: `application/json`
+
+#### 请求参数
+
+| 参数名 | 类型 | 必填 | 说明 | 示例值 |
+|-------|------|------|------|--------|
+| items | Array\<IndexRequest\> | 是 | 文档列表(最多100个) | 见下方示例 |
+
+IndexRequest 结构同"单个文档入库"接口。
+
+#### 请求示例
+
+```bash
+curl -X POST http://localhost:8084/api/v1/documents/batch-index \
+  -H "Content-Type: application/json" \
+  -d '{
+    "items": [
+      {
+        "docId": "batch-doc-001",
+        "fileName": "合同1.pdf",
+        "fullText": "这是第一个合同文档的内容。包含重要条款和条件。",
+        "fileType": "pdf",
+        "metadata": {
+          "contractType": "消费贷",
+          "partyA": "A公司"
+        }
+      },
+      {
+        "docId": "batch-doc-002",
+        "fileName": "音频1.mp3",
+        "fullText": "这是音频的转写文本内容。",
+        "fileType": "mp3",
+        "metadata": {
+          "duration": 1800,
+          "speaker": "张三"
+        }
+      },
+      {
+        "docId": "batch-doc-003",
+        "fileName": "视频1.mp4",
+        "fullText": "这是视频的字幕内容。",
+        "fileType": "mp4",
+        "metadata": {
+          "duration": 3600,
+          "resolution": "1920x1080"
+        }
+      }
+    ]
+  }'
+```
+
+#### 响应示例
+
+**成功响应** - 200 OK
+
+```json
+{
+  "totalCount": 3,
+  "successCount": 3,
+  "failedCount": 0,
+  "failedItems": []
+}
+```
+
+**部分失败响应** - 200 OK
+
+```json
+{
+  "totalCount": 3,
+  "successCount": 2,
+  "failedCount": 1,
+  "failedItems": [
+    {
+      "docId": "batch-doc-003",
+      "error": "全文内容不能为空"
+    }
+  ]
+}
+```
+
+**失败响应** - 400 Bad Request
+
+```json
+{
+  "code": 400,
+  "success": false,
+  "message": "参数校验失败",
+  "errors": {
+    "items": "文档列表不能为空"
+  },
+  "timestamp": 1773306700683
+}
+```
+
+#### 测试用例
+
+| 用例编号 | 测试场景 | 请求参数 | 预期结果 |
+|---------|---------|---------|---------|
+| TC-008 | 正常批量入库 | 3个不同类型文档 | 200,successCount=3 |
+| TC-009 | 空列表 | items=[] | 400,提示"文档列表不能为空" |
+| TC-010 | 大批量入库 | 10个文档 | 200,successCount=10 |
+| TC-011 | 部分失败 | 3个文档,1个缺少fullText | 200,successCount=2,failedCount=1 |
+
+---
+
+### 4. 向量搜索接口
+
+#### 基本信息
+
+- **接口名称**: 向量搜索
+- **接口路径**: `/api/v1/search`
+- **请求方法**: `POST`
+- **Content-Type**: `application/json`
+
+#### 功能说明
+
+基于语义的向量搜索,使用 Embedding 模型将查询文本转换为 4096 维向量,然后在 ES 中进行 KNN 搜索,返回最相似的结果。
+
+#### 请求参数
+
+| 参数名 | 类型 | 必填 | 说明 | 示例值 | 默认值 |
+|-------|------|------|------|--------|-------|
+| query | String | 是 | 查询文本 | "A公司的消费贷合同利率是多少" | - |
+| topK | Integer | 否 | 返回结果数量 | 5 | 10 |
+
+#### 请求示例
+
+```bash
+curl -X POST http://localhost:8084/api/v1/search \
+  -H "Content-Type: application/json" \
+  -d '{
+    "query": "A公司的消费贷合同利率是多少",
+    "topK": 5
+  }'
+```
+
+#### 响应示例
+
+**成功响应** - 200 OK
+
+```json
+[
+  {
+    "docId": "company-a-test",
+    "chunkId": "company-a-test_chunk_0",
+    "chunkIndex": 0,
+    "content": "A公司与B公司签订消费贷合同,合同金额100万元,贷款利率按照央行基准利率执行,贷款期限36个月",
+    "score": 0.8871919,
+    "metadata": {
+      "chunk_index": 0,
+      "file_path": "/tmp/test.pdf",
+      "create_time": "2026-03-12T17:06:51",
+      "doc_id": "company-a-test",
+      "chunk_id": "company-a-test_chunk_0",
+      "content": "A公司与B公司签订消费贷合同,合同金额100万元,贷款利率按照央行基准利率执行,贷款期限36个月",
+      "file_size": 1024,
+      "file_size_mb": 0.0009765625,
+      "chunk_count": 0,
+      "update_time": "2026-03-12T17:06:51",
+      "file_type": "pdf",
+      "contract_type": "消费贷贷款合同",
+      "party_a": "A公司",
+      "party_b": "B公司"
+    }
+  }
+]
+```
+
+**注意**: 响应中**不包含** `embedding` 字段,以节省带宽和提高性能。
+
+**无结果响应** - 200 OK
+
+```json
+[]
+```
+
+**失败响应** - 400 Bad Request
+
+```json
+{
+  "code": 400,
+  "success": false,
+  "message": "参数校验失败",
+  "errors": {
+    "query": "查询文本不能为空"
+  },
+  "timestamp": 1773306701000
+}
+```
+
+#### 响应字段说明
+
+| 字段名 | 类型 | 说明 |
+|-------|------|------|
+| docId | String | 文档ID |
+| chunkId | String | 分片ID |
+| chunkIndex | Integer | 分片序号 |
+| content | String | 匹配的内容片段 |
+| score | Float | 相似度分数(0-1,越高越相似) |
+| metadata | Map | 完整的元数据信息(不包含embedding) |
+
+#### 测试用例
+
+| 用例编号 | 测试场景 | 请求参数 | 预期结果 |
+|---------|---------|---------|---------|
+| TC-012 | 语义搜索 | query="合同利率", topK=5 | 200,返回相关合同片段 |
+| TC-013 | 空查询 | 不传query | 400,提示"查询文本不能为空" |
+| TC-014 | 指定topK | query="贷款", topK=3 | 200,返回最多3条结果 |
+| TC-015 | 无匹配结果 | query="不存在的特定内容" | 200,返回空数组[] |
+| TC-016 | 中文模糊搜索 | query="多少钱" | 200,返回包含金额信息的片段 |
+
+---
+
+### 5. 混合搜索接口
+
+#### 基本信息
+
+- **接口名称**: 混合搜索(向量 + 业务过滤)
+- **接口路径**: `/api/v1/search/hybrid`
+- **请求方法**: `POST`
+- **Content-Type**: `application/json`
+
+#### 功能说明
+
+结合向量搜索和业务字段过滤,先进行 KNN 搜索,再根据指定的业务字段过滤结果。
+
+#### 请求参数
+
+| 参数名 | 类型 | 必填 | 说明 | 示例值 | 默认值 |
+|-------|------|------|------|--------|-------|
+| query | String | 是 | 查询文本 | "贷款利率和还款方式" | - |
+| topK | Integer | 否 | 返回结果数量 | 10 | 10 |
+| filters | Map | 否 | **动态过滤条件,支持任意字段** | 见下方示例 | - |
+
+#### filters 字段说明
+
+**重要**: `filters` 是完全动态的,可以根据入库时的 metadata 任意过滤!
+
+**合同过滤示例**:
+```json
+{
+  "contractType": "消费贷贷款合同",
+  "partyA": "A公司"
+}
+```
+
+**音频过滤示例**:
+```json
+{
+  "speaker": "张三",
+  "language": "zh-CN"
+}
+```
+
+**视频过滤示例**:
+```json
+{
+  "resolution": "1920x1080",
+  "codec": "H.264"
+}
+```
+
+#### 请求示例
+
+**示例 1: 合同过滤**
+
+```bash
+curl -X POST http://localhost:8084/api/v1/search/hybrid \
+  -H "Content-Type: application/json" \
+  -d '{
+    "query": "贷款利率和还款方式",
+    "topK": 10,
+    "filters": {
+      "contractType": "消费贷贷款合同",
+      "partyA": "A公司"
+    }
+  }'
+```
+
+**示例 2: 音频过滤**
+
+```bash
+curl -X POST http://localhost:8084/api/v1/search/hybrid \
+  -H "Content-Type: application/json" \
+  -d '{
+    "query": "会议讨论的重点内容",
+    "topK": 5,
+    "filters": {
+      "speaker": "张三",
+      "language": "zh-CN"
+    }
+  }'
+```
+
+**示例 3: 视频过滤**
+
+```bash
+curl -X POST http://localhost:8084/api/v1/search/hybrid \
+  -H "Content-Type: application/json" \
+  -d '{
+    "query": "培训课程的核心要点",
+    "topK": 10,
+    "filters": {
+      "resolution": "1920x1080",
+      "codec": "H.264"
+    }
+  }'
+```
+
+#### 响应示例
+
+**成功响应** - 200 OK
+
+```json
+[
+  {
+    "docId": "test-doc-001",
+    "chunkId": "test-doc-001_chunk_0",
+    "chunkIndex": 0,
+    "content": "永续贷产品贷款合同\n\n第二章:贷款条款\n2.1 贷款利率按照央行基准利率执行。\n2.2 贷款期限为36个月。\n2.3 还款方式为等额本息。",
+    "score": 0.8512345,
+    "metadata": {
+      "contract_type": "消费贷贷款合同",
+      "party_a": "A公司",
+      "party_b": "B公司",
+      "contract_amount": 1000000.0
+    }
+  }
+]
+```
+
+**无匹配结果** - 200 OK
+
+```json
+[]
+```
+
+#### 测试用例
+
+| 用例编号 | 测试场景 | 请求参数 | 预期结果 |
+|---------|---------|---------|---------|
+| TC-017 | 合同字段过滤 | query="贷款利率", filters={contractType:"消费贷",partyA:"A公司"} | 200,返回A公司的消费贷合同 |
+| TC-018 | 音频字段过滤 | query="会议记录", filters={speaker:"张三"} | 200,返回张三发言的音频 |
+| TC-019 | 视频字段过滤 | query="课程", filters={resolution:"1920x1080"} | 200,返回高清视频 |
+| TC-020 | 无匹配过滤 | query="利率", filters={contractType:"不存在的类型"} | 200,返回空数组[] |
+| TC-021 | 无过滤条件 | query="合同", 不传filters | 200,等同于普通向量搜索 |
+
+---
+
+### 6. 查询文档接口
+
+#### 基本信息
+
+- **接口名称**: 查询文档的所有chunks
+- **接口路径**: `/api/v1/documents/{docId}`
+- **请求方法**: `GET`
+- **Content-Type**: 无需设置
+
+#### 请求参数
+
+| 参数名 | 类型 | 必填 | 说明 | 示例值 |
+|-------|------|------|------|--------|
+| docId | String | 是 | 文档ID(路径参数) | "contract-001" |
+
+#### 请求示例
+
+```bash
+curl -X GET http://localhost:8084/api/v1/documents/contract-001
+```
+
+#### 响应示例
+
+**成功响应** - 200 OK
+
+```json
+[
+  {
+    "docId": "contract-001",
+    "chunkId": "contract-001_chunk_0",
+    "chunkIndex": 0,
+    "content": "永续贷产品贷款合同\n\n第一章:总则\n本合同由甲方A公司与乙方B公司签订。",
+    "filePath": "/data/contracts/contract-001.pdf",
+    "fileType": "pdf",
+    "createTime": "2026-03-12 17:10:15",
+    "updateTime": "2026-03-12 17:10:15",
+    "extendedFields": {
+      "contractType": "消费贷贷款合同",
+      "partyA": "A公司",
+      "partyB": "B公司"
+    }
+  }
+]
+```
+
+**文档不存在** - 200 OK
+
+```json
+[]
+```
+
+#### 测试用例
+
+| 用例编号 | 测试场景 | 请求参数 | 预期结果 |
+|---------|---------|---------|---------|
+| TC-022 | 查询存在的文档 | docId="contract-001" | 200,返回文档的所有chunks |
+| TC-023 | 查询不存在的文档 | docId="non-existent" | 200,返回空数组[] |
+
+---
+
+### 7. 删除文档接口
+
+#### 基本信息
+
+- **接口名称**: 删除文档的所有chunks
+- **接口路径**: `/api/v1/documents/{docId}`
+- **请求方法**: `DELETE`
+- **Content-Type**: 无需设置
+
+#### 请求参数
+
+| 参数名 | 类型 | 必填 | 说明 | 示例值 |
+|-------|------|------|------|--------|
+| docId | String | 是 | 文档ID(路径参数) | "contract-001" |
+
+#### 请求示例
+
+```bash
+curl -X DELETE http://localhost:8084/api/v1/documents/contract-001
+```
+
+#### 响应示例
+
+**成功响应** - 200 OK
+
+```json
+{
+  "success": true,
+  "docId": "contract-001",
+  "deletedCount": 1,
+  "message": "删除成功"
+}
+```
+
+**文档不存在** - 200 OK
+
+```json
+{
+  "success": true,
+  "docId": "non-existent-doc",
+  "deletedCount": 0,
+  "message": "删除成功"
+}
+```
+
+#### 测试用例
+
+| 用例编号 | 测试场景 | 请求参数 | 预期结果 |
+|---------|---------|---------|---------|
+| TC-024 | 删除存在的文档 | docId="contract-001" | 200,deletedCount>0 |
+| TC-025 | 删除不存在的文档 | docId="non-existent" | 200,deletedCount=0 |
+
+---
+
+### 8. 删除Chunk接口
+
+#### 基本信息
+
+- **接口名称**: 删除单个chunk
+- **接口路径**: `/api/v1/documents/chunk/{chunkId}`
+- **请求方法**: `DELETE`
+- **Content-Type**: 无需设置
+
+#### 请求参数
+
+| 参数名 | 类型 | 必填 | 说明 | 示例值 |
+|-------|------|------|------|--------|
+| chunkId | String | 是 | 分片ID(路径参数) | "contract-001_chunk_0" |
+
+#### 请求示例
+
+```bash
+curl -X DELETE http://localhost:8084/api/v1/documents/chunk/contract-001_chunk_0
+```
+
+#### 响应示例
+
+**成功响应** - 200 OK
+
+```json
+{
+  "success": true,
+  "chunkId": "contract-001_chunk_0",
+  "message": "删除成功"
+}
+```
+
+**chunk不存在** - 200 OK
+
+```json
+{
+  "success": false,
+  "chunkId": "non-existent-chunk",
+  "message": "删除失败"
+}
+```
+
+#### 测试用例
+
+| 用例编号 | 测试场景 | 请求参数 | 预期结果 |
+|---------|---------|---------|---------|
+| TC-026 | 删除存在的chunk | chunkId="contract-001_chunk_0" | 200,success=true |
+| TC-027 | 删除不存在的chunk | chunkId="non-existent-chunk" | 200,success=false |
+
+---
+
+## 🔒 错误码说明
+
+### HTTP 状态码
+
+| 状态码 | 说明 |
+|-------|------|
+| 200 | 请求成功 |
+| 400 | 请求参数错误 |
+| 500 | 服务器内部错误 |
+
+### 业务错误码
+
+| 错误码 | 说明 | 示例 |
+|-------|------|------|
+| 400 | 参数校验失败 | 缺少必填字段、字段格式错误 |
+
+### 错误响应格式
+
+```json
+{
+  "code": 400,
+  "success": false,
+  "message": "参数校验失败",
+  "errors": {
+    "fieldName": "具体错误信息"
+  },
+  "timestamp": 1773306699906
+}
+```
+
+---
+
+## 📊 数据模型
+
+### Elasticsearch 索引结构
+
+本系统使用 **ES动态映射(DynamicMapping.True)**,索引结构如下:
+
+#### 固定字段(必须显式定义)
+
+| 字段名 | 类型 | 说明 |
+|-------|------|------|
+| doc_id | keyword | 文档唯一标识 |
+| chunk_id | keyword | 分片唯一标识 |
+| chunk_index | long | 分片序号 |
+| content | text | 文档内容(支持全文搜索) |
+| embedding | dense_vector | 4096维向量(用于KNN搜索) |
+| create_time | date | 创建时间 |
+| update_time | date | 更新时间 |
+
+#### 动态字段(自动识别)
+
+除上述固定字段外,**所有其他字段都是动态的**,包括:
+
+- 文件元数据:file_path, file_size, file_type 等
+- 业务元数据:任意自定义字段(通过 metadata 传入)
+- ES会根据传入值自动推断类型:字符串→text/keyword,数字→long/double,日期→date等
+
+**示例**:传入以下 metadata:
+```json
+{
+  "contractType": "贷款合同",
+  "partyA": "A公司",
+  "contractAmount": 1000000,
+  "signDate": "2026-01-15"
+}
+```
+ES会自动创建并索引这些字段,无需修改代码。
+
+### IndexRequest(文档入库请求)
+
+```json
+{
+  "docId": "string (必填)",
+  "fileName": "string (可选)",
+  "fullText": "string (必填)",
+  "filePath": "string (可选)",
+  "fileSize": "long (可选)",
+  "fileType": "string (可选)",
+  "metadata": {
+    "任意业务字段": "任意值(完全动态)"
+  }
+}
+```
+
+### SearchRequest(搜索请求)
+
+```json
+{
+  "query": "string (必填)",
+  "topK": "integer (可选, 默认10)",
+  "filters": {
+    "任意字段": "任意值(完全动态)"
+  }
+}
+```
+
+### SearchResult(搜索结果)
+
+```json
+{
+  "docId": "string",
+  "chunkId": "string",
+  "chunkIndex": "integer",
+  "content": "string",
+  "score": "float",
+  "metadata": {
+    "所有文档元数据字段(不包含embedding)"
+  }
+}
+```
+
+**注意**: metadata 中不包含 `embedding` 字段,以节省带宽。
+
+---
+
+## 🧪 完整测试用例清单
+
+### 测试环境要求
+
+- ✅ Elasticsearch 8.x 运行正常
+- ✅ Embedding API 服务可用
+- ✅ 应用服务运行在 http://localhost:8084
+
+### 执行测试
+
+```bash
+# 运行完整测试套件
+bash test_api.sh
+
+# 查看测试结果
+cat api_test_results.txt
+```
+
+### 测试用例总览
+
+| 模块 | 用例数 | 涵盖场景 |
+|------|-------|---------|
+| 健康检查 | 1 | 服务状态检查 |
+| 文档入库 | 6 | 正常入库、参数校验、多数据类型、动态字段 |
+| 批量入库 | 4 | 正常批量、空列表、大批量、部分失败 |
+| 向量搜索 | 5 | 语义搜索、空查询、指定数量、无结果 |
+| 混合搜索 | 5 | 合同过滤、音频过滤、视频过滤、多条件 |
+| 文档管理 | 5 | 查询、删除文档和chunk |
+| **合计** | **26** | **覆盖所有核心场景** |
+
+---
+
+## 📝 附录
+
+### A. 快速开始
+
+#### 1. 启动服务
+
+```bash
+# 启动应用
+java -jar schedule-embedding-api.jar
+
+# 或使用 Maven
+mvn spring-boot:run
+```
+
+#### 2. 验证服务
+
+```bash
+curl http://localhost:8084/actuator/health
+```
+
+#### 3. 测试文档入库
+
+```bash
+curl -X POST http://localhost:8084/api/v1/documents/index \
+  -H "Content-Type: application/json" \
+  -d '{
+    "docId": "test-001",
+    "fullText": "测试文档内容"
+  }'
+```
+
+#### 4. 测试向量搜索
+
+```bash
+curl -X POST http://localhost:8084/api/v1/search \
+  -H "Content-Type: application/json" \
+  -d '{
+    "query": "测试",
+    "topK": 5
+  }'
+```
+
+### B. 常见问题
+
+#### Q1: 为什么向量搜索返回空数组?
+
+**A**: 可能的原因:
+1. ES 中还没有相关文档,先执行文档入库
+2. 查询文本与现有文档语义差异较大
+3. ES 索引延迟,等待几秒后重试
+
+#### Q2: 如何提高搜索准确度?
+
+**A**: 建议:
+1. 入库时提供完整的文档内容
+2. 使用与文档风格相似的查询文本
+3. 调整 topK 参数获取更多结果
+4. 使用混合搜索添加业务过滤条件
+
+#### Q3: 批量入库有限制吗?
+
+**A**:
+- 单次批量请求建议不超过 100 个文档
+- 超长文本会自动分片处理
+- 建议分批次大批量数据
+
+#### Q4: metadata 支持哪些字段?
+
+**A**:
+- **支持任意自定义字段**,完全动态化
+- 合同类:contractType, partyA, partyB, contractAmount 等
+- 音频类:duration, speaker, language, sampleRate 等
+- 视频类:resolution, frameRate, codec, duration 等
+- 图片类:width, height, format, dpi 等
+- 任意其他自定义字段
+
+#### Q5: 新增字段需要修改代码吗?
+
+**A**:
+- **完全不需要**!
+- 直接在 metadata 中添加任意字段即可
+- 系统会自动识别、存储、索引
+- 搜索时也可以直接使用新字段过滤
+
+### C. 性能指标
+
+| 指标 | 数值 | 说明 |
+|------|------|------|
+| 单文档入库 | < 2s | 包含向量化时间 |
+| 批量入库(10个) | < 5s | 并发处理 |
+| 向量搜索 | < 1s | topK=10 |
+| 混合搜索 | < 1.5s | 包含过滤 |
+| 向量维度 | 4096 | Qwen3-Embedding-8B |
+| 响应大小优化 | ~16KB/结果 | 移除embedding字段 |
+
+### D. 设计优势
+
+### 1. ES动态映射(DynamicMapping)
+
+本系统使用 Elasticsearch 的 **动态映射** 功能,实现真正的零代码扩展:
+
+```java
+// 创建索引时启用动态映射
+.mappings(m -> m
+    .dynamic(DynamicMapping.True)  // 关键配置!
+    .properties("doc_id", p -> p.keyword(k -> k))
+    .properties("chunk_id", p -> p.keyword(k -> k))
+    // ... 核心字段
+    // 其他字段自动识别!
+)
+```
+
+**效果**:
+- 传入任意 metadata 字段,ES 自动创建索引
+- 自动推断字段类型(字符串、数字、日期等)
+- 无需修改代码,新增字段即生效
+
+### 2. 完全动态化(代码层面)
+
+**旧设计**:
+```java
+// 每个字段都要判断
+if (metadata.containsKey("contractType")) {
+    doc.setContractType((String) metadata.get("contractType"));
+}
+if (metadata.containsKey("duration")) {
+    doc.setDuration((Integer) metadata.get("duration"));
+}
+// ... 无穷无尽的 if
+```
+
+**新设计**:
+```java
+// 所有字段自动处理,只有 1 行代码!
+doc.addExtendedFields(request.getMetadata());
+```
+
+### 3. 类型无关
+
+- ✅ 合同文档、音频、视频、图片等任意类型
+- ✅ 每种类型有自己独特的业务字段
+- ✅ 无需为每种类型定义不同的实体类
+
+### 4. 零代码扩展
+
+| 操作 | 旧设计 | 新设计 |
+|------|-------|--------|
+| 新增合同字段 | 修改 ES Mapping + 代码 | 直接在 metadata 加字段 |
+| 新增音频字段 | 修改 ES Mapping + 代码 | 直接在 metadata 加字段 |
+| 新增视频字段 | 修改 ES Mapping + 代码 | 直接在 metadata 加字段 |
+| 新增任意字段 | 修改 ES Mapping + 代码 | 直接在 metadata 加字段 |
+
+### E. 联系方式
+
+- **项目地址**: /Users/Vova/Gitee/four-level-schedule/schedule-embedding-api
+- **测试脚本**: test_api.sh(基础测试), test_complete.sh(完整功能测试)
+- **数据模型文档**: 数据模型设计文档.md
+
+---
+
+## 📝 实际使用示例
+
+基于解析结果示例材料的典型入库请求:
+
+### 示例1:银行流水文档
+
+```bash
+curl -X POST http://localhost:8084/api/v1/documents/index \
+  -H "Content-Type: application/json" \
+  -d '{
+    "docId": "bank-flow-001",
+    "fileName": "111111133.png",
+    "fullText": "账户明细...交易记录...",
+    "filePath": "/data/files/20260313_8f9e7d6c5b4a.png",
+    "fileSize": 102400,
+    "fileType": "png",
+    "metadata": {
+      "file_unique_id": "20260313_8f9e7d6c5b4a",
+      "business_topic": "金融-信贷审批",
+      "document_type": "银行流水",
+      "belong_department": "风控部",
+      "tags": ["信贷", "流水", "合规", "农业"]
+    }
+  }'
+```
+
+### 示例2:操作手册文档
+
+```bash
+curl -X POST http://localhost:8084/api/v1/documents/index \
+  -H "Content-Type: application/json" \
+  -d '{
+    "docId": "manual-001",
+    "fileName": "宇信科技操作手册.docx",
+    "fullText": "宇信科技管理数智化星云平台 快速入门操作手册...",
+    "filePath": "/data/files/manual.docx",
+    "fileSize": 2048000,
+    "fileType": "docx",
+    "metadata": {
+      "business_topic": "金融-系统文件",
+      "document_type": "操作手册",
+      "belong_department": "产品部",
+      "tags": ["金融", "企管", "星云", "操作手册"]
+    }
+  }'
+```
+
+### 典型metadata字段说明
+
+| 字段名 | 类型 | 说明 | 示例值 |
+|-------|------|------|--------|
+| file_unique_id | String | 文件唯一标识 | "20260313_8f9e7d6c5b4a" |
+| business_topic | String | 业务主题 | "金融-信贷审批" |
+| document_type | String | 文档类型 | "银行流水"、"操作手册"、"贷款合同" |
+| belong_department | String | 所属部门 | "风控部"、"信贷部"、"审计部" |
+| tags | Array | 标签数组 | ["信贷", "流水", "合规"] |
+
+### 搜索示例
+
+**向量搜索**:
+```bash
+curl -X POST http://localhost:8084/api/v1/search \
+  -H "Content-Type: application/json" \
+  -d '{
+    "query": "银行流水交易记录",
+    "topK": 5
+  }'
+```
+
+**混合搜索(按业务维度过滤)**:
+```bash
+curl -X POST http://localhost:8084/api/v1/search/hybrid \
+  -H "Content-Type: application/json" \
+  -d '{
+    "query": "风险评估",
+    "topK": 5,
+    "filters": {
+      "business_topic": "金融-风险管理",
+      "belong_department": "风控部"
+    }
+  }'
+```
+
+---
+
+**文档结束**

+ 73 - 0
schedule-embedding-api/docs/init.sql

@@ -0,0 +1,73 @@
+-- =====================================================
+-- schedule-embedding-api 数据库初始化脚本
+-- 数据库: schedule_embedding
+-- =====================================================
+
+-- 创建数据库
+CREATE DATABASE IF NOT EXISTS schedule_embedding DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+USE schedule_embedding;
+
+-- =====================================================
+-- 1. 操作日志表 (HTTP API 调用审计)
+-- =====================================================
+CREATE TABLE IF NOT EXISTS operation_log (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    operation_type VARCHAR(50) NOT NULL COMMENT '操作类型 (INDEX/BATCH_INDEX/DELETE/SEARCH/HYBRID_SEARCH/GET_CHUNK/DELETE_CHUNK)',
+    doc_id VARCHAR(255) COMMENT '文档ID',
+    chunk_id VARCHAR(255) COMMENT 'chunk ID',
+    operator VARCHAR(100) COMMENT '操作人/调用方',
+    request_params TEXT COMMENT '请求参数 (JSON格式)',
+    response_result TEXT COMMENT '响应结果 (JSON格式)',
+    status VARCHAR(20) NOT NULL DEFAULT 'SUCCESS' COMMENT '执行状态 (SUCCESS/FAILED)',
+    error_message VARCHAR(1000) COMMENT '错误信息',
+    duration_ms BIGINT COMMENT '执行耗时(毫秒)',
+    ip_address VARCHAR(50) COMMENT 'IP地址',
+    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    INDEX idx_operation_type (operation_type),
+    INDEX idx_doc_id (doc_id),
+    INDEX idx_status (status),
+    INDEX idx_create_time (create_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志表 - 记录HTTP API调用';
+
+-- =====================================================
+-- 2. Kafka处理日志表 (Kafka消息处理)
+-- =====================================================
+CREATE TABLE IF NOT EXISTS kafka_processing_log (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    message_id VARCHAR(255) COMMENT 'Kafka消息ID/任务ID',
+    doc_id VARCHAR(255) COMMENT '文档ID',
+    task_type VARCHAR(50) COMMENT '任务类型 (INDEX/UPDATE/DELETE)',
+    processing_stage VARCHAR(50) NOT NULL COMMENT '处理阶段 (RECEIVED/PROCESSING/EMBEDDING/STORING/COMPLETED/FAILED)',
+    status VARCHAR(20) NOT NULL DEFAULT 'PROCESSING' COMMENT '执行状态 (PROCESSING/SUCCESS/FAILED)',
+    error_message VARCHAR(1000) COMMENT '错误信息',
+    duration_ms BIGINT COMMENT '执行耗时(毫秒)',
+    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    INDEX idx_message_id (message_id),
+    INDEX idx_doc_id (doc_id),
+    INDEX idx_processing_stage (processing_stage),
+    INDEX idx_status (status),
+    INDEX idx_create_time (create_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Kafka处理日志表 - 记录Kafka消息处理过程';
+
+-- =====================================================
+-- 表说明
+-- =====================================================
+-- operation_log: 记录所有HTTP API调用(入库、搜索、删除、查询)
+-- kafka_processing_log: 记录Kafka消息处理过程(需要启用Kafka)
+
+-- =====================================================
+-- 示例查询
+-- =====================================================
+-- 查看最近的操作日志
+-- SELECT * FROM operation_log ORDER BY create_time DESC LIMIT 10;
+
+-- 按操作类型统计
+-- SELECT operation_type, COUNT(*) as count FROM operation_log GROUP BY operation_type;
+
+-- 查看某个文档的操作记录
+-- SELECT * FROM operation_log WHERE doc_id = 'contract-001';
+
+-- 查看Kafka处理日志(需要启用Kafka)
+-- SELECT * FROM kafka_processing_log ORDER BY create_time DESC LIMIT 10;

+ 2502 - 0
schedule-embedding-api/docs/向量化入库方案.md

@@ -0,0 +1,2502 @@
+# 文本向量化与Elasticsearch入库方案
+
+## 一、环境测试结果
+
+### 1.1 Embedding接口测试
+
+**配置信息:**
+- 接口地址:`http://10.192.72.11:18081/v1`
+- API Key:`1`
+- 模型:`Qwen3-Embedding-8B`
+- 向量维度:`4096`
+- 批处理大小:`128`
+
+**测试结果:** ✅ 可用
+- HTTP状态码:200
+- 响应时间:< 1秒
+- 向量维度验证:正确返回4096维浮点数数组
+- 支持批量请求
+
+**请求示例:**
+```bash
+curl -X POST "http://10.192.72.11:18081/v1/embeddings" \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer 1" \
+  -d '{
+    "model": "Qwen3-Embedding-8B",
+    "input": "测试文本内容"
+  }'
+```
+
+### 1.2 Elasticsearch测试
+
+**配置信息:**
+- 地址:`http://10.192.72.13:9200`
+- 版本:`8.12.0`
+- 集群状态:yellow(单节点模式正常)
+
+**测试结果:** ✅ 可用且支持向量字段
+- 集群连接正常
+- dense_vector字段类型支持
+- KNN向量搜索支持
+- 向量维度严格验证
+
+**创建向量索引示例:**
+```json
+PUT /text_vectors
+{
+  "settings": {
+    "number_of_shards": 1,
+    "number_of_replicas": 0
+  },
+  "mappings": {
+    "properties": {
+      "id": { "type": "keyword" },
+      "content": { "type": "text" },
+      "embedding": {
+        "type": "dense_vector",
+        "dims": 4096,
+        "index": true,
+        "similarity": "cosine"
+      },
+      "business_id": { "type": "keyword" },
+      "create_time": { "type": "date" }
+    }
+  }
+}
+```
+
+---
+
+## 二、整体架构设计
+
+### 2.1 架构层次
+
+```
+┌─────────────────────────────────────────────────────────┐
+│                    Controller层                          │
+│  文本入库接口 / 向量搜索接口 / 批量操作接口               │
+└─────────────────────────────────────────────────────────┘
+                           ↓
+┌─────────────────────────────────────────────────────────┐
+│                    Service层                             │
+│  业务逻辑编排 / 批量处理策略 / 异常处理与重试             │
+└─────────────────────────────────────────────────────────┘
+                           ↓
+┌────────────────────────┬────────────────────────────────┐
+│    EmbeddingClient层   │     ElasticsearchRepository层   │
+│  调用向量化接口         │     ES操作封装                  │
+│  批量请求优化          │     索引管理/文档CRUD/KNN搜索   │
+└────────────────────────┴────────────────────────────────┘
+```
+
+### 2.2 数据流转
+
+**文本入库流程:**
+```
+原始文本
+  ↓
+文本预处理(长度限制、特殊字符处理)
+  ↓
+调用Embedding接口获取4096维向量
+  ↓
+构建ES文档(包含原文、向量、元数据)
+  ↓
+批量写入ES(Bulk API)
+  ↓
+返回入库结果(成功数、失败数、文档ID)
+```
+
+**向量搜索流程:**
+```
+用户输入查询文本
+  ↓
+调用Embedding接口向量化
+  ↓
+KNN搜索(cosine相似度)
+  ↓
+返回最相似的Top-K文档及相似度分数
+```
+
+---
+
+## 三、完整操作步骤(从零开始)
+
+### 3.1 操作流程总览
+
+```
+步骤0:环境准备
+   ↓
+步骤1:创建ES索引(定义表结构)
+   ↓
+步骤2:准备文本数据
+   ↓
+步骤3:文本向量化(调用Embedding接口)
+   ↓
+步骤4:数据入库ES(插入文档)
+   ↓
+步骤5:向量搜索(KNN查询)
+   ↓
+步骤6:验证结果
+```
+
+---
+
+### 3.2 步骤0:环境准备
+
+#### 3.2.1 检查服务状态
+
+| 服务 | 检查命令 | 预期结果 |
+|------|---------|---------|
+| **Embedding接口** | `curl http://10.192.72.11:18081/v1` | 返回API信息 |
+| **Elasticsearch** | `curl http://10.192.72.13:9200` | 返回ES版本信息 |
+
+#### 3.2.2 准备配置信息
+
+| 配置项 | 值 |
+|--------|-----|
+| Embedding接口地址 | `http://10.192.72.11:18081/v1` |
+| API Key | `1` |
+| 模型名称 | `Qwen3-Embedding-8B` |
+| 向量维度 | `4096` |
+| ES地址 | `http://10.192.72.13:9200` |
+| 索引名称 | `text_vectors` |
+
+---
+
+### 3.3 步骤1:创建ES索引(一次性操作)
+
+#### 3.3.1 创建索引命令
+
+```bash
+curl -X PUT "http://10.192.72.13:9200/text_vectors" \
+  -H "Content-Type: application/json" \
+  -d '{
+  "settings": {
+    "number_of_shards": 1,
+    "number_of_replicas": 0
+  },
+  "mappings": {
+    "properties": {
+      "content": {
+        "type": "text",
+        "analyzer": "standard"
+      },
+      "embedding": {
+        "type": "dense_vector",
+        "dims": 4096,
+        "index": true,
+        "similarity": "cosine"
+      },
+      "business_id": {
+        "type": "keyword"
+      },
+      "content_length": {
+        "type": "integer"
+      },
+      "create_time": {
+        "type": "date",
+        "format": "yyyy-MM-dd'\''T'\''HH:mm:ss"
+      }
+    }
+  }
+}'
+```
+
+#### 3.3.2 预期返回
+
+```json
+{
+  "acknowledged": true,
+  "shards_acknowledged": true,
+  "index": "text_vectors"
+}
+```
+
+#### 3.3.3 验证索引创建成功
+
+```bash
+curl -X GET "http://10.192.72.13:9200/text_vectors/_mapping?pretty"
+```
+
+#### 3.3.4 字段说明表
+
+| 字段名 | 类型 | 说明 | 示例值 |
+|--------|------|------|--------|
+| `content` | text | 原始文本内容 | "人工智能技术..." |
+| `embedding` | dense_vector | 4096维向量 | [0.0234, -0.0123, ...] |
+| `business_id` | keyword | 业务唯一标识 | "doc-ai-001" |
+| `content_length` | integer | 文本长度 | 45 |
+| `create_time` | date | 创建时间 | "2026-03-12T10:30:00" |
+
+---
+
+### 3.4 步骤2:准备文本数据与分段
+
+#### 3.4.1 重要说明:全量文本分段
+
+**输入:** 全量文本(未分段)
+```
+原始输入:一份完整的合同文档(5000字)
+```
+
+**输出:** 分段后的文本列表
+```
+分段输出:
+- Chunk 0: 第1-500字(合同基本信息)
+- Chunk 1: 第501-1000字(条款1)
+- Chunk 2: 第1001-1500字(条款2)
+- ...
+- Chunk 9: 第4501-5000字(签署信息)
+```
+
+**为什么要分段?**
+1. Embedding模型有token限制(通常512-2048 tokens)
+2. 分段检索更精准(直接定位到相关段落)
+3. 便于生成准确的回答(基于段落而非整篇文档)
+
+---
+
+#### 3.4.2 文本分段策略
+
+**策略对比表:**
+
+| 分段策略 | 原理 | 适用场景 | 优缺点 |
+|---------|------|---------|--------|
+| **固定长度分段** | 按字符数/token数切分 | 通用文档 | ✅ 简单<br>❌ 可能切断语义 |
+| **段落分段** | 按自然段落切分 | 结构化文档 | ✅ 保持语义完整<br>❌ 段落长度不均 |
+| **语义分段** | 基于语义相似度 | 重要文档 | ✅ 语义完整<br>❌ 需要额外模型 |
+| **滑动窗口** | 重叠切分 | 避免边界丢失 | ✅ 防止信息丢失<br>❌ 数据冗余 |
+| **混合策略** | 综合多种方法 | 生产环境推荐 | ✅ 平衡效果和性能 |
+
+---
+
+#### 3.4.3 推荐的分段策略(混合方案)
+
+**分段流程:**
+
+```
+全量文本(5000字)
+    ↓
+1. 预处理(去除特殊字符、统一格式)
+    ↓
+2. 按段落切分(得到15个自然段)
+    ↓
+3. 每段检查长度
+    ↓
+4. 超长段落继续切分(最大500字)
+    ↓
+5. 添加重叠(前后50字重叠)
+    ↓
+6. 生成chunk列表(最终20个chunk)
+    ↓
+7. 每个chunk添加元数据(chunk序号、页码等)
+```
+
+---
+
+#### 3.4.4 分段参数配置
+
+| 参数 | 推荐值 | 说明 |
+|------|--------|------|
+| **最大chunk长度** | 500-800字 | 确保不超过模型token限制 |
+| **最小chunk长度** | 100字 | 避免过短无意义的chunk |
+| **重叠大小** | 50-100字 | 防止边界信息丢失 |
+| **分段模式** | paragraph | 按段落优先,超长再切分 |
+| **保留换行** | false | 转换为空格,便于向量化 |
+
+---
+
+#### 3.4.5 分段示例
+
+**输入文本(全量):**
+```
+永续贷产品贷款合同
+
+第一章:总则
+本合同由甲方A公司与乙方B公司签订,合同金额为100万元。
+本合同旨在规范双方的借贷行为,保护双方合法权益。
+
+第二章:贷款条款
+2.1 贷款利率按照央行基准利率执行。
+2.2 贷款期限为36个月,从2026年1月15日起算。
+2.3 还款方式为等额本息,每月15日还款。
+
+第三章:违约责任
+任何一方违反本合同约定,应承担相应的违约责任。
+违约金为未还款项的5%。
+...
+```
+
+**分段输出:**
+
+| Chunk ID | 内容 | 长度 | 页码 |
+|----------|------|------|------|
+| doc-001_chunk_0 | 永续贷产品贷款合同\n\n第一章:总则\n本合同由甲方A公司与乙方B公司签订... | 85 | P1 |
+| doc-001_chunk_1 | ...保护双方合法权益。\n\n第二章:贷款条款\n2.1 贷款利率按照央行基准利率执行。 | 78 | P1 |
+| doc-001_chunk_2 | 2.2 贷款期限为36个月,从2026年1月15日起算。\n2.3 还款方式为等额本息... | 82 | P2 |
+| doc-001_chunk_3 | ...每月15日还款。\n\n第三章:违约责任\n任何一方违反本合同约定... | 88 | P2 |
+
+---
+
+#### 3.4.6 Java分段实现(核心代码结构)
+
+**TextSplitter.java**
+
+```java
+@Component
+public class TextSplitter {
+
+    // 分段配置
+    @Value("${chunk.max.length:500}")
+    private int maxChunkLength;
+
+    @Value("${chunk.min.length:100}")
+    private int minChunkLength;
+
+    @Value("${chunk.overlap:50}")
+    private int overlapSize;
+
+    /**
+     * 分段方法
+     */
+    public List<Chunk> split(String fullText, String docId) {
+        List<Chunk> chunks = new ArrayList<>();
+
+        // 1. 预处理
+        String text = preprocess(fullText);
+
+        // 2. 按段落切分
+        List<String> paragraphs = splitByParagraph(text);
+
+        // 3. 合并或切分段落
+        int chunkIndex = 0;
+        StringBuilder currentChunk = new StringBuilder();
+
+        for (String paragraph : paragraphs) {
+            if (currentChunk.length() + paragraph.length() > maxChunkLength) {
+                // 当前chunk已满,保存并新建
+                if (currentChunk.length() > 0) {
+                    chunks.add(createChunk(
+                        docId, chunkIndex++,
+                        currentChunk.toString()
+                    ));
+                }
+                currentChunk = new StringBuilder(paragraph);
+            } else {
+                currentChunk.append(paragraph).append("\n");
+            }
+        }
+
+        // 最后一个chunk
+        if (currentChunk.length() > 0) {
+            chunks.add(createChunk(docId, chunkIndex, currentChunk.toString()));
+        }
+
+        return chunks;
+    }
+
+    /**
+     * 预处理文本
+     */
+    private String preprocess(String text) {
+        return text
+            .replaceAll("\\r\\n", "\n")     // 统一换行符
+            .replaceAll("\\s+", " ")          // 多个空格合并
+            .trim();                          // 去除首尾空格
+    }
+
+    /**
+     * 按段落切分
+     */
+    private List<String> splitByParagraph(String text) {
+        return Arrays.asList(text.split("\\n\\s*\\n"));
+    }
+
+    /**
+     * 创建chunk对象
+     */
+    private Chunk createChunk(String docId, int index, String content) {
+        return Chunk.builder()
+            .docId(docId)
+            .chunkId(docId + "_chunk_" + index)
+            .chunkIndex(index)
+            .content(content)
+            .contentLength(content.length())
+            .build();
+    }
+}
+```
+
+**Chunk.java**
+
+```java
+@Data
+@Builder
+public class Chunk {
+    private String docId;           // 文档ID
+    private String chunkId;         // chunk唯一标识
+    private int chunkIndex;         // chunk序号
+    private String content;         // chunk内容
+    private int contentLength;      // 内容长度
+
+    // 业务数据(从全量文本中提取)
+    private Map<String, Object> metadata;
+}
+```
+
+---
+
+#### 3.4.7 完整的数据流转(包含分段)
+
+```
+【原始输入】
+完整合同文档PDF(5000字)
+    ↓
+【步骤1:文档解析】
+提取文本 → "永续贷产品贷款合同\n\n第一章:总则..."
+提取元数据 → 甲方:A公司,乙方:B公司,金额:100万
+    ↓
+【步骤2:文本分段】
+调用TextSplitter.split()
+    ↓
+生成10个chunk,每个约500字
+    ↓
+【步骤3:批量向量化】
+调用Embedding接口(批量10个)
+    ↓
+得到10个向量,每个4096维
+    ↓
+【步骤4:构建ES文档】
+为每个chunk添加:
+  - doc_id(相同)
+  - chunk_id(不同)
+  - content(不同)
+  - embedding(不同)
+  - 元数据(相同,冗余)
+    ↓
+【步骤5:批量入库】
+使用Bulk API一次性插入10个文档
+    ↓
+【步骤6:验证】
+查询doc_id,应返回10个chunk
+```
+
+---
+
+#### 3.4.8 准备示例文本(全量)
+
+**场景:** 上传完整合同文档
+
+```json
+{
+  "file_id": "wo1o23bn2oi3ngo3",
+  "file_name": "永续贷产品贷款合同.pdf",
+  "file_path": "/home/data/contracts/xxx.pdf",
+  "full_text": "永续贷产品贷款合同\n\n第一章:总则\n本合同由甲方A公司与乙方B公司签订,合同金额为100万元。\n本合同旨在规范双方的借贷行为,保护双方合法权益。\n\n第二章:贷款条款\n2.1 贷款利率按照央行基准利率执行。\n2.2 贷款期限为36个月,从2026年1月15日起算。\n2.3 还款方式为等额本息,每月15日还款。\n\n第三章:违约责任\n任何一方违反本合同约定,应承担相应的违约责任。",
+
+  // 提取的业务元数据
+  "metadata": {
+    "contract_type": "消费贷贷款合同",
+    "contract_name": "永续贷产品贷款合同",
+    "party_a": "A公司",
+    "party_b": "B公司",
+    "contract_amount": 1000000.00,
+    "sign_date": "2026-01-15"
+  }
+}
+```
+
+**经过分段处理后:**
+
+```json
+[
+  {
+    "doc_id": "wo1o23bn2oi3ngo3",
+    "chunk_id": "wo1o23bn2oi3ngo3_chunk_0",
+    "chunk_index": 0,
+    "content": "永续贷产品贷款合同\n\n第一章:总则\n本合同由甲方A公司与乙方B公司签订,合同金额为100万元。",
+    "metadata": {...}
+  },
+  {
+    "doc_id": "wo1o23bn2oi3ngo3",
+    "chunk_id": "wo1o23bn2oi3ngo3_chunk_1",
+    "chunk_index": 1,
+    "content": "本合同旨在规范双方的借贷行为,保护双方合法权益。\n\n第二章:贷款条款\n2.1 贷款利率按照央行基准利率执行。",
+    "metadata": {...}
+  },
+  // ... 更多chunk
+]
+```
+
+---
+
+#### 3.4.9 文本预处理(可选)
+
+```json
+[
+  {
+    "business_id": "doc-ai-001",
+    "content": "人工智能是计算机科学的一个分支,致力于创建能够执行通常需要人类智能的任务。包括语言理解、视觉感知、决策制定等。"
+  },
+  {
+    "business_id": "doc-ml-001",
+    "content": "机器学习是人工智能的核心技术之一,通过算法让计算机从数据中学习规律,做出预测或决策。"
+  },
+  {
+    "business_id": "doc-dl-001",
+    "content": "深度学习使用神经网络模拟人脑的学习过程,在图像识别、自然语言处理等领域取得突破。"
+  },
+  {
+    "business_id": "doc-nlp-001",
+    "content": "自然语言处理使计算机能够理解、解释和生成人类语言,应用于翻译、情感分析、智能客服等。"
+  }
+]
+```
+
+#### 3.4.2 文本预处理(可选)
+
+| 预处理项 | 操作 | 示例 |
+|----------|------|------|
+| 去除多余空格 | `text.trim()` | `"  文本  "` → `"文本"` |
+| 统一换行符 | 替换为空格 | `"文本1\n文本2"` → `"文本1 文本2"` |
+| 长度限制 | 截断或分批 | 超过8192字符时处理 |
+| HTML标签去除 | 正则替换 | `"<p>文本</p>"` → `"文本"` |
+
+---
+
+### 3.5 步骤3:文本向量化
+
+#### 3.5.1 调用Embedding接口(单个文本)
+
+```bash
+curl -X POST "http://10.192.72.11:18081/v1/embeddings" \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer 1" \
+  -d '{
+    "model": "Qwen3-Embedding-8B",
+    "input": "人工智能是计算机科学的一个分支"
+  }'
+```
+
+#### 3.5.2 返回的向量数据
+
+```json
+{
+  "object": "list",
+  "data": [{
+    "object": "embedding",
+    "index": 0,
+    "embedding": [
+      0.013597949407994747,
+      0.0037246558349579573,
+      0.008395255543291569,
+      -0.003488169750198722,
+      0.010582752525806427,
+      // ... 共4096个浮点数 ...
+      0.020219560712575912
+    ]
+  }],
+  "model": "Qwen3-Embedding-8B",
+  "usage": {
+    "prompt_tokens": 35,
+    "total_tokens": 35
+  }
+}
+```
+
+#### 3.5.3 向量化数据说明
+
+| 属性 | 值 | 说明 |
+|------|-----|------|
+| 向量维度 | 4096 | 固定维度 |
+| 数据类型 | 浮点数 | float类型 |
+| 取值范围 | -1 ~ 1 | 通常在-1到1之间 |
+| 向量数量 | 1个 | 单个文本返回1个向量 |
+
+#### 3.5.4 批量向量化(可选,提高效率)
+
+```bash
+curl -X POST "http://10.192.72.11:18081/v1/embeddings" \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer 1" \
+  -d '{
+    "model": "Qwen3-Embedding-8B",
+    "input": [
+      "人工智能是计算机科学的一个分支",
+      "机器学习是人工智能的核心技术",
+      "深度学习使用神经网络模拟人脑"
+    ]
+  }'
+```
+
+**返回:** 3个向量,每个4096维
+
+---
+
+### 3.6 步骤4:数据入库ES
+
+#### 3.6.1 插入单个文档
+
+```bash
+curl -X POST "http://10.192.72.13:9200/text_vectors/_doc/doc-ai-001" \
+  -H "Content-Type: application/json" \
+  -d '{
+  "content": "人工智能是计算机科学的一个分支,致力于创建能够执行通常需要人类智能的任务。包括语言理解、视觉感知、决策制定等。",
+  "embedding": [
+    0.013597949407994747,
+    0.0037246558349579573,
+    0.008395255543291569,
+    -0.003488169750198722,
+    0.010582752525806427,
+    0.015623456789012345,
+    -0.043223456789012345,
+    0.067823456789012345,
+    0.023423456789012345,
+    -0.056723456789012345,
+    // ... 共4096个数字 ...
+    0.020219560712575912
+  ],
+  "business_id": "doc-ai-001",
+  "content_length": 68,
+  "create_time": "2026-03-12T10:30:00"
+}'
+```
+
+#### 3.6.2 预期返回
+
+```json
+{
+  "_index": "text_vectors",
+  "_id": "doc-ai-001",
+  "_version": 1,
+  "result": "created",
+  "_shards": {
+    "total": 1,
+    "successful": 1,
+    "failed": 0
+  },
+  "_seq_no": 0,
+  "_primary_term": 1
+}
+```
+
+#### 3.6.3 批量插入文档(Bulk API)
+
+```bash
+curl -X POST "http://10.192.72.13:9200/_bulk" \
+  -H "Content-Type: application/json" \
+  -d '
+{"index": {"_index": "text_vectors", "_id": "doc-ml-001"}}
+{"content": "机器学习是人工智能的核心技术之一,通过算法让计算机从数据中学习规律。", "embedding": [0.0256, -0.0134, ...], "business_id": "doc-ml-001", "content_length": 45, "create_time": "2026-03-12T10:31:00"}
+
+{"index": {"_index": "text_vectors", "_id": "doc-dl-001"}}
+{"content": "深度学习使用神经网络模拟人脑的学习过程,在图像识别、自然语言处理等领域取得突破。", "embedding": [0.0221, -0.0112, ...], "business_id": "doc-dl-001", "content_length": 52, "create_time": "2026-03-12T10:32:00"}
+
+{"index": {"_index": "text_vectors", "_id": "doc-nlp-001"}}
+{"content": "自然语言处理使计算机能够理解、解释和生成人类语言,应用于翻译、情感分析、智能客服等。", "embedding": [0.0198, -0.0098, ...], "business_id": "doc-nlp-001", "content_length": 48, "create_time": "2026-03-12T10:33:00"}
+'
+```
+
+#### 3.6.4 批量插入返回
+
+```json
+{
+  "took": 45,
+  "errors": false,
+  "items": [
+    {"index": {"_id": "doc-ml-001", "result": "created"}},
+    {"index": {"_id": "doc-dl-001", "result": "created"}},
+    {"index": {"_id": "doc-nlp-001", "result": "created"}}
+  ]
+}
+```
+
+#### 3.6.5 验证数据入库成功
+
+```bash
+# 查看文档数量
+curl -X GET "http://10.192.72.13:9200/_cat/count/text_vectors?v"
+
+# 查看具体文档
+curl -X GET "http://10.192.72.13:9200/text_vectors/_doc/doc-ai-001?pretty"
+```
+
+---
+
+### 3.7 步骤5:向量搜索(KNN查询)
+
+#### 3.7.1 准备查询文本
+
+```
+用户查询:"什么是AI技术"
+```
+
+#### 3.7.2 查询文本向量化
+
+```bash
+curl -X POST "http://10.192.72.11:18081/v1/embeddings" \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer 1" \
+  -d '{
+    "model": "Qwen3-Embedding-8B",
+    "input": "什么是AI技术"
+  }'
+```
+
+**得到查询向量:** `[0.0145, 0.0045, 0.0098, ...]` (4096维)
+
+#### 3.7.3 执行KNN搜索
+
+```bash
+curl -X POST "http://10.192.72.13:9200/text_vectors/_search" \
+  -H "Content-Type: application/json" \
+  -d '{
+  "knn": {
+    "field": "embedding",
+    "query_vector": [
+      0.0145, 0.0045, 0.0098, -0.0042, 0.0115,
+      0.0168, -0.0456, 0.0698, 0.0256, -0.0589,
+      // ... 共4096个数字 ...
+      0.0219
+    ],
+    "k": 3,
+    "num_candidates": 10
+  },
+  "fields": ["content", "business_id", "_score"],
+  "_source": false
+}'
+```
+
+#### 3.7.4 搜索结果
+
+```json
+{
+  "took": 25,
+  "hits": {
+    "total": {
+      "value": 3,
+      "relation": "eq"
+    },
+    "hits": [
+      {
+        "_id": "doc-ai-001",
+        "_score": 0.9234,
+        "fields": {
+          "content": ["人工智能是计算机科学的一个分支..."],
+          "business_id": ["doc-ai-001"]
+        }
+      },
+      {
+        "_id": "doc-ml-001",
+        "_score": 0.8891,
+        "fields": {
+          "content": ["机器学习是人工智能的核心技术..."],
+          "business_id": ["doc-ml-001"]
+        }
+      },
+      {
+        "_id": "doc-dl-001",
+        "_score": 0.8756,
+        "fields": {
+          "content": ["深度学习使用神经网络模拟人脑..."],
+          "business_id": ["doc-dl-001"]
+        }
+      }
+    ]
+  }
+}
+```
+
+#### 3.7.5 结果说明
+
+| 排名 | 文档ID | 相似度分数 | 内容摘要 |
+|------|--------|-----------|---------|
+| 🥇 1 | doc-ai-001 | 0.9234 | 人工智能是计算机科学的一个分支... |
+| 🥈 2 | doc-ml-001 | 0.8891 | 机器学习是人工智能的核心技术... |
+| 🥉 3 | doc-dl-001 | 0.8756 | 深度学习使用神经网络模拟人脑... |
+
+**说明:**
+- 分数范围:0~1,越接近1越相似
+- 0.9+:非常相似
+- 0.8+:很相似
+- 0.5以下:不相关
+
+---
+
+### 3.8 步骤6:验证与调试
+
+#### 3.8.1 验证索引状态
+
+```bash
+# 查看索引信息
+curl -X GET "http://10.192.72.13:9200/text_vectors?pretty"
+
+# 查看文档数量
+curl -X GET "http://10.192.72.13:9200/_cat/count/text_vectors?v"
+```
+
+#### 3.8.2 查看已入库文档
+
+```bash
+# 查看所有文档
+curl -X GET "http://10.192.72.13:9200/text_vectors/_search?pretty&size=10"
+
+# 查看特定文档
+curl -X GET "http://10.192.72.13:9200/text_vectors/_doc/doc-ai-001?pretty"
+
+# 验证向量维度
+curl -X POST "http://10.192.72.13:9200/text_vectors/_search" \
+  -H "Content-Type: application/json" \
+  -d '{
+  "script_fields": {
+    "vector_dimension": {
+      "script": {
+        "source": "doc['embedding'].size()"
+      }
+    }
+  },
+  "size": 1
+}'
+```
+
+#### 3.8.3 常见问题排查
+
+| 问题 | 检查方法 | 解决方案 |
+|------|---------|---------|
+| 索引不存在 | `GET /text_vectors` | 执行步骤1创建索引 |
+| 向量维度错误 | 检查返回的向量长度 | 确保4096维 |
+| 搜索无结果 | 检查文档是否入库 | 执行验证命令 |
+| 相似度分数异常 | 检查向量是否归一化 | 确认ES配置 |
+
+---
+
+### 3.9 完整操作检查清单
+
+| 步骤 | 操作 | 命令/验证 | 状态 |
+|------|------|----------|------|
+| ✅ 0 | 环境准备 | curl检查服务 | [ ] |
+| ✅ 1 | 创建ES索引 | PUT /text_vectors | [ ] |
+| ✅ 2 | 准备文本数据 | 准备JSON数组 | [ ] |
+| ✅ 3 | 文本向量化 | POST /embeddings | [ ] |
+| ✅ 4 | 数据入库ES | POST /text_vectors/_doc | [ ] |
+| ✅ 5 | 向量搜索 | POST /text_vectors/_search | [ ] |
+| ✅ 6 | 验证结果 | 检查返回数据 | [ ] |
+
+---
+
+## 四、完整示例(从文本到搜索)
+
+### 4.1 场景说明
+
+假设我们有一个关于"人工智能"的文档库,需要实现:
+1. 将文档向量化并入库ES
+2. 根据用户查询找到最相似的文档
+
+### 4.2 数据流转示例
+
+#### 步骤1️⃣:文本入库
+
+| 项目 | 值 |
+|------|-----|
+| **输入文本** | "人工智能是计算机科学的一个分支,致力于创建能够执行通常需要人类智能的任务。包括语言理解、视觉感知、决策制定等。" |
+| **业务ID** | doc-ai-001 |
+
+#### 步骤2️⃣:Embedding向量化
+
+| 项目 | 值 |
+|------|-----|
+| **输入文本** | "人工智能是计算机科学的一个分支,致力于创建能够执行通常需要人类智能的任务。包括语言理解、视觉感知、决策制定等。" |
+| **业务ID** | doc-ai-001 |
+
+#### 步骤2️⃣:Embedding向量化
+
+| 项目 | 说明 |
+|------|------|
+| **调用接口** | POST `http://10.192.72.11:18081/v1/embeddings` |
+| **输入** | 文本字符串 |
+| **输出** | 4096维浮点数数组(简化展示) |
+| **向量示例** | `[0.0234, -0.0123, 0.0456, 0.0789, -0.0321, ..., 共4096个]` |
+
+**本质理解:**
+> 把这段话翻译成4096个数字,作为这段话的"数学坐标"
+
+#### 步骤3️⃣:存储到ES
+
+| 字段名 | 值 | 说明 |
+|--------|-----|------|
+| `content` | "人工智能是计算机科学的一个分支..." | 原始文本 |
+| `embedding` | [0.0234, -0.0123, ..., 共4096个] | 4096维向量 |
+| `business_id` | doc-ai-001 | 业务唯一标识(作为ES文档ID) |
+| `create_time` | 2026-03-12T10:30:00 | 创建时间 |
+
+#### 步骤4️⃣:再入库几篇文档(用于对比)
+
+| 文档ID | 内容摘要 | 向量(简化) |
+|--------|----------|-------------|
+| doc-ml-001 | "机器学习是人工智能的核心技术之一..." | `[0.0256, -0.0134, ...]` ✅ 与AI相关,向量接近 |
+| doc-life-001 | "今天天气很好,我去公园散步了。" | `[-0.1123, 0.0456, ...]` ❌ 与AI无关,向量差异大 |
+| doc-dl-001 | "深度学习使用神经网络模拟人脑..." | `[0.0221, -0.0112, ...]` ✅ 与AI相关,向量接近 |
+
+#### 步骤5️⃣:用户搜索
+
+| 项目 | 值 |
+|------|-----|
+| **查询文本** | "什么是AI技术" |
+| **Top-K** | 3 |
+
+#### 步骤6️⃣:查询向量化
+
+| 项目 | 说明 |
+|------|------|
+| **查询文本** | "什么是AI技术" |
+| **查询向量** | `[0.0245, -0.0118, 0.0445, ..., 共4096个]` |
+
+#### 步骤7️⃣:KNN相似度计算
+
+| 对比文档 | 余弦相似度 | 相似程度 |
+|----------|-----------|---------|
+| doc-ai-001 (人工智能...) | **0.92** | ⭐⭐⭐⭐⭐ 最相似 |
+| doc-ml-001 (机器学习...) | **0.89** | ⭐⭐⭐⭐ 很相似 |
+| doc-life-001 (天气...) | **0.23** | ❌ 不相关 |
+| doc-dl-001 (深度学习...) | **0.87** | ⭐⭐⭐⭐ 很相似 |
+
+#### 步骤8️⃣:返回搜索结果
+
+| 排名 | 文档ID | 内容摘要 | 相似度分数 |
+|------|--------|----------|-----------|
+| 🥇 1 | doc-ai-001 | 人工智能是计算机科学的一个分支... | 0.92 |
+| 🥈 2 | doc-ml-001 | 机器学习是人工智能的核心技术... | 0.89 |
+| 🥉 3 | doc-dl-001 | 深度学习使用神经网络模拟人脑... | 0.87 |
+
+### 3.3 核心概念表格总结
+
+| 阶段 | 输入 | 输出 | 本质 |
+|------|------|------|------|
+| **向量化** | 文本:"人工智能技术..." | 4096个数字:`[0.0234, -0.0123, ...]` | 文本 → 数学坐标 |
+| **入库** | 文本 + 向量 | ES文档 | 存储到数据库 |
+| **搜索** | 查询:"什么是AI" | 相似文档列表 | 找向量最近的点 |
+
+### 3.4 类比理解
+
+```
+想象一个4096维的空间:
+
+📍 "人工智能技术发展"  →  坐标点A [0.02, 0.03, ...]
+📍 "AI技术进展"       →  坐标点B [0.021, 0.029, ...]
+   两点距离很近 ✅ 相似度高
+
+📍 "人工智能技术发展"  →  坐标点A [0.02, 0.03, ...]
+📍 "今天天气不错"     →  坐标点C [-0.1, 0.08, ...]
+   两点距离很远 ❌ 相似度低
+```
+
+---
+
+## 五、融合设计方案(企业级最佳实践)
+
+### 5.1 设计理念
+
+对于企业级应用,特别是RAG(检索增强生成)场景,推荐采用**融合设计(宽表设计)**,将元数据、业务数据、向量字段存储在同一个索引中。
+
+#### 5.1.1 核心思想
+
+```
+传统方案(JOIN)                    融合设计(宽表)
+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+索引1: chunks                      索引: contract_chunks
+├─ content                        ├─ doc_id
+├─ embedding                      ├─ chunk_id
+└─ file_id (FK)  ─────────┐       ├─ content
+                              ├─ embedding
+索引2: files             ┌────┘  ├─ contract_type
+├─ file_id               │       ├─ contract_name
+├─ file_path             │       ├─ party_a
+├─ contract_type ────────┘       ├─ party_b
+└─ ...                           ├─ file_path
+                                 └─ ...
+
+查询需要JOIN                    查询一次搞定 ✅
+```
+
+#### 5.1.2 设计优势
+
+| 优势 | 说明 | 评分 |
+|------|------|------|
+| **避免JOIN** | ES不支持JOIN,融合设计避免了应用层关联 | ⭐⭐⭐⭐⭐ |
+| **查询性能** | 一次查询返回所有数据,无需多次查询 | ⭐⭐⭐⭐⭐ |
+| **混合过滤** | 支持向量搜索 + 业务字段过滤 | ⭐⭐⭐⭐⭐ |
+| **文件级操作** | 通过doc_id可以操作文件的所有chunk | ⭐⭐⭐⭐⭐ |
+| **扩展性** | 易于添加新字段,无需修改表结构 | ⭐⭐⭐⭐ |
+
+---
+
+### 5.2 完整Mapping设计(合同文档示例)
+
+#### 5.2.1 索引结构
+
+```json
+PUT /contract_chunks
+{
+  "settings": {
+    "number_of_shards": 3,
+    "number_of_replicas": 1
+  },
+  "mappings": {
+    "properties": {
+      // ========== 1. 文档标识 ==========
+      "doc_id": {
+        "type": "keyword",
+        "description": "文件ID(与USD文件ID保持一致)"
+      },
+      "chunk_id": {
+        "type": "keyword",
+        "description": "chunk唯一标识:docId + chunk序号"
+      },
+      "chunk_index": {
+        "type": "integer",
+        "description": "chunk在文档中的序号(0-based)"
+      },
+
+      // ========== 2. 内容和向量 ==========
+      "content": {
+        "type": "text",
+        "analyzer": "ik_max_word",
+        "description": "chunk的文本内容"
+      },
+      "embedding": {
+        "type": "dense_vector",
+        "dims": 4096,
+        "index": true,
+        "similarity": "cosine",
+        "description": "4096维向量"
+      },
+
+      // ========== 3. 文件元数据 ==========
+      "file_path": {
+        "type": "keyword",
+        "description": "文件存储路径"
+      },
+      "file_size": {
+        "type": "long",
+        "description": "文件大小(字节)"
+      },
+      "file_size_mb": {
+        "type": "float",
+        "description": "文件大小(MB)"
+      },
+      "file_type": {
+        "type": "keyword",
+        "description": "文件类型(pdf/docx/txt)"
+      },
+      "page_count": {
+        "type": "integer",
+        "description": "文档总页数"
+      },
+      "chunk_count": {
+        "type": "integer",
+        "description": "文档总chunk数"
+      },
+
+      // ========== 4. 业务数据(合同相关) ==========
+      "contract_type": {
+        "type": "keyword",
+        "description": "合同类型(消费贷/企业贷等)"
+      },
+      "contract_name": {
+        "type": "text",
+        "fields": {
+          "keyword": {
+            "type": "keyword",
+            "ignore_above": 256
+          }
+        },
+        "description": "合同名称"
+      },
+      "party_a": {
+        "type": "text",
+        "fields": {
+          "keyword": {
+            "type": "keyword"
+          }
+        },
+        "description": "合同主体甲方"
+      },
+      "party_b": {
+        "type": "text",
+        "fields": {
+          "keyword": {
+            "type": "keyword"
+          }
+        },
+        "description": "合同主体乙方"
+      },
+      "contract_amount": {
+        "type": "double",
+        "description": "合同金额"
+      },
+      "sign_date": {
+        "type": "date",
+        "format": "yyyy-MM-dd",
+        "description": "签署日期"
+      },
+      "effective_date": {
+        "type": "date",
+        "format": "yyyy-MM-dd",
+        "description": "生效日期"
+      },
+
+      // ========== 5. 标签(便于过滤) ==========
+      "tags": {
+        "type": "keyword",
+        "description": "业务标签"
+      },
+      "department": {
+        "type": "keyword",
+        "description": "所属部门"
+      },
+      "status": {
+        "type": "keyword",
+        "description": "合同状态"
+      },
+      "risk_level": {
+        "type": "keyword",
+        "description": "风险等级"
+      },
+
+      // ========== 6. 时间字段 ==========
+      "create_time": {
+        "type": "date",
+        "format": "yyyy-MM-dd'T'HH:mm:ss",
+        "description": "创建时间"
+      },
+      "update_time": {
+        "type": "date",
+        "format": "yyyy-MM-dd'T'HH:mm:ss",
+        "description": "更新时间"
+      }
+    }
+  }
+}
+```
+
+---
+
+### 5.3 数据示例
+
+#### 5.3.1 同一文件的多个Chunk
+
+| 字段分类 | Chunk 0 | Chunk 1 | Chunk 2 | 说明 |
+|---------|---------|---------|---------|------|
+| **标识字段** | | | | |
+| doc_id | wo1o23bn2oi3ngo3 | wo1o23bn2oi3ngo3 | wo1o23bn2oi3ngo3 | ✅ 相同 |
+| chunk_id | wo1o23bn2oi3ngo3_0 | wo1o23bn2oi3ngo3_1 | wo1o23bn2oi3ngo3_2 | ❌ 不同 |
+| chunk_index | 0 | 1 | 2 | ❌ 不同 |
+| **内容字段** | | | | |
+| content | "永续贷产品贷款合同..." | "第二条,贷款利率..." | "第三条,还款方式..." | ❌ 不同 |
+| embedding | [向量1] | [向量2] | [向量3] | ❌ 不同 |
+| **元数据(冗余)** | | | | |
+| file_path | /home/data/xxx.pdf | /home/data/xxx.pdf | /home/data/xxx.pdf | ✅ 相同 |
+| file_size_mb | 1.23 | 1.23 | 1.23 | ✅ 相同 |
+| page_count | 10 | 10 | 10 | ✅ 相同 |
+| **业务数据(冗余)** | | | | |
+| contract_type | 消费贷 | 消费贷 | 消费贷 | ✅ 相同 |
+| party_a | A公司 | A公司 | A公司 | ✅ 相同 |
+| party_b | B公司 | B公司 | B公司 | ✅ 相同 |
+| contract_amount | 1000000 | 1000000 | 1000000 | ✅ 相同 |
+
+#### 5.3.2 完整的JSON示例
+
+```json
+{
+  "_id": "wo1o23bn2oi3ngo3_chunk_0",
+  "_source": {
+    "doc_id": "wo1o23bn2oi3ngo3",
+    "chunk_id": "wo1o23bn2oi3ngo3_chunk_0",
+    "chunk_index": 0,
+
+    "content": "永续贷产品贷款合同,甲方A公司...",
+    "embedding": [
+      0.0234, -0.0123, 0.0456, 0.0789, -0.0321,
+      // ... 共4096个 ...
+      0.0202
+    ],
+
+    "file_path": "/home/data/contracts/永续贷产品.pdf",
+    "file_size": 1289748,
+    "file_size_mb": 1.23,
+    "file_type": "pdf",
+    "page_count": 10,
+    "chunk_count": 5,
+
+    "contract_type": "消费贷贷款合同",
+    "contract_name": "永续贷产品贷款合同",
+    "party_a": "A公司",
+    "party_b": "B公司",
+    "contract_amount": 1000000.00,
+    "sign_date": "2026-01-15",
+    "effective_date": "2026-01-20",
+
+    "tags": ["重要", "长期合作"],
+    "department": "信贷部",
+    "status": "执行中",
+    "risk_level": "低",
+
+    "create_time": "2026-03-12T10:30:00",
+    "update_time": "2026-03-12T10:30:00"
+  }
+}
+```
+
+---
+
+### 5.4 核心操作
+
+#### 5.4.1 混合查询(向量搜索 + 业务过滤)
+
+```bash
+POST /contract_chunks/_search
+{
+  "query": {
+    "bool": {
+      "must": [
+        {
+          "knn": {
+            "field": "embedding",
+            "query_vector": [0.0145, 0.0045, ..., 共4096个],
+            "k": 10,
+            "num_candidates": 50
+          }
+        }
+      ],
+      "filter": [
+        {"term": {"contract_type": "消费贷贷款合同"}},
+        {"term": {"status": "执行中"}},
+        {"term": {"party_a.keyword": "A公司"}},
+        {"range": {"contract_amount": {"gte": 500000}}},
+        {"range": {"sign_date": {"gte": "2026-01-01"}}}
+      ]
+    }
+  },
+  "_source": ["content", "contract_name", "party_a", "party_b"],
+  "size": 10
+}
+```
+
+**返回结果:** 既相似又满足业务条件的chunk
+
+#### 5.4.2 文件级别操作
+
+```bash
+# 1. 查询某个文件的所有chunk
+POST /contract_chunks/_search
+{
+  "query": {
+    "term": {"doc_id": "wo1o23bn2oi3ngo3"}
+  },
+  "sort": [{"chunk_index": {"order": "asc"}}]
+}
+
+# 2. 删除某个文件的所有chunk
+POST /contract_chunks/_delete_by_query
+{
+  "query": {
+    "term": {"doc_id": "wo1o23bn2oi3ngo3"}
+  }
+}
+
+# 3. 更新某个文件的所有chunk(合同信息变更)
+POST /contract_chunks/_update_by_query
+{
+  "query": {
+    "term": {"doc_id": "wo1o23bn2oi3ngo3"}
+  },
+  "script": {
+    "source": """
+      ctx._source.contract_name = params.contract_name;
+      ctx._source.status = params.status;
+      ctx._source.update_time = params.update_time;
+    """,
+    "lang": "painless",
+    "params": {
+      "contract_name": "新的合同名称",
+      "status": "已变更",
+      "update_time": "2026-03-12T15:00:00"
+    }
+  }
+}
+
+# 4. 统计某个文件的chunk数量
+POST /contract_chunks/_count
+{
+  "query": {
+    "term": {"doc_id": "wo1o23bn2oi3ngo3"}
+  }
+}
+```
+
+---
+
+### 5.5 数据流转
+
+#### 5.5.1 入库流程
+
+```
+PDF文件上传
+    ↓
+文档解析(提取文本、识别页数)
+    ↓
+文本分段(按页/按段落,生成5个chunk)
+    ↓
+提取业务数据(合同类型、甲方、乙方等)
+    ↓
+批量向量化(调用Embedding接口,5个向量)
+    ↓
+批量入库ES(5个文档,每个包含完整的业务数据)
+    ↓
+验证入库(查询doc_id,应返回5个chunk)
+```
+
+#### 5.5.2 搜索流程
+
+```
+用户查询:"A公司的消费贷合同利率是多少"
+    ↓
+1. 查询文本向量化
+    ↓
+2. ES混合查询
+   - KNN向量搜索(相似度)
+   - 过滤条件:contract_type="消费贷" AND party_a="A公司"
+    ↓
+3. 返回Top-K chunk(按相似度排序)
+    ↓
+4. 应用层处理
+   - 按doc_id分组(避免同一文件多个chunk)
+   - 提取最相关的chunk内容
+   - 返回给用户
+```
+
+---
+
+### 5.6 设计权衡
+
+#### 5.6.1 优势分析
+
+| 方面 | 传统设计 | 融合设计 | 提升 |
+|------|---------|---------|------|
+| **查询性能** | 需要2次查询+JOIN | 1次查询搞定 | 50%+ |
+| **应用层复杂度** | 需要组装数据 | 直接返回完整数据 | -80% |
+| **混合查询** | 难以实现 | 原生支持 | ✅ |
+| **文件级操作** | 需要多次操作 | 批量操作 | ✅ |
+
+#### 5.6.3 需要注意的问题
+
+| 问题 | 影响 | 解决方案 |
+|------|------|---------|
+| **数据冗余** | 存储空间增加约30% | 定期归档,只保留必要字段 |
+| **更新成本** | 文件变更需更新所有chunk | 使用`_update_by_query`批量更新 |
+| **单文档大小** | 单个文档较大 | 使用`_source`过滤返回字段 |
+
+#### 5.6.3 适用场景
+
+| 场景 | 是否推荐 | 原因 |
+|------|---------|------|
+| ✅ RAG检索 | 强烈推荐 | 需要向量搜索+元数据过滤 |
+| ✅ 多段落文档 | 强烈推荐 | 需要chunk级别的检索 |
+| ✅ 视频/音频 | 强烈推荐 | 需要时间戳+内容检索 |
+| ✅ 企业知识库 | 强烈推荐 | 需要复杂的业务过滤 |
+| ❌ 简单全文搜索 | 不推荐 | 冗余数据浪费空间 |
+
+---
+
+### 5.7 扩展设计
+
+#### 5.7.1 支持多种文档类型
+
+```json
+// 根据document_type字段区分不同类型的业务字段
+{
+  "doc_id": "xxx",
+  "document_type": "contract",  // 或 invoice、report等
+
+  // 通用字段
+  "content": "...",
+  "embedding": [...],
+  "file_path": "...",
+
+  // 合同专用字段
+  "contract_type": "...",
+  "party_a": "...",
+
+  // 发票专用字段(可能为空)
+  "invoice_number": null,
+  "invoice_amount": null
+}
+```
+
+#### 5.7.2 版本管理
+
+```json
+{
+  "doc_id": "xxx",
+  "version": 2,
+  "is_latest": true,
+
+  // 支持查询历史版本
+  "content": "更新后的内容...",
+  "embedding": [新向量]
+}
+```
+
+---
+
+### 5.8 最佳实践总结
+
+| 实践项 | 建议 | 说明 |
+|--------|------|------|
+| **ID设计** | doc_id + chunk_id | 支持文件级操作 |
+| **字段选择** | 只存储必要的业务字段 | 减少冗余 |
+| **更新策略** | 批量更新所有chunk | 保证数据一致性 |
+| **查询优化** | 使用filter替代must | 提高性能 |
+| **分页处理** | 使用search_after | 大数据量友好 |
+
+---
+
+## 六、Java实现方案(JDK1.8 + Spring Boot)
+
+### 6.1 模块结构设计
+
+#### 6.1.1 Maven多模块结构
+
+**完整项目结构:**
+
+```xml
+<project>
+    <modules>
+        <!-- 现有模块 -->
+        <module>schedule-producer</module>
+        <module>schedule-consumer</module>
+        <module>schedule-manager</module>
+        <module>schedule-monitor</module>
+
+        <!-- 新增模块:文本向量化与检索API -->
+        <module>schedule-embedding-api</module>
+    </modules>
+</project>
+```
+
+---
+
+#### 6.1.2 schedule-embedding-api 模块结构
+
+**完整目录结构:**
+
+```
+schedule-embedding-api/
+├── pom.xml                                    Maven配置
+│
+├── src/main/java/com/schedule/embedding/
+│   ├── ScheduleEmbeddingApplication.java    启动类
+│   │
+│   ├── controller/                           控制器层(REST API)
+│   │   ├── TextEmbeddingController.java      文本入库API
+│   │   ├── VectorSearchController.java       向量搜索API
+│   │   └── DocumentController.java           文档管理API
+│   │
+│   ├── service/                              服务层(业务逻辑)
+│   │   ├── TextEmbeddingService.java        文本向量化服务
+│   │   ├── VectorSearchService.java         向量搜索服务
+│   │   ├── TextSplitter.java                文本分段器
+│   │   └── DocumentService.java             文档管理服务
+│   │
+│   ├── client/                               外部接口客户端
+│   │   └── EmbeddingClient.java             Embedding API客户端
+│   │
+│   ├── repository/                           ES操作层
+│   │   └── ElasticsearchRepository.java     ES封装
+│   │
+│   ├── model/                                数据模型
+│   │   ├── dto/
+│   │   │   ├── IndexRequest.java            入库请求DTO
+│   │   │   ├── BatchIndexRequest.java       批量入库请求DTO
+│   │   │   ├── SearchRequest.java           搜索请求DTO
+│   │   │   └── SearchResult.java            搜索结果DTO
+│   │   ├── entity/
+│   │   │   ├── TextDocument.java            文档实体
+│   │   │   └── Chunk.java                   分段实体
+│   │   └── vo/
+│   │       ├── IndexResult.java             入库结果VO
+│   │       └── BatchIndexResult.java        批量入库结果VO
+│   │
+│   ├── config/                               配置类
+│   │   ├── ElasticsearchConfig.java         ES配置
+│   │   ├── EmbeddingConfig.java             Embedding配置
+│   │   └── ChunkConfig.java                 分段配置
+│   │
+│   └── exception/                            异常处理
+│       ├── EmbeddingException.java          向量化异常
+│       └── ElasticsearchException.java      ES操作异常
+│
+├── src/main/resources/
+│   ├── application.yml                       配置文件
+│   └── logback-spring.xml                    日志配置
+│
+└── src/test/java/                            测试代码
+    └── com/schedule/embedding/
+        └── service/
+            └── TextEmbeddingServiceTest.java
+```
+
+---
+
+#### 6.1.3 pom.xml 配置
+
+```xml
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
+         http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>com.schedule</groupId>
+        <artifactId>schedule-parent</artifactId>
+        <version>1.0.0</version>
+    </parent>
+
+    <artifactId>schedule-embedding-api</artifactId>
+    <name>schedule-embedding-api</name>
+    <description>文本向量化与向量检索服务</description>
+
+    <properties>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+        <java.version>1.8</java.version>
+    </properties>
+
+    <dependencies>
+        <!-- Spring Boot Web -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+
+        <!-- Spring Boot Validation -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-validation</artifactId>
+        </dependency>
+
+        <!-- Elasticsearch Rest High Level Client -->
+        <dependency>
+            <groupId>org.elasticsearch.client</groupId>
+            <artifactId>elasticsearch-rest-high-level-client</artifactId>
+            <version>7.17.15</version>
+        </dependency>
+
+        <!-- HTTP客户端(调用Embedding接口) -->
+        <dependency>
+            <groupId>com.squareup.okhttp3</groupId>
+            <artifactId>okhttp</artifactId>
+            <version>4.9.3</version>
+        </dependency>
+
+        <!-- JSON处理 -->
+        <dependency>
+            <groupId>com.fasterxml.jackson.core</groupId>
+            <artifactId>jackson-databind</artifactId>
+        </dependency>
+
+        <!-- Lombok(减少样板代码) -->
+        <dependency>
+            <groupId>org.projectlombok</groupId>
+            <artifactId>lombok</artifactId>
+            <optional>true</optional>
+        </dependency>
+
+        <!-- Apache Commons(工具类) -->
+        <dependency>
+            <groupId>org.apache.commons</groupId>
+            <artifactId>commons-lang3</artifactId>
+        </dependency>
+
+        <!-- 测试依赖 -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+            </plugin>
+        </plugins>
+    </build>
+</project>
+```
+
+---
+
+#### 6.1.4 与其他模块的交互关系
+
+**模块交互图:**
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│                        schedule-producer                          │
+│                      文档上传任务生产者                              │
+└──────────────────────────────┬──────────────────────────────────┘
+                               │ 发送文档入库消息
+                               ↓
+┌─────────────────────────────────────────────────────────────────┐
+│                       schedule-consumer                           │
+│                       文档入库任务消费者                              │
+└──────────────────────────────┬──────────────────────────────────┘
+                               │ 调用入库API
+                               ↓
+┌─────────────────────────────────────────────────────────────────┐
+│                   schedule-embedding-api                          │
+│                  文本向量化与向量检索API服务                          │
+│  ┌─────────────────────────────────────────────────────────────┐ │
+│  │ 1. 文本分段(TextSplitter)                                 │ │
+│  │ 2. 向量化(EmbeddingClient → Embedding接口)               │ │
+│  │ 3. 入库ES(ElasticsearchRepository)                         │ │
+│  │ 4. 向量搜索(KNN查询)                                       │ │
+│  └─────────────────────────────────────────────────────────────┘ │
+└──────────────────────────────┬──────────────────────────────────┘
+                               │
+                ┌──────────────┴──────────────┐
+                ↓                             ↓
+┌───────────────────────┐  ┌─────────────────────────┐
+│   Embedding接口        │  │    Elasticsearch         │
+│   http://10.192.72.11  │  │    http://10.192.72.13   │
+│      :18081/v1         │  │         :9200            │
+└───────────────────────┘  └─────────────────────────┘
+                ↑                             ↑
+                │ 监控                        │ 监控
+                └──────────────┬──────────────┘
+                               ↓
+┌─────────────────────────────────────────────────────────────────┐
+│                      schedule-monitor                             │
+│                      监控告警服务                                   │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+---
+
+#### 6.1.5 核心功能模块
+
+| 功能模块 | 类名 | 职责 |
+|---------|------|------|
+| **文本分段** | TextSplitter | 将全量文本分段为chunks |
+| **向量化** | EmbeddingClient | 调用Embedding接口获取向量 |
+| **入库服务** | TextEmbeddingService | 协调分段、向量化、入库流程 |
+| **ES操作** | ElasticsearchRepository | 封装ES操作(索引、查询、删除) |
+| **向量搜索** | VectorSearchService | KNN向量搜索服务 |
+| **文档管理** | DocumentService | 文档级别的CRUD操作 |
+
+---
+
+#### 6.1.6 API接口设计
+
+**RESTful API列表:**
+
+| 功能 | HTTP方法 | 路径 | 说明 |
+|------|---------|------|------|
+| **单个文档入库** | POST | /api/v1/documents/index | 上传完整文档,自动分段入库 |
+| **批量文档入库** | POST | /api/v1/documents/batch-index | 批量上传文档 |
+| **向量搜索** | POST | /api/v1/search | 向量相似度搜索 |
+| **混合搜索** | POST | /api/v1/search/hybrid | 向量+业务条件过滤 |
+| **查询文档** | GET | /api/v1/documents/{docId} | 查询文档的所有chunks |
+| **删除文档** | DELETE | /api/v1/documents/{docId} | 删除文档的所有chunks |
+| **更新文档** | PUT | /api/v1/documents/{docId} | 更新文档的所有chunks |
+| **健康检查** | GET | /actuator/health | 服务健康状态 |
+
+**API示例:**
+
+```bash
+# 1. 单个文档入库
+POST http://localhost:8080/api/v1/documents/index
+Content-Type: application/json
+
+{
+  "docId": "wo1o23bn2oi3ngo3",
+  "fileName": "永续贷产品贷款合同.pdf",
+  "fullText": "永续贷产品贷款合同\n\n第一章:总则...",
+  "metadata": {
+    "contractType": "消费贷贷款合同",
+    "partyA": "A公司",
+    "partyB": "B公司",
+    "contractAmount": 1000000.00
+  }
+}
+
+# 2. 向量搜索
+POST http://localhost:8080/api/v1/search
+Content-Type: application/json
+
+{
+  "query": "A公司的消费贷合同利率是多少",
+  "topK": 5,
+  "filters": {
+    "contractType": "消费贷贷款合同",
+    "partyA": "A公司",
+    "status": "执行中"
+  }
+}
+
+# 3. 查询文档的所有chunks
+GET http://localhost:8080/api/v1/documents/wo1o23bn2oi3ngo3?sortBy=chunkIndex&order=asc
+
+# 4. 删除文档(删除所有chunks)
+DELETE http://localhost:8080/api/v1/documents/wo1o23bn2oi3ngo3
+```
+
+---
+
+#### 6.1.7 application.yml 配置
+
+```yaml
+server:
+  port: 8080
+  servlet:
+    context-path: /
+
+spring:
+  application:
+    name: schedule-embedding-api
+
+# ========== Embedding接口配置 ==========
+embedding:
+  api:
+    url: http://10.192.72.11:18081/v1
+    key: 1
+    model: Qwen3-Embedding-8B
+    timeout: 30000
+  batch:
+    size: 128
+
+# ========== 文本分段配置 ==========
+chunk:
+  max-length: 500              # 最大chunk长度(字)
+  min-length: 100              # 最小chunk长度(字)
+  overlap: 50                  # 重叠大小(字)
+  mode: paragraph              # 分段模式:paragraph/fixed/semantic
+
+# ========== Elasticsearch配置 ==========
+elasticsearch:
+  host: http://10.192.72.13:9200
+  index:
+    name: contract_chunks
+    shards: 3
+    replicas: 1
+  connection:
+    timeout: 30000
+    max-retry-timeout-millis: 30000
+
+# ========== 监控配置 ==========
+management:
+  endpoints:
+    web:
+      exposure:
+        include: health,info,metrics
+  metrics:
+    export:
+      prometheus:
+        enabled: true
+
+# ========== 日志配置 ==========
+logging:
+  level:
+    com.schedule.embedding: INFO
+    org.elasticsearch.client: WARN
+```
+
+---
+
+### 6.2 核心依赖
+
+```xml
+<!-- Elasticsearch Rest High Level Client (JDK1.8兼容) -->
+<dependency>
+    <groupId>org.elasticsearch.client</groupId>
+    <artifactId>elasticsearch-rest-high-level-client</artifactId>
+    <version>7.17.15</version>
+</dependency>
+
+<!-- HTTP客户端(调用Embedding接口) -->
+<dependency>
+    <groupId>okhttp3</groupId>
+    <artifactId>okhttp</artifactId>
+    <version>4.9.3</version>
+</dependency>
+
+<!-- JSON处理 -->
+<dependency>
+    <groupId>com.fasterxml.jackson.core</groupId>
+    <artifactId>jackson-databind</artifactId>
+</dependency>
+```
+
+### 6.2 代码结构设计
+
+#### 6.2.1 Controller层
+
+**TextEmbeddingController.java**
+
+```java
+@RestController
+@RequestMapping("/api/text-embedding")
+public class TextEmbeddingController {
+
+    @Autowired
+    private TextEmbeddingService embeddingService;
+
+    /**
+     * 单个文本入库
+     * POST /api/text-embedding/index
+     * Body: { "content": "文本内容", "businessId": "业务ID" }
+     */
+    @PostMapping("/index")
+    public ResponseEntity<IndexResult> indexText(@RequestBody IndexRequest request) {
+        // 参数校验
+        // 调用Service层
+        // 返回结果
+    }
+
+    /**
+     * 批量文本入库
+     * POST /api/text-embedding/batch-index
+     * Body: { "items": [{"content": "...", "businessId": "..."}] }
+     */
+    @PostMapping("/batch-index")
+    public ResponseEntity<BatchIndexResult> batchIndex(@RequestBody BatchIndexRequest request) {
+        // 批量处理(最多128条)
+        // 返回批量结果
+    }
+
+    /**
+     * 向量相似度搜索
+     * POST /api/text-embedding/search
+     * Body: { "query": "查询文本", "topK": 10 }
+     */
+    @PostMapping("/search")
+    public ResponseEntity<List<SearchResult>> search(@RequestBody SearchRequest request) {
+        // 调用Service层搜索
+        // 返回相似文档列表
+    }
+}
+```
+
+#### 6.2.2 Service层
+
+**TextEmbeddingService.java**
+
+```java
+@Service
+public class TextEmbeddingService {
+
+    @Value("${embedding.api.url}")
+    private String embeddingApiUrl;
+
+    @Value("${embedding.api.key}")
+    private String embeddingApiKey;
+
+    @Value("${embedding.batch.size:128}")
+    private int batchSize;
+
+    @Autowired
+    private EmbeddingClient embeddingClient;
+
+    @Autowired
+    private ElasticsearchRepository esRepository;
+
+    /**
+     * 单个文本入库流程
+     */
+    public IndexResult indexText(String content, String businessId) {
+        // 1. 文本预处理
+        String processedText = preprocessText(content);
+
+        // 2. 调用Embedding接口
+        float[] vector = embeddingClient.getEmbedding(processedText);
+
+        // 3. 构建ES文档
+        TextDocument doc = buildDocument(processedText, vector, businessId);
+
+        // 4. 写入ES
+        String docId = esRepository.indexDocument(doc);
+
+        return IndexResult.success(docId);
+    }
+
+    /**
+     * 批量入库(支持分批处理)
+     */
+    public BatchIndexResult batchIndex(List<IndexItem> items) {
+        // 按batchSize分批
+        // 每批调用embedding接口批量向量化
+        // 批量写入ES(Bulk API)
+        // 返回汇总结果
+    }
+
+    /**
+     * 向量搜索
+     */
+    public List<SearchResult> search(String query, int topK) {
+        // 1. 查询文本向量化
+        float[] queryVector = embeddingClient.getEmbedding(query);
+
+        // 2. KNN搜索
+        return esRepository.knnSearch(queryVector, topK);
+    }
+}
+```
+
+#### 6.2.3 EmbeddingClient层
+
+**EmbeddingClient.java**
+
+```java
+@Component
+public class EmbeddingClient {
+
+    @Value("${embedding.api.url}")
+    private String apiUrl;
+
+    @Value("${embedding.api.key}")
+    private String apiKey;
+
+    private OkHttpClient httpClient;
+    private ObjectMapper objectMapper;
+
+    /**
+     * 单个文本向量化
+     */
+    public float[] getEmbedding(String text) {
+        // 构建请求
+        // HTTP POST调用
+        // 解析返回的4096维向量
+        // 返回float[]
+    }
+
+    /**
+     * 批量文本向量化
+     */
+    public List<float[]> getBatchEmbeddings(List<String> texts) {
+        // 批量请求(最多128条)
+        // 解析批量响应
+        // 返回向量列表
+    }
+
+    /**
+     * 重试机制(指数退避)
+     */
+    private float[] callWithRetry(String text, int maxRetries) {
+        // 失败重试逻辑
+    }
+}
+```
+
+#### 6.2.4 ElasticsearchRepository层
+
+**ElasticsearchRepository.java**
+
+```java
+@Component
+public class ElasticsearchRepository {
+
+    @Value("${elasticsearch.host}")
+    private String esHost;
+
+    private RestHighLevelClient client;
+
+    /**
+     * 创建向量索引
+     */
+    public boolean createIndex(String indexName) {
+        // 使用dense_vector字段类型
+        // 设置cosine相似度
+    }
+
+    /**
+     * 索引单个文档
+     */
+    public String indexDocument(TextDocument doc) {
+        // 使用businessId作为文档ID(幂等性)
+        // IndexRequest
+    }
+
+    /**
+     * 批量索引文档
+     */
+    public BulkResponse bulkIndex(List<TextDocument> docs) {
+        // BulkRequest
+        // 批量写入优化
+    }
+
+    /**
+     * KNN向量搜索
+     */
+    public List<SearchResult> knnSearch(float[] queryVector, int topK) {
+        // 使用KNN查询
+        // 返回Top-K相似文档
+    }
+}
+```
+
+### 6.3 配置文件
+
+**application.yml**
+
+```yaml
+# Embedding接口配置
+embedding:
+  api:
+    url: http://10.192.72.11:18081/v1
+    key: 1
+    model: Qwen3-Embedding-8B
+  batch:
+    size: 128
+  timeout: 30000
+
+# Elasticsearch配置
+elasticsearch:
+  host: http://10.192.72.13:9200
+  index:
+    name: text_vectors
+    shards: 1
+    replicas: 0
+
+# 服务配置
+server:
+  port: 8080
+
+spring:
+  application:
+    name: text-embedding-service
+```
+
+---
+
+## 七、核心数据结构
+
+### 5.1 ES文档结构
+
+```json
+{
+  "id": "文档唯一ID(ES自动生成)",
+  "content": "原始文本内容",
+  "embedding": [0.0123, 0.0456, ...],  // 4096维向量
+  "business_id": "业务ID(作为ES文档ID,保证幂等性)",
+  "content_length": 256,
+  "create_time": "2026-03-12T10:00:00",
+  "update_time": "2026-03-12T10:00:00"
+}
+```
+
+### 5.2 索引映射设计
+
+```json
+{
+  "mappings": {
+    "properties": {
+      "content": {
+        "type": "text",
+        "analyzer": "ik_max_word"
+      },
+      "embedding": {
+        "type": "dense_vector",
+        "dims": 4096,
+        "index": true,
+        "similarity": "cosine"
+      },
+      "business_id": {
+        "type": "keyword"
+      },
+      "content_length": {
+        "type": "integer"
+      },
+      "create_time": {
+        "type": "date",
+        "format": "yyyy-MM-dd'T'HH:mm:ss"
+      }
+    }
+  }
+}
+```
+
+---
+
+## 八、可靠性与性能保障
+
+### 6.1 批量处理策略
+
+**批次大小控制:**
+- 单次最多处理128条文本(EMBEDDING_BATCH_SIZE)
+- 超过128条自动分批处理
+- 每批独立处理,失败不影响其他批次
+
+**分批示例:**
+```java
+// 假设有500条文本
+List<List<IndexItem>> batches = Lists.partition(items, 128);
+// 得到4个批次:128, 128, 128, 116
+for (List<IndexItem> batch : batches) {
+    processBatch(batch);  // 每批独立处理
+}
+```
+
+### 6.2 异常处理与重试
+
+**Embedding接口调用:**
+- 超时时间:30秒
+- 最大重试次数:3次
+- 重试策略:指数退避(1s, 2s, 4s)
+- 失败记录:记录失败文本,支持手动重试
+
+**ES写入异常:**
+- Bulk部分失败:记录失败文档ID
+- 网络异常:自动重试
+- 索引不存在:自动创建索引
+
+### 6.3 幂等性设计
+
+**使用businessId作为ES文档ID:**
+```java
+// 相同的businessId重复入库会覆盖,不会产生重复数据
+String docId = businessId;  // 业务ID作为ES文档ID
+IndexRequest request = new IndexRequest(indexName)
+    .id(docId)  // 设置文档ID
+    .source(buildSource(doc));
+```
+
+### 8.4 性能优化
+
+**Embedding接口:**
+- 使用HTTP连接池
+- 批量请求减少网络开销
+- 异步处理(适用于大批量)
+
+**ES写入优化:**
+- 使用Bulk API批量写入
+- 设置合理的refresh_interval(如30s)
+- 批量大小控制在100-500条
+
+**KNN搜索优化:**
+- 使用knn查询而非script_score
+- 设置合理的num_candidates参数
+- 结合过滤条件减少搜索范围
+
+---
+
+## 九、关键流程说明
+
+### 5.1 文本入库详细流程
+
+```java
+public IndexResult indexText(String content, String businessId) {
+    // 步骤1:参数校验
+    if (StringUtils.isBlank(content)) {
+        throw new IllegalArgumentException("内容不能为空");
+    }
+    if (content.length() > 8192) {  // 假设最大长度8192
+        throw new IllegalArgumentException("内容长度超限");
+    }
+
+    // 步骤2:文本预处理
+    String processedText = content.trim();
+
+    // 步骤3:调用Embedding接口(带重试)
+    float[] vector = embeddingClient.getEmbedding(processedText);
+    if (vector == null || vector.length != 4096) {
+        throw new RuntimeException("向量化失败");
+    }
+
+    // 步骤4:构建文档对象
+    TextDocument doc = TextDocument.builder()
+        .content(processedText)
+        .embedding(vector)
+        .businessId(businessId)
+        .contentLength(processedText.length())
+        .createTime(new Date())
+        .build();
+
+    // 步骤5:写入ES
+    String docId = esRepository.indexDocument(doc);
+
+    // 步骤6:返回结果
+    return IndexResult.builder()
+        .success(true)
+        .docId(docId)
+        .build();
+}
+```
+
+### 5.2 批量入库详细流程
+
+```java
+public BatchIndexResult batchIndex(List<IndexItem> items) {
+    // 步骤1:参数校验
+    if (items == null || items.isEmpty()) {
+        throw new IllegalArgumentException("批次不能为空");
+    }
+
+    // 步骤2:分批处理
+    List<List<IndexItem>> batches = Lists.partition(items, batchSize);
+
+    int totalSuccess = 0;
+    int totalFailed = 0;
+    List<String> failedItems = new ArrayList<>();
+
+    // 步骤3:逐批处理
+    for (List<IndexItem> batch : batches) {
+        try {
+            // 3.1 批量向量化
+            List<String> texts = batch.stream()
+                .map(IndexItem::getContent)
+                .collect(Collectors.toList());
+            List<float[]> vectors = embeddingClient.getBatchEmbeddings(texts);
+
+            // 3.2 构建文档列表
+            List<TextDocument> docs = new ArrayList<>();
+            for (int i = 0; i < batch.size(); i++) {
+                TextDocument doc = buildDocument(
+                    batch.get(i),
+                    vectors.get(i)
+                );
+                docs.add(doc);
+            }
+
+            // 3.3 批量写入ES
+            BulkResponse response = esRepository.bulkIndex(docs);
+
+            // 3.4 统计结果
+            if (response.hasFailures()) {
+                // 处理失败项
+                totalFailed += countFailed(response);
+                failedItems.addAll(getFailedItems(response));
+            } else {
+                totalSuccess += docs.size();
+            }
+
+        } catch (Exception e) {
+            // 整批失败
+            totalFailed += batch.size();
+            batch.forEach(item -> failedItems.add(item.getBusinessId()));
+            log.error("批次处理失败", e);
+        }
+    }
+
+    // 步骤4:返回汇总结果
+    return BatchIndexResult.builder()
+        .totalCount(items.size())
+        .successCount(totalSuccess)
+        .failedCount(totalFailed)
+        .failedItems(failedItems)
+        .build();
+}
+```
+
+### 9.3 向量搜索详细流程
+
+```java
+public List<SearchResult> search(String query, int topK) {
+    // 步骤1:查询向量化
+    float[] queryVector = embeddingClient.getEmbedding(query);
+
+    // 步骤2:KNN搜索
+    SearchRequest searchRequest = new SearchRequest(indexName);
+    SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
+
+    // 构建KNN查询
+    sourceBuilder.query(QueryBuilders.knnQuery(
+        "embedding",
+        queryVector,
+        topK
+    ).numCandidates(100));  // 候选文档数
+
+    searchRequest.source(sourceBuilder);
+
+    // 步骤3:执行搜索
+    SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT);
+
+    // 步骤4:解析结果
+    List<SearchResult> results = new ArrayList<>();
+    for (SearchHit hit : response.getHits().getHits()) {
+        Map<String, Object> sourceMap = hit.getSourceAsMap();
+        float score = hit.getScore();
+
+        SearchResult result = SearchResult.builder()
+            .docId(hit.getId())
+            .content((String) sourceMap.get("content"))
+            .businessId((String) sourceMap.get("business_id"))
+            .score(score)
+            .build();
+
+        results.add(result);
+    }
+
+    return results;
+}
+```
+
+---
+
+## 十、部署与配置
+
+### 6.1 应用配置清单
+
+**必需配置:**
+- `embedding.api.url`:Embedding接口地址
+- `embedding.api.key`:API密钥
+- `embedding.batch.size`:批量大小(默认128)
+- `elasticsearch.host`:ES地址
+- `elasticsearch.index.name`:索引名称
+
+**可选配置:**
+- `embedding.timeout`:接口超时时间(毫秒)
+- `elasticsearch.index.shards`:索引分片数
+- `elasticsearch.index.replicas`:索引副本数
+
+### 6.2 初始化检查
+
+**应用启动时检查:**
+1. Embedding接口连通性
+2. ES集群连接状态
+3. 索引是否存在(不存在则创建)
+4. 索引映射是否正确
+
+---
+
+## 十一、监控与日志
+
+### 5.1 关键指标监控
+
+- **Embedding接口:**
+  - 调用次数
+  - 平均响应时间
+  - 失败率
+
+- **ES操作:**
+  - 文档写入数量
+  - 搜索请求次数
+  - 平均查询时间
+
+### 5.2 日志记录
+
+**关键日志点:**
+- 入库请求:businessId、内容长度
+- Embedding调用:请求ID、响应时间
+- ES写入:文档ID、写入结果
+- 异常情况:异常类型、错误信息、堆栈
+
+---
+
+## 十二、总结
+
+### 6.1 方案优势
+
+1. **高可用性:**
+   - 异常重试机制
+   - 批量处理失败隔离
+   - 幂等性设计
+
+2. **高性能:**
+   - 批量向量化(128条/批)
+   - ES Bulk API批量写入
+   - HTTP连接池复用
+
+3. **可扩展性:**
+   - 分层架构清晰
+   - 配置灵活
+   - 易于扩展新功能
+
+### 5.2 下一步工作
+
+1. **代码实现:**
+   - 按照上述结构编写完整代码
+   - 单元测试覆盖
+   - 集成测试验证
+
+2. **性能测试:**
+   - 压力测试
+   - 批量入库性能测试
+   - 搜索性能测试
+
+3. **优化迭代:**
+   - 根据测试结果优化参数
+   - 添加缓存机制(如需要)
+   - 优化索引配置
+
+---
+
+## 十三、附录:接口测试命令
+
+### 13.1 Embedding接口测试
+
+```bash
+curl -X POST "http://10.192.72.11:18081/v1/embeddings" \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer 1" \
+  -d '{
+    "model": "Qwen3-Embedding-8B",
+    "input": "测试文本内容"
+  }'
+```
+
+### 13.2 ES集群状态测试
+
+```bash
+curl -X GET "http://10.192.72.13:9200/_cluster/health?pretty"
+```
+
+### 13.3 创建向量索引
+
+```bash
+curl -X PUT "http://10.192.72.13:9200/text_vectors" \
+  -H "Content-Type: application/json" \
+  -d '{
+  "settings": {
+    "number_of_shards": 1,
+    "number_of_replicas": 0
+  },
+  "mappings": {
+    "properties": {
+      "content": { "type": "text" },
+      "embedding": {
+        "type": "dense_vector",
+        "dims": 4096,
+        "index": true,
+        "similarity": "cosine"
+      },
+      "business_id": { "type": "keyword" },
+      "create_time": { "type": "date" }
+    }
+  }
+}'
+```
+
+### 13.4 查看索引映射
+
+```bash
+curl -X GET "http://10.192.72.13:9200/text_vectors/_mapping?pretty"
+```
+
+---
+
+**文档版本:** v1.5
+**编写日期:** 2026-03-12
+**更新日期:** 2026-03-12
+**更新内容:**
+- v1.1: 新增完整示例章节(从文本到搜索的完整流程)
+- v1.2: 新增完整操作步骤(从零开始,7个步骤详细说明)
+- v1.3: 新增融合设计方案(企业级最佳实践,宽表设计)
+- v1.4: 新增文本分段策略与实现(全量文本分段处理)
+- v1.5: 新增模块结构设计(schedule-embedding-api完整架构)
+**测试环境:** 内网环境验证通过

+ 171 - 0
schedule-embedding-api/pom.xml

@@ -0,0 +1,171 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>org.example</groupId>
+        <artifactId>four-level-schedule</artifactId>
+        <version>1.0-SNAPSHOT</version>
+    </parent>
+
+    <groupId>cn.com.yusys.manager</groupId>
+    <artifactId>schedule-embedding-api</artifactId>
+    <packaging>jar</packaging>
+    <name>schedule-embedding-api</name>
+    <description>文本向量化与向量检索服务</description>
+
+    <properties>
+        <maven.compiler.source>17</maven.compiler.source>
+        <maven.compiler.target>17</maven.compiler.target>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+        <elasticsearch.version>8.13.0</elasticsearch.version>
+        <okhttp.version>4.9.3</okhttp.version>
+    </properties>
+
+    <dependencies>
+        <!-- Spring Boot Web -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+
+        <!-- Spring Boot Validation -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-validation</artifactId>
+        </dependency>
+
+        <!-- Spring Kafka -->
+        <dependency>
+            <groupId>org.springframework.kafka</groupId>
+            <artifactId>spring-kafka</artifactId>
+        </dependency>
+
+        <!-- Elasticsearch Java Client (ES 8.x官方客户端,支持JDK 8) -->
+        <dependency>
+            <groupId>co.elastic.clients</groupId>
+            <artifactId>elasticsearch-java</artifactId>
+            <version>${elasticsearch.version}</version>
+        </dependency>
+
+        <!-- Elasticsearch Rest Client (底层依赖) -->
+        <dependency>
+            <groupId>org.elasticsearch.client</groupId>
+            <artifactId>elasticsearch-rest-client</artifactId>
+            <version>${elasticsearch.version}</version>
+        </dependency>
+
+        <!-- Jakarta JSON API (ES 8.x 需要) -->
+        <dependency>
+            <groupId>jakarta.json</groupId>
+            <artifactId>jakarta.json-api</artifactId>
+            <version>2.0.1</version>
+        </dependency>
+
+        <!-- Jakarta JSON 实现 (Glassfish) -->
+        <dependency>
+            <groupId>org.glassfish</groupId>
+            <artifactId>jakarta.json</artifactId>
+            <version>2.0.1</version>
+        </dependency>
+
+        <!-- Jackson Databind (ES Java Client需要) -->
+        <dependency>
+            <groupId>com.fasterxml.jackson.core</groupId>
+            <artifactId>jackson-databind</artifactId>
+        </dependency>
+
+        <!-- javax.annotation (PostConstruct注解) -->
+        <dependency>
+            <groupId>javax.annotation</groupId>
+            <artifactId>javax.annotation-api</artifactId>
+            <version>1.3.2</version>
+        </dependency>
+
+        <!-- HTTP客户端(调用Embedding接口) -->
+        <dependency>
+            <groupId>com.squareup.okhttp3</groupId>
+            <artifactId>okhttp</artifactId>
+            <version>${okhttp.version}</version>
+        </dependency>
+
+        <!-- JSON处理 -->
+        <dependency>
+            <groupId>com.fasterxml.jackson.core</groupId>
+            <artifactId>jackson-databind</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>com.alibaba</groupId>
+            <artifactId>fastjson</artifactId>
+        </dependency>
+
+        <!-- Lombok(减少样板代码) -->
+        <dependency>
+            <groupId>org.projectlombok</groupId>
+            <artifactId>lombok</artifactId>
+            <optional>true</optional>
+        </dependency>
+
+        <!-- Apache Commons(工具类) -->
+        <dependency>
+            <groupId>org.apache.commons</groupId>
+            <artifactId>commons-lang3</artifactId>
+        </dependency>
+
+        <!-- Spring Boot Actuator (健康检查) -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-actuator</artifactId>
+        </dependency>
+
+        <!-- Spring Boot Data JPA -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-data-jpa</artifactId>
+        </dependency>
+
+        <!-- MySQL Driver -->
+        <dependency>
+            <groupId>com.mysql</groupId>
+            <artifactId>mysql-connector-j</artifactId>
+            <scope>runtime</scope>
+        </dependency>
+
+        <!-- 测试依赖 -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter-api</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter-engine</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <finalName>schedule-embedding-api</finalName>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+                <executions>
+                    <execution>
+                        <goals>
+                            <goal>repackage</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+    </build>
+
+</project>

+ 18 - 0
schedule-embedding-api/src/main/java/cn/com/yusys/manager/EmbeddingApplication.java

@@ -0,0 +1,18 @@
+package cn.com.yusys.manager;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+import org.springframework.scheduling.annotation.EnableAsync;
+
+/**
+ * 文本向量化服务启动类
+ */
+@SpringBootApplication
+@EnableJpaAuditing
+@EnableAsync
+public class EmbeddingApplication {
+    public static void main(String[] args) {
+        SpringApplication.run(EmbeddingApplication.class, args);
+    }
+}

+ 205 - 0
schedule-embedding-api/src/main/java/cn/com/yusys/manager/client/EmbeddingClient.java

@@ -0,0 +1,205 @@
+package cn.com.yusys.manager.client;
+
+import cn.com.yusys.manager.config.EmbeddingConfig;
+import cn.com.yusys.manager.exception.EmbeddingException;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.*;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Embedding接口客户端
+ */
+@Slf4j
+@Component
+public class EmbeddingClient {
+
+    @Autowired
+    private EmbeddingConfig embeddingConfig;
+
+    private OkHttpClient httpClient;
+
+    public EmbeddingClient() {
+        this.httpClient = new OkHttpClient.Builder()
+                .connectTimeout(30, TimeUnit.SECONDS)
+                .readTimeout(30, TimeUnit.SECONDS)
+                .writeTimeout(30, TimeUnit.SECONDS)
+                .build();
+    }
+
+    /**
+     * 单个文本向量化
+     * @param text 文本
+     * @return 4096维向量
+     */
+    public float[] getEmbedding(String text) {
+        try {
+            return callWithRetry(text, 3);
+        } catch (Exception e) {
+            log.error("Embedding接口调用失败: {}", e.getMessage(), e);
+            throw new EmbeddingException("向量化失败: " + e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 批量文本向量化
+     * @param texts 文本列表
+     * @return 向量列表
+     */
+    public List<float[]> getBatchEmbeddings(List<String> texts) {
+        if (texts == null || texts.isEmpty()) {
+            return new ArrayList<>();
+        }
+
+        // 分批处理,每批最多128条
+        int batchSize = embeddingConfig.getBatchSize();
+        List<float[]> results = new ArrayList<>();
+
+        for (int i = 0; i < texts.size(); i += batchSize) {
+            int end = Math.min(i + batchSize, texts.size());
+            List<String> batch = texts.subList(i, end);
+            results.addAll(callBatchEmbedding(batch));
+        }
+
+        return results;
+    }
+
+    /**
+     * 调用接口(带重试)
+     * @param text 文本
+     * @param maxRetries 最大重试次数
+     * @return 向量
+     */
+    private float[] callWithRetry(String text, int maxRetries) {
+        Exception lastException = null;
+        for (int i = 0; i < maxRetries; i++) {
+            try {
+                float[] result = doCallEmbedding(text);
+                if (result != null && result.length == 4096) {
+                    return result;
+                }
+            } catch (Exception e) {
+                lastException = e;
+                log.warn("Embedding接口调用失败,第 {} 次重试: {}", i + 1, e.getMessage());
+                if (i < maxRetries - 1) {
+                    try {
+                        Thread.sleep((long) Math.pow(2, i) * 1000); // 指数退避
+                    } catch (InterruptedException ie) {
+                        Thread.currentThread().interrupt();
+                        throw new EmbeddingException("重试被中断", ie);
+                    }
+                }
+            }
+        }
+        throw new EmbeddingException("Embedding接口调用失败,已达最大重试次数", lastException);
+    }
+
+    /**
+     * 执行单文本向量化调用
+     * @param text 文本
+     * @return 向量
+     */
+    private float[] doCallEmbedding(String text) throws IOException {
+        String url = embeddingConfig.getUrl() + "/embeddings";
+
+        JSONObject requestBody = new JSONObject();
+        requestBody.put("model", embeddingConfig.getModel());
+        requestBody.put("input", text);
+
+        Request request = new Request.Builder()
+                .url(url)
+                .addHeader("Content-Type", "application/json")
+                .addHeader("Authorization", "Bearer " + embeddingConfig.getKey())
+                .post(RequestBody.create(requestBody.toJSONString(), MediaType.parse("application/json")))
+                .build();
+
+        try (Response response = httpClient.newCall(request).execute()) {
+            if (!response.isSuccessful()) {
+                throw new IOException("HTTP code: " + response.code());
+            }
+
+            String responseBody = response.body().string();
+            JSONObject jsonResponse = JSON.parseObject(responseBody);
+            JSONArray dataArray = jsonResponse.getJSONArray("data");
+            if (dataArray == null || dataArray.isEmpty()) {
+                throw new IOException("响应数据为空");
+            }
+
+            JSONObject data = dataArray.getJSONObject(0);
+            JSONArray embeddingArray = data.getJSONArray("embedding");
+            if (embeddingArray == null || embeddingArray.size() != 4096) {
+                throw new IOException("向量维度不是4096");
+            }
+
+            float[] embedding = new float[4096];
+            for (int i = 0; i < 4096; i++) {
+                embedding[i] = embeddingArray.getFloatValue(i);
+            }
+
+            return embedding;
+        }
+    }
+
+    /**
+     * 执行批量向量化调用
+     * @param texts 文本列表
+     * @return 向量列表
+     */
+    private List<float[]> callBatchEmbedding(List<String> texts) {
+        try {
+            String url = embeddingConfig.getUrl() + "/embeddings";
+
+            JSONObject requestBody = new JSONObject();
+            requestBody.put("model", embeddingConfig.getModel());
+            requestBody.put("input", texts);
+
+            Request request = new Request.Builder()
+                    .url(url)
+                    .addHeader("Content-Type", "application/json")
+                    .addHeader("Authorization", "Bearer " + embeddingConfig.getKey())
+                    .post(RequestBody.create(requestBody.toJSONString(), MediaType.parse("application/json")))
+                    .build();
+
+            try (Response response = httpClient.newCall(request).execute()) {
+                if (!response.isSuccessful()) {
+                    throw new IOException("HTTP code: " + response.code());
+                }
+
+                String responseBody = response.body().string();
+                JSONObject jsonResponse = JSON.parseObject(responseBody);
+                JSONArray dataArray = jsonResponse.getJSONArray("data");
+                if (dataArray == null || dataArray.isEmpty()) {
+                    throw new IOException("响应数据为空");
+                }
+
+                List<float[]> results = new ArrayList<>();
+                for (int i = 0; i < dataArray.size(); i++) {
+                    JSONObject data = dataArray.getJSONObject(i);
+                    JSONArray embeddingArray = data.getJSONArray("embedding");
+                    if (embeddingArray == null || embeddingArray.size() != 4096) {
+                        throw new IOException("向量维度不是4096");
+                    }
+
+                    float[] embedding = new float[4096];
+                    for (int j = 0; j < 4096; j++) {
+                        embedding[j] = embeddingArray.getFloatValue(j);
+                    }
+                    results.add(embedding);
+                }
+
+                return results;
+            }
+        } catch (Exception e) {
+            log.error("批量向量化失败: {}", e.getMessage(), e);
+            throw new EmbeddingException("批量向量化失败: " + e.getMessage(), e);
+        }
+    }
+}

+ 34 - 0
schedule-embedding-api/src/main/java/cn/com/yusys/manager/config/ChunkConfig.java

@@ -0,0 +1,34 @@
+package cn.com.yusys.manager.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * 文本分段配置
+ */
+@Data
+@Configuration
+@ConfigurationProperties(prefix = "chunk")
+public class ChunkConfig {
+
+    /**
+     * 最大chunk长度(字)
+     */
+    private Integer maxLength = 500;
+
+    /**
+     * 最小chunk长度(字)
+     */
+    private Integer minLength = 100;
+
+    /**
+     * 重叠大小(字)
+     */
+    private Integer overlap = 50;
+
+    /**
+     * 分段模式:paragraph/fixed/semantic
+     */
+    private String mode = "paragraph";
+}

+ 85 - 0
schedule-embedding-api/src/main/java/cn/com/yusys/manager/config/ElasticsearchConfig.java

@@ -0,0 +1,85 @@
+package cn.com.yusys.manager.config;
+
+import co.elastic.clients.elasticsearch.ElasticsearchClient;
+import co.elastic.clients.json.jackson.JacksonJsonpMapper;
+import co.elastic.clients.transport.ElasticsearchTransport;
+import co.elastic.clients.transport.rest_client.RestClientTransport;
+import org.apache.http.HttpHost;
+import org.apache.http.auth.AuthScope;
+import org.apache.http.auth.UsernamePasswordCredentials;
+import org.apache.http.client.CredentialsProvider;
+import org.apache.http.impl.client.BasicCredentialsProvider;
+import org.elasticsearch.client.RestClient;
+import org.elasticsearch.client.RestClientBuilder;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Elasticsearch配置类
+ * 使用ES 8.x新的Java客户端
+ */
+@Configuration
+public class ElasticsearchConfig {
+
+    @Value("${elasticsearch.host}")
+    private String elasticsearchHost;
+
+    @Value("${elasticsearch.username:}")
+    private String username;
+
+    @Value("${elasticsearch.password:}")
+    private String password;
+
+    @Value("${elasticsearch.connection.timeout:30000}")
+    private int timeout;
+
+    @Bean(destroyMethod = "close")
+    public RestClient restClient() {
+        // 解析host地址 (格式: http://localhost:9200)
+        String[] hostParts = elasticsearchHost.replace("http://", "").replace("https://", "").split(":");
+        String hostname = hostParts[0];
+        int port = hostParts.length > 1 ? Integer.parseInt(hostParts[1]) : 9200;
+
+        RestClientBuilder builder = RestClient.builder(new HttpHost(hostname, port, "http"))
+                .setRequestConfigCallback(requestConfigBuilder -> {
+                    requestConfigBuilder.setConnectTimeout(timeout);
+                    requestConfigBuilder.setSocketTimeout(timeout);
+                    requestConfigBuilder.setConnectionRequestTimeout(timeout);
+                    return requestConfigBuilder;
+                })
+                .setHttpClientConfigCallback(httpClientBuilder -> {
+                    httpClientBuilder.setMaxConnTotal(100);
+                    httpClientBuilder.setMaxConnPerRoute(50);
+                    return httpClientBuilder;
+                });
+
+        // 如果配置了用户名密码,添加认证
+        if (username != null && !username.isEmpty()) {
+            final CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
+            credentialsProvider.setCredentials(AuthScope.ANY,
+                    new UsernamePasswordCredentials(username, password));
+            builder.setHttpClientConfigCallback(httpClientBuilder -> {
+                httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider);
+                httpClientBuilder.setMaxConnTotal(100);
+                httpClientBuilder.setMaxConnPerRoute(50);
+                return httpClientBuilder;
+            });
+        }
+
+        return builder.build();
+    }
+
+    @Bean(destroyMethod = "close")
+    public ElasticsearchTransport elasticsearchTransport(RestClient restClient) {
+        return new RestClientTransport(
+                restClient,
+                new JacksonJsonpMapper()
+        );
+    }
+
+    @Bean
+    public ElasticsearchClient elasticsearchClient(ElasticsearchTransport transport) {
+        return new ElasticsearchClient(transport);
+    }
+}

+ 39 - 0
schedule-embedding-api/src/main/java/cn/com/yusys/manager/config/EmbeddingConfig.java

@@ -0,0 +1,39 @@
+package cn.com.yusys.manager.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Embedding接口配置
+ */
+@Data
+@Configuration
+@ConfigurationProperties(prefix = "embedding.api")
+public class EmbeddingConfig {
+
+    /**
+     * Embedding接口地址
+     */
+    private String url;
+
+    /**
+     * API密钥
+     */
+    private String key;
+
+    /**
+     * 模型名称
+     */
+    private String model;
+
+    /**
+     * 超时时间(毫秒)
+     */
+    private Integer timeout = 30000;
+
+    /**
+     * 批处理大小
+     */
+    private Integer batchSize = 128;
+}

+ 60 - 0
schedule-embedding-api/src/main/java/cn/com/yusys/manager/config/KafkaConsumerConfig.java

@@ -0,0 +1,60 @@
+package cn.com.yusys.manager.config;
+
+import org.apache.kafka.clients.consumer.ConsumerConfig;
+import org.apache.kafka.common.serialization.StringDeserializer;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.kafka.annotation.EnableKafka;
+import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
+import org.springframework.kafka.core.ConsumerFactory;
+import org.springframework.kafka.core.DefaultKafkaConsumerFactory;
+import org.springframework.kafka.listener.ContainerProperties;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Kafka消费者配置
+ * 只有在 kafka.enabled=true 时才会生效
+ */
+@Configuration
+@EnableKafka
+@ConditionalOnProperty(name = "kafka.enabled", havingValue = "true")
+public class KafkaConsumerConfig {
+
+    @Value("${spring.kafka.bootstrap-servers}")
+    private String bootstrapServers;
+
+    @Value("${spring.kafka.consumer.group-id}")
+    private String groupId;
+
+    @Value("${spring.kafka.consumer.auto-offset-reset}")
+    private String autoOffsetReset;
+
+    @Value("${spring.kafka.consumer.enable-auto-commit}")
+    private Boolean enableAutoCommit;
+
+    @Bean
+    public ConsumerFactory<String, String> consumerFactory() {
+        Map<String, Object> props = new HashMap<>();
+        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
+        props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);
+        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
+        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
+        props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, autoOffsetReset);
+        props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, enableAutoCommit);
+        props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 10);
+        return new DefaultKafkaConsumerFactory<>(props);
+    }
+
+    @Bean
+    public ConcurrentKafkaListenerContainerFactory<String, String> kafkaListenerContainerFactory() {
+        ConcurrentKafkaListenerContainerFactory<String, String> factory =
+                new ConcurrentKafkaListenerContainerFactory<>();
+        factory.setConsumerFactory(consumerFactory());
+        factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL_IMMEDIATE);
+        return factory;
+    }
+}

+ 166 - 0
schedule-embedding-api/src/main/java/cn/com/yusys/manager/controller/DocumentController.java

@@ -0,0 +1,166 @@
+package cn.com.yusys.manager.controller;
+
+import cn.com.yusys.manager.model.entity.OperationLog;
+import cn.com.yusys.manager.model.entity.TextDocument;
+import cn.com.yusys.manager.model.entity.service.DocumentService;
+import cn.com.yusys.manager.service.LogService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 文档管理控制器
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/v1/documents")
+public class DocumentController {
+
+    @Autowired
+    private DocumentService documentService;
+
+    @Autowired
+    private LogService logService;
+
+    /**
+     * 查询文档的所有chunks
+     * GET /api/v1/documents/{docId}
+     */
+    @GetMapping("/{docId}")
+    public ResponseEntity<List<TextDocument>> getDocument(@PathVariable String docId,
+                                                         HttpServletRequest httpRequest) {
+        long startTime = System.currentTimeMillis();
+        String ipAddress = getClientIp(httpRequest);
+
+        log.info("查询文档: docId={}", docId);
+        List<TextDocument> documents = documentService.getDocument(docId);
+
+        long duration = System.currentTimeMillis() - startTime;
+
+        // 记录操作日志
+        Map<String, Object> requestParams = new HashMap<>();
+        requestParams.put("docId", docId);
+
+        Map<String, Object> responseResult = new HashMap<>();
+        responseResult.put("resultCount", documents.size());
+
+        logService.recordOperationLog(
+                OperationLog.OperationType.GET_CHUNK,
+                docId,
+                null,
+                null,
+                requestParams,
+                responseResult,
+                OperationLog.Status.SUCCESS,
+                null,
+                duration,
+                ipAddress
+        );
+
+        return ResponseEntity.ok(documents);
+    }
+
+    /**
+     * 删除文档的所有chunks
+     * DELETE /api/v1/documents/{docId}
+     */
+    @DeleteMapping("/{docId}")
+    public ResponseEntity<Map<String, Object>> deleteDocument(@PathVariable String docId,
+                                                             HttpServletRequest httpRequest) {
+        long startTime = System.currentTimeMillis();
+        String ipAddress = getClientIp(httpRequest);
+
+        log.info("删除文档: docId={}", docId);
+        long count = documentService.deleteDocument(docId);
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("success", true);
+        result.put("docId", docId);
+        result.put("deletedCount", count);
+        result.put("message", "删除成功");
+
+        long duration = System.currentTimeMillis() - startTime;
+
+        // 记录操作日志
+        Map<String, Object> requestParams = new HashMap<>();
+        requestParams.put("docId", docId);
+
+        logService.recordOperationLog(
+                OperationLog.OperationType.DELETE,
+                docId,
+                null,
+                null,
+                requestParams,
+                result,
+                OperationLog.Status.SUCCESS,
+                null,
+                duration,
+                ipAddress
+        );
+
+        return ResponseEntity.ok(result);
+    }
+
+    /**
+     * 删除单个chunk
+     * DELETE /api/v1/documents/chunk/{chunkId}
+     */
+    @DeleteMapping("/chunk/{chunkId}")
+    public ResponseEntity<Map<String, Object>> deleteChunk(@PathVariable String chunkId,
+                                                           HttpServletRequest httpRequest) {
+        long startTime = System.currentTimeMillis();
+        String ipAddress = getClientIp(httpRequest);
+
+        log.info("删除chunk: chunkId={}", chunkId);
+        boolean success = documentService.deleteChunk(chunkId);
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("success", success);
+        result.put("chunkId", chunkId);
+        result.put("message", success ? "删除成功" : "删除失败");
+
+        long duration = System.currentTimeMillis() - startTime;
+
+        // 记录操作日志
+        Map<String, Object> requestParams = new HashMap<>();
+        requestParams.put("chunkId", chunkId);
+
+        logService.recordOperationLog(
+                OperationLog.OperationType.DELETE_CHUNK,
+                null,
+                chunkId,
+                null,
+                requestParams,
+                result,
+                success ? OperationLog.Status.SUCCESS : OperationLog.Status.FAILED,
+                null,
+                duration,
+                ipAddress
+        );
+
+        return ResponseEntity.ok(result);
+    }
+
+    /**
+     * 获取客户端IP
+     */
+    private String getClientIp(HttpServletRequest request) {
+        String ip = request.getHeader("X-Forwarded-For");
+        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
+            ip = request.getHeader("X-Real-IP");
+        }
+        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
+            ip = request.getRemoteAddr();
+        }
+        if (ip != null && ip.contains(",")) {
+            ip = ip.split(",")[0].trim();
+        }
+        return ip;
+    }
+}

+ 166 - 0
schedule-embedding-api/src/main/java/cn/com/yusys/manager/controller/TextEmbeddingController.java

@@ -0,0 +1,166 @@
+package cn.com.yusys.manager.controller;
+
+import cn.com.yusys.manager.model.dto.BatchIndexRequest;
+import cn.com.yusys.manager.model.dto.IndexRequest;
+import cn.com.yusys.manager.model.entity.OperationLog;
+import cn.com.yusys.manager.model.vo.BatchIndexResult;
+import cn.com.yusys.manager.model.vo.IndexResult;
+import cn.com.yusys.manager.model.entity.service.TextEmbeddingService;
+import cn.com.yusys.manager.service.LogService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.validation.Valid;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 文本向量化控制器
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/v1/documents")
+@Validated
+public class TextEmbeddingController {
+
+    @Autowired
+    private TextEmbeddingService textEmbeddingService;
+
+    @Autowired
+    private LogService logService;
+
+    /**
+     * 单个文档入库
+     * POST /api/v1/documents/index
+     */
+    @PostMapping("/index")
+    public ResponseEntity<IndexResult> indexDocument(@Valid @RequestBody IndexRequest request,
+                                                      HttpServletRequest httpRequest) {
+        long startTime = System.currentTimeMillis();
+        String ipAddress = getClientIp(httpRequest);
+
+        log.info("收到文档入库请求: docId={}", request.getDocId());
+        IndexResult result;
+        String status = OperationLog.Status.SUCCESS;
+        String errorMessage = null;
+
+        try {
+            result = textEmbeddingService.indexDocument(request);
+            if (result.getSuccess() == null || !result.getSuccess()) {
+                status = OperationLog.Status.FAILED;
+                errorMessage = result.getError();
+            }
+        } catch (Exception e) {
+            status = OperationLog.Status.FAILED;
+            errorMessage = e.getMessage();
+            result = IndexResult.failure(request.getDocId(), errorMessage);
+        }
+
+        long duration = System.currentTimeMillis() - startTime;
+
+        // 记录操作日志
+        Map<String, Object> requestParams = new HashMap<>();
+        requestParams.put("docId", request.getDocId());
+        requestParams.put("fileName", request.getFileName());
+        requestParams.put("fileType", request.getFileType());
+
+        Map<String, Object> responseResult = new HashMap<>();
+        responseResult.put("success", result.getSuccess());
+        responseResult.put("docId", result.getDocId());
+        responseResult.put("chunkCount", result.getChunkCount());
+
+        logService.recordOperationLog(
+                OperationLog.OperationType.INDEX,
+                request.getDocId(),
+                null,
+                null,
+                requestParams,
+                responseResult,
+                status,
+                errorMessage,
+                duration,
+                ipAddress
+        );
+
+        return ResponseEntity.ok(result);
+    }
+
+    /**
+     * 批量文档入库
+     * POST /api/v1/documents/batch-index
+     */
+    @PostMapping("/batch-index")
+    public ResponseEntity<BatchIndexResult> batchIndex(@Valid @RequestBody BatchIndexRequest request,
+                                                       HttpServletRequest httpRequest) {
+        long startTime = System.currentTimeMillis();
+        String ipAddress = getClientIp(httpRequest);
+
+        log.info("收到批量文档入库请求: count={}", request.getItems().size());
+        BatchIndexResult result;
+        String status = OperationLog.Status.SUCCESS;
+        String errorMessage = null;
+
+        try {
+            result = textEmbeddingService.batchIndexDocuments(request);
+            if (result.getFailedCount() > 0) {
+                status = OperationLog.Status.FAILED;
+                errorMessage = result.getFailedCount() + " 个文档入库失败";
+            }
+        } catch (Exception e) {
+            status = OperationLog.Status.FAILED;
+            errorMessage = e.getMessage();
+            result = new BatchIndexResult();
+            result.setTotalCount(request.getItems().size());
+            result.setSuccessCount(0);
+            result.setFailedCount(request.getItems().size());
+        }
+
+        long duration = System.currentTimeMillis() - startTime;
+
+        // 记录操作日志
+        Map<String, Object> requestParams = new HashMap<>();
+        requestParams.put("items", request.getItems().size() + " 个文档");
+
+        Map<String, Object> responseResult = new HashMap<>();
+        responseResult.put("totalCount", result.getTotalCount());
+        responseResult.put("successCount", result.getSuccessCount());
+        responseResult.put("failedCount", result.getFailedCount());
+
+        logService.recordOperationLog(
+                OperationLog.OperationType.BATCH_INDEX,
+                null,
+                null,
+                null,
+                requestParams,
+                responseResult,
+                status,
+                errorMessage,
+                duration,
+                ipAddress
+        );
+
+        return ResponseEntity.ok(result);
+    }
+
+    /**
+     * 获取客户端IP
+     */
+    private String getClientIp(HttpServletRequest request) {
+        String ip = request.getHeader("X-Forwarded-For");
+        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
+            ip = request.getHeader("X-Real-IP");
+        }
+        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
+            ip = request.getRemoteAddr();
+        }
+        // 多级代理时取第一个
+        if (ip != null && ip.contains(",")) {
+            ip = ip.split(",")[0].trim();
+        }
+        return ip;
+    }
+}

+ 133 - 0
schedule-embedding-api/src/main/java/cn/com/yusys/manager/controller/VectorSearchController.java

@@ -0,0 +1,133 @@
+package cn.com.yusys.manager.controller;
+
+import cn.com.yusys.manager.model.dto.SearchRequest;
+import cn.com.yusys.manager.model.dto.SearchResult;
+import cn.com.yusys.manager.model.entity.OperationLog;
+import cn.com.yusys.manager.model.entity.service.TextEmbeddingService;
+import cn.com.yusys.manager.service.LogService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.validation.Valid;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 向量搜索控制器
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/v1")
+@Validated
+public class VectorSearchController {
+
+    @Autowired
+    private TextEmbeddingService textEmbeddingService;
+
+    @Autowired
+    private LogService logService;
+
+    /**
+     * 向量搜索
+     * POST /api/v1/search
+     */
+    @PostMapping("/search")
+    public ResponseEntity<List<SearchResult>> search(@Valid @RequestBody SearchRequest request,
+                                                      HttpServletRequest httpRequest) {
+        long startTime = System.currentTimeMillis();
+        String ipAddress = getClientIp(httpRequest);
+        String operationType = OperationLog.OperationType.SEARCH;
+
+        log.info("收到向量搜索请求: query={}, topK={}", request.getQuery(), request.getTopK());
+        List<SearchResult> results = textEmbeddingService.search(request);
+
+        long duration = System.currentTimeMillis() - startTime;
+
+        // 记录操作日志
+        Map<String, Object> requestParams = new HashMap<>();
+        requestParams.put("query", request.getQuery());
+        requestParams.put("topK", request.getTopK());
+
+        Map<String, Object> responseResult = new HashMap<>();
+        responseResult.put("resultCount", results.size());
+
+        logService.recordOperationLog(
+                operationType,
+                null,
+                null,
+                null,
+                requestParams,
+                responseResult,
+                OperationLog.Status.SUCCESS,
+                null,
+                duration,
+                ipAddress
+        );
+
+        return ResponseEntity.ok(results);
+    }
+
+    /**
+     * 混合搜索(向量 + 业务过滤)
+     * POST /api/v1/search/hybrid
+     */
+    @PostMapping("/search/hybrid")
+    public ResponseEntity<List<SearchResult>> hybridSearch(@Valid @RequestBody SearchRequest request,
+                                                            HttpServletRequest httpRequest) {
+        long startTime = System.currentTimeMillis();
+        String ipAddress = getClientIp(httpRequest);
+        String operationType = OperationLog.OperationType.HYBRID_SEARCH;
+
+        log.info("收到混合搜索请求: query={}, topK={}, filters={}",
+                request.getQuery(), request.getTopK(), request.getFilters());
+        List<SearchResult> results = textEmbeddingService.search(request);
+
+        long duration = System.currentTimeMillis() - startTime;
+
+        // 记录操作日志
+        Map<String, Object> requestParams = new HashMap<>();
+        requestParams.put("query", request.getQuery());
+        requestParams.put("topK", request.getTopK());
+        requestParams.put("filters", request.getFilters());
+
+        Map<String, Object> responseResult = new HashMap<>();
+        responseResult.put("resultCount", results.size());
+
+        logService.recordOperationLog(
+                operationType,
+                null,
+                null,
+                null,
+                requestParams,
+                responseResult,
+                OperationLog.Status.SUCCESS,
+                null,
+                duration,
+                ipAddress
+        );
+
+        return ResponseEntity.ok(results);
+    }
+
+    /**
+     * 获取客户端IP
+     */
+    private String getClientIp(HttpServletRequest request) {
+        String ip = request.getHeader("X-Forwarded-For");
+        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
+            ip = request.getHeader("X-Real-IP");
+        }
+        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
+            ip = request.getRemoteAddr();
+        }
+        if (ip != null && ip.contains(",")) {
+            ip = ip.split(",")[0].trim();
+        }
+        return ip;
+    }
+}

+ 26 - 0
schedule-embedding-api/src/main/java/cn/com/yusys/manager/exception/BusinessException.java

@@ -0,0 +1,26 @@
+package cn.com.yusys.manager.exception;
+
+import lombok.Getter;
+
+/**
+ * 业务异常
+ */
+@Getter
+public class BusinessException extends RuntimeException {
+
+    private int code;
+
+    public BusinessException(int code, String message) {
+        super(message);
+        this.code = code;
+    }
+
+    public BusinessException(String message) {
+        this(500, message);
+    }
+
+    public BusinessException(int code, String message, Throwable cause) {
+        super(message, cause);
+        this.code = code;
+    }
+}

+ 15 - 0
schedule-embedding-api/src/main/java/cn/com/yusys/manager/exception/ElasticsearchException.java

@@ -0,0 +1,15 @@
+package cn.com.yusys.manager.exception;
+
+/**
+ * Elasticsearch操作异常
+ */
+public class ElasticsearchException extends RuntimeException {
+
+    public ElasticsearchException(String message) {
+        super(message);
+    }
+
+    public ElasticsearchException(String message, Throwable cause) {
+        super(message, cause);
+    }
+}

+ 15 - 0
schedule-embedding-api/src/main/java/cn/com/yusys/manager/exception/EmbeddingException.java

@@ -0,0 +1,15 @@
+package cn.com.yusys.manager.exception;
+
+/**
+ * Embedding接口异常
+ */
+public class EmbeddingException extends RuntimeException {
+
+    public EmbeddingException(String message) {
+        super(message);
+    }
+
+    public EmbeddingException(String message, Throwable cause) {
+        super(message, cause);
+    }
+}

+ 98 - 0
schedule-embedding-api/src/main/java/cn/com/yusys/manager/exception/GlobalExceptionHandler.java

@@ -0,0 +1,98 @@
+package cn.com.yusys.manager.exception;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.FieldError;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 全局异常处理器
+ */
+@Slf4j
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+    /**
+     * 业务异常
+     */
+    @ExceptionHandler(BusinessException.class)
+    public ResponseEntity<Map<String, Object>> handleBusinessException(BusinessException e) {
+        log.warn("业务异常: code={}, message={}", e.getCode(), e.getMessage());
+        Map<String, Object> result = new HashMap<>();
+        result.put("success", false);
+        result.put("code", e.getCode());
+        result.put("message", e.getMessage());
+        result.put("timestamp", System.currentTimeMillis());
+        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(result);
+    }
+
+    /**
+     * 参数校验异常
+     */
+    @ExceptionHandler(MethodArgumentNotValidException.class)
+    public ResponseEntity<Map<String, Object>> handleValidationException(MethodArgumentNotValidException e) {
+        Map<String, String> errors = new HashMap<>();
+        e.getBindingResult().getAllErrors().forEach(error -> {
+            String fieldName = ((FieldError) error).getField();
+            String errorMessage = error.getDefaultMessage();
+            errors.put(fieldName, errorMessage);
+        });
+
+        log.warn("参数校验异常: errors={}", errors);
+        Map<String, Object> result = new HashMap<>();
+        result.put("success", false);
+        result.put("code", 400);
+        result.put("message", "参数校验失败");
+        result.put("errors", errors);
+        result.put("timestamp", System.currentTimeMillis());
+        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(result);
+    }
+
+    /**
+     * Embedding异常
+     */
+    @ExceptionHandler(EmbeddingException.class)
+    public ResponseEntity<Map<String, Object>> handleEmbeddingException(EmbeddingException e) {
+        log.error("Embedding异常: message={}", e.getMessage(), e);
+        Map<String, Object> result = new HashMap<>();
+        result.put("success", false);
+        result.put("code", 500);
+        result.put("message", "向量化服务异常: " + e.getMessage());
+        result.put("timestamp", System.currentTimeMillis());
+        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(result);
+    }
+
+    /**
+     * Elasticsearch异常
+     */
+    @ExceptionHandler(ElasticsearchException.class)
+    public ResponseEntity<Map<String, Object>> handleElasticsearchException(ElasticsearchException e) {
+        log.error("Elasticsearch异常: message={}", e.getMessage(), e);
+        Map<String, Object> result = new HashMap<>();
+        result.put("success", false);
+        result.put("code", 500);
+        result.put("message", "存储服务异常: " + e.getMessage());
+        result.put("timestamp", System.currentTimeMillis());
+        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(result);
+    }
+
+    /**
+     * 其他异常
+     */
+    @ExceptionHandler(Exception.class)
+    public ResponseEntity<Map<String, Object>> handleException(Exception e) {
+        log.error("系统异常: message={}", e.getMessage(), e);
+        Map<String, Object> result = new HashMap<>();
+        result.put("success", false);
+        result.put("code", 500);
+        result.put("message", "系统内部错误: " + e.getMessage());
+        result.put("timestamp", System.currentTimeMillis());
+        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(result);
+    }
+}

+ 117 - 0
schedule-embedding-api/src/main/java/cn/com/yusys/manager/listener/EmbeddingTaskListener.java

@@ -0,0 +1,117 @@
+package cn.com.yusys.manager.listener;
+
+import cn.com.yusys.manager.model.dto.EmbeddingTaskMessage;
+import cn.com.yusys.manager.model.dto.IndexRequest;
+import cn.com.yusys.manager.model.entity.service.TextEmbeddingService;
+import com.alibaba.fastjson.JSON;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.kafka.annotation.KafkaListener;
+import org.springframework.kafka.support.Acknowledgment;
+import org.springframework.kafka.support.KafkaHeaders;
+import org.springframework.messaging.handler.annotation.Header;
+import org.springframework.messaging.handler.annotation.Payload;
+import org.springframework.stereotype.Component;
+
+/**
+ * Kafka Embedding任务消费者
+ * 只有在 kafka.enabled=true 时才会生效
+ */
+@Slf4j
+@Component
+@ConditionalOnProperty(name = "kafka.enabled", havingValue = "true")
+public class EmbeddingTaskListener {
+
+    @Autowired
+    private TextEmbeddingService textEmbeddingService;
+
+    @Value("${kafka.topics.embedding}")
+    private String embeddingTopic;
+
+    /**
+     * 监听Embedding任务
+     * @param message 消息内容
+     * @param ack Acknowledgment
+     * @param topic 主题
+     * @param partition 分区
+     * @param offset 偏移量
+     */
+    @KafkaListener(
+            topics = "${kafka.topics.embedding}",
+            groupId = "${spring.kafka.consumer.group-id}",
+            containerFactory = "kafkaListenerContainerFactory"
+    )
+    public void onMessage(
+            @Payload String message,
+            Acknowledgment ack,
+            @Header(KafkaHeaders.RECEIVED_TOPIC) String topic,
+            @Header(KafkaHeaders.RECEIVED_PARTITION_ID) int partition,
+            @Header(KafkaHeaders.OFFSET) long offset) {
+
+        log.info("收到Embedding任务: topic={}, partition={}, offset={}, message={}",
+                topic, partition, offset, message);
+
+        try {
+            // 1. 解析消息
+            EmbeddingTaskMessage taskMessage = JSON.parseObject(message, EmbeddingTaskMessage.class);
+
+            // 2. 构建IndexRequest
+            IndexRequest request = IndexRequest.builder()
+                    .docId(taskMessage.getDocId())
+                    .fileName(taskMessage.getFileName())
+                    .fullText(taskMessage.getFullText())
+                    .filePath(taskMessage.getFilePath())
+                    .fileSize(taskMessage.getFileSize())
+                    .fileType(taskMessage.getFileType())
+                    .metadata((java.util.Map<String, Object>) taskMessage.getMetadata())
+                    .build();
+
+            // 3. 执行入库
+            textEmbeddingService.indexDocument(request);
+
+            // 4. 手动ACK
+            if (ack != null) {
+                ack.acknowledge();
+                log.info("任务处理成功并ACK: docId={}", taskMessage.getDocId());
+            }
+
+            // 5. 可选:回调通知
+            if (taskMessage.getCallbackUrl() != null && !taskMessage.getCallbackUrl().isEmpty()) {
+                sendCallback(taskMessage, true, "入库成功");
+            }
+
+        } catch (Exception e) {
+            log.error("处理Embedding任务失败: {}", e.getMessage(), e);
+
+            // 处理失败,可以选择不ACK(让Kafka重试)或ACK并记录失败
+            // 这里选择ACK,避免阻塞后续消息
+            if (ack != null) {
+                ack.acknowledge();
+            }
+
+            // 发送失败回调
+            try {
+                EmbeddingTaskMessage taskMessage = JSON.parseObject(message, EmbeddingTaskMessage.class);
+                if (taskMessage.getCallbackUrl() != null && !taskMessage.getCallbackUrl().isEmpty()) {
+                    sendCallback(taskMessage, false, e.getMessage());
+                }
+            } catch (Exception callbackException) {
+                log.error("发送失败回调异常: {}", callbackException.getMessage());
+            }
+        }
+    }
+
+    /**
+     * 发送回调通知
+     * @param taskMessage 任务消息
+     * @param success 是否成功
+     * @param message 消息
+     */
+    private void sendCallback(EmbeddingTaskMessage taskMessage, boolean success, String message) {
+        // TODO: 实现HTTP回调
+        log.info("发送回调通知: callbackUrl={}, success={}, message={}",
+                taskMessage.getCallbackUrl(), success, message);
+    }
+}

+ 27 - 0
schedule-embedding-api/src/main/java/cn/com/yusys/manager/model/dto/BatchIndexRequest.java

@@ -0,0 +1,27 @@
+package cn.com.yusys.manager.model.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import javax.validation.Valid;
+import javax.validation.constraints.NotEmpty;
+import java.util.List;
+
+/**
+ * 批量文档入库请求DTO
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class BatchIndexRequest {
+
+    /**
+     * 文档列表
+     */
+    @NotEmpty(message = "文档列表不能为空")
+    @Valid
+    private List<IndexRequest> items;
+}

+ 61 - 0
schedule-embedding-api/src/main/java/cn/com/yusys/manager/model/dto/EmbeddingTaskMessage.java

@@ -0,0 +1,61 @@
+package cn.com.yusys.manager.model.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * Kafka Embedding任务消息DTO
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class EmbeddingTaskMessage {
+
+    /**
+     * 文档ID
+     */
+    private String docId;
+
+    /**
+     * 文件名
+     */
+    private String fileName;
+
+    /**
+     * 全量文本
+     */
+    private String fullText;
+
+    /**
+     * 文件路径
+     */
+    private String filePath;
+
+    /**
+     * 文件大小
+     */
+    private Long fileSize;
+
+    /**
+     * 文件类型
+     */
+    private String fileType;
+
+    /**
+     * 元数据
+     */
+    private Object metadata;
+
+    /**
+     * 回调URL(可选)
+     */
+    private String callbackUrl;
+
+    /**
+     * 任务ID
+     */
+    private String taskId;
+}

+ 56 - 0
schedule-embedding-api/src/main/java/cn/com/yusys/manager/model/dto/IndexRequest.java

@@ -0,0 +1,56 @@
+package cn.com.yusys.manager.model.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import javax.validation.constraints.NotBlank;
+import java.util.Map;
+
+/**
+ * 文档入库请求DTO
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class IndexRequest {
+
+    /**
+     * 文档ID
+     */
+    @NotBlank(message = "文档ID不能为空")
+    private String docId;
+
+    /**
+     * 文件名
+     */
+    private String fileName;
+
+    /**
+     * 全量文本
+     */
+    @NotBlank(message = "全文内容不能为空")
+    private String fullText;
+
+    /**
+     * 文件路径
+     */
+    private String filePath;
+
+    /**
+     * 文件大小
+     */
+    private Long fileSize;
+
+    /**
+     * 文件类型
+     */
+    private String fileType;
+
+    /**
+     * 元数据(合同类型、甲方、乙方等)
+     */
+    private Map<String, Object> metadata;
+}

+ 36 - 0
schedule-embedding-api/src/main/java/cn/com/yusys/manager/model/dto/SearchRequest.java

@@ -0,0 +1,36 @@
+package cn.com.yusys.manager.model.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import javax.validation.constraints.NotBlank;
+import java.util.Map;
+
+/**
+ * 向量搜索请求DTO
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class SearchRequest {
+
+    /**
+     * 查询文本
+     */
+    @NotBlank(message = "查询文本不能为空")
+    private String query;
+
+    /**
+     * 返回Top-K结果
+     */
+    @Builder.Default
+    private Integer topK = 10;
+
+    /**
+     * 过滤条件(contractType、partyA等)
+     */
+    private Map<String, Object> filters;
+}

+ 48 - 0
schedule-embedding-api/src/main/java/cn/com/yusys/manager/model/dto/SearchResult.java

@@ -0,0 +1,48 @@
+package cn.com.yusys.manager.model.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.Map;
+
+/**
+ * 向量搜索结果DTO
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class SearchResult {
+
+    /**
+     * 文档ID
+     */
+    private String docId;
+
+    /**
+     * chunk ID
+     */
+    private String chunkId;
+
+    /**
+     * chunk序号
+     */
+    private Integer chunkIndex;
+
+    /**
+     * 内容
+     */
+    private String content;
+
+    /**
+     * 相似度分数
+     */
+    private Float score;
+
+    /**
+     * 元数据
+     */
+    private Map<String, Object> metadata;
+}

+ 48 - 0
schedule-embedding-api/src/main/java/cn/com/yusys/manager/model/entity/Chunk.java

@@ -0,0 +1,48 @@
+package cn.com.yusys.manager.model.entity;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.Map;
+
+/**
+ * 分段实体
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class Chunk {
+
+    /**
+     * 文档ID
+     */
+    private String docId;
+
+    /**
+     * chunk唯一标识
+     */
+    private String chunkId;
+
+    /**
+     * chunk序号
+     */
+    private Integer chunkIndex;
+
+    /**
+     * chunk内容
+     */
+    private String content;
+
+    /**
+     * 内容长度
+     */
+    private Integer contentLength;
+
+    /**
+     * 元数据
+     */
+    private Map<String, Object> metadata;
+}

+ 110 - 0
schedule-embedding-api/src/main/java/cn/com/yusys/manager/model/entity/KafkaProcessingLog.java

@@ -0,0 +1,110 @@
+package cn.com.yusys.manager.model.entity;
+
+import lombok.Data;
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.annotation.LastModifiedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import javax.persistence.*;
+import java.time.LocalDateTime;
+
+/**
+ * Kafka处理日志实体类
+ * 记录Kafka消息处理过程
+ */
+@Data
+@Entity
+@Table(name = "kafka_processing_log")
+@EntityListeners(AuditingEntityListener.class)
+public class KafkaProcessingLog {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Long id;
+
+    /**
+     * Kafka消息ID/任务ID
+     */
+    @Column(name = "message_id", length = 255)
+    private String messageId;
+
+    /**
+     * 文档ID
+     */
+    @Column(name = "doc_id", length = 255)
+    private String docId;
+
+    /**
+     * 任务类型: INDEX/UPDATE/DELETE
+     */
+    @Column(name = "task_type", length = 50)
+    private String taskType;
+
+    /**
+     * 处理阶段: RECEIVED/PROCESSING/EMBEDDING/STORING/COMPLETED/FAILED
+     */
+    @Column(name = "processing_stage", nullable = false, length = 50)
+    private String processingStage;
+
+    /**
+     * 执行状态: PROCESSING/SUCCESS/FAILED
+     */
+    @Column(name = "status", nullable = false, length = 20)
+    private String status = "PROCESSING";
+
+    /**
+     * 错误信息
+     */
+    @Column(name = "error_message", length = 1000)
+    private String errorMessage;
+
+    /**
+     * 执行耗时(毫秒)
+     */
+    @Column(name = "duration_ms")
+    private Long durationMs;
+
+    /**
+     * 创建时间
+     */
+    @CreatedDate
+    @Column(name = "create_time", nullable = false, updatable = false)
+    private LocalDateTime createTime;
+
+    /**
+     * 更新时间
+     */
+    @LastModifiedDate
+    @Column(name = "update_time", nullable = false)
+    private LocalDateTime updateTime;
+
+    /**
+     * 任务类型常量
+     */
+    public static final class TaskType {
+        public static final String INDEX = "INDEX";
+        public static final String UPDATE = "UPDATE";
+        public static final String DELETE = "DELETE";
+    }
+
+    /**
+     * 处理阶段常量
+     */
+    public static final class ProcessingStage {
+        public static final String RECEIVED = "RECEIVED";
+        public static final String PROCESSING = "PROCESSING";
+        public static final String EMBEDDING = "EMBEDDING";
+        public static final String STORING = "STORING";
+        public static final String COMPLETED = "COMPLETED";
+        public static final String FAILED = "FAILED";
+    }
+
+    /**
+     * 执行状态常量
+     */
+    public static final class Status {
+        public static final String PROCESSING = "PROCESSING";
+        public static final String SUCCESS = "SUCCESS";
+        public static final String FAILED = "FAILED";
+    }
+}

+ 111 - 0
schedule-embedding-api/src/main/java/cn/com/yusys/manager/model/entity/OperationLog.java

@@ -0,0 +1,111 @@
+package cn.com.yusys.manager.model.entity;
+
+import lombok.Data;
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import javax.persistence.*;
+import java.time.LocalDateTime;
+
+/**
+ * 操作日志实体类
+ * 记录所有接口调用的审计日志
+ */
+@Data
+@Entity
+@Table(name = "operation_log")
+@EntityListeners(AuditingEntityListener.class)
+public class OperationLog {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Long id;
+
+    /**
+     * 操作类型: INDEX/BATCH_INDEX/DELETE/SEARCH/HYBRID_SEARCH/GET_CHUNK/DELETE_CHUNK
+     */
+    @Column(name = "operation_type", nullable = false, length = 50)
+    private String operationType;
+
+    /**
+     * 文档ID
+     */
+    @Column(name = "doc_id", length = 255)
+    private String docId;
+
+    /**
+     * chunk ID
+     */
+    @Column(name = "chunk_id", length = 255)
+    private String chunkId;
+
+    /**
+     * 操作人/调用方
+     */
+    @Column(name = "operator", length = 100)
+    private String operator;
+
+    /**
+     * 请求参数 (JSON格式)
+     */
+    @Column(name = "request_params", columnDefinition = "TEXT")
+    private String requestParams;
+
+    /**
+     * 响应结果 (JSON格式)
+     */
+    @Column(name = "response_result", columnDefinition = "TEXT")
+    private String responseResult;
+
+    /**
+     * 执行状态: SUCCESS/FAILED
+     */
+    @Column(name = "status", nullable = false, length = 20)
+    private String status = "SUCCESS";
+
+    /**
+     * 错误信息
+     */
+    @Column(name = "error_message", length = 1000)
+    private String errorMessage;
+
+    /**
+     * 执行耗时(毫秒)
+     */
+    @Column(name = "duration_ms")
+    private Long durationMs;
+
+    /**
+     * IP地址
+     */
+    @Column(name = "ip_address", length = 50)
+    private String ipAddress;
+
+    /**
+     * 创建时间
+     */
+    @CreatedDate
+    @Column(name = "create_time", nullable = false, updatable = false)
+    private LocalDateTime createTime;
+
+    /**
+     * 操作类型常量
+     */
+    public static final class OperationType {
+        public static final String INDEX = "INDEX";
+        public static final String BATCH_INDEX = "BATCH_INDEX";
+        public static final String DELETE = "DELETE";
+        public static final String SEARCH = "SEARCH";
+        public static final String HYBRID_SEARCH = "HYBRID_SEARCH";
+        public static final String GET_CHUNK = "GET_CHUNK";
+        public static final String DELETE_CHUNK = "DELETE_CHUNK";
+    }
+
+    /**
+     * 执行状态常量
+     */
+    public static final class Status {
+        public static final String SUCCESS = "SUCCESS";
+        public static final String FAILED = "FAILED";
+    }
+}

+ 145 - 0
schedule-embedding-api/src/main/java/cn/com/yusys/manager/model/entity/TextDocument.java

@@ -0,0 +1,145 @@
+package cn.com.yusys.manager.model.entity;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 文档实体(对应ES文档结构)
+ * 设计原则:只保留核心必需字段,所有业务字段完全动态化
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class TextDocument {
+
+    // ==================== 核心固定字段(系统必需,不会变化) ====================
+
+    /**
+     * 文档ID
+     * 格式:文件ID,例如 "contract-001", "audio-001"
+     */
+    private String docId;
+
+    /**
+     * chunk唯一标识
+     * 格式:文件ID + "_" + chunk序号,例如 "contract-001_chunk_0"
+     */
+    private String chunkId;
+
+    /**
+     * chunk序号
+     */
+    private Integer chunkIndex;
+
+    /**
+     * 内容文本
+     * 可以是:合同文本、音频转写文本、视频字幕等
+     */
+    private String content;
+
+    /**
+     * 4096维向量
+     */
+    private float[] embedding;
+
+    /**
+     * 创建时间
+     */
+    private Date createTime;
+
+    /**
+     * 更新时间
+     */
+    private Date updateTime;
+
+    // ==================== 技术元数据(文件基本信息,相对固定) ====================
+
+    /**
+     * 文件路径
+     */
+    private String filePath;
+
+    /**
+     * 文件大小(字节)
+     */
+    private Long fileSize;
+
+    /**
+     * 文件大小(MB)
+     */
+    private Double fileSizeMb;
+
+    /**
+     * 文件类型
+     * 例如:pdf, mp4, mp3, docx 等
+     */
+    private String fileType;
+
+    /**
+     * 文档总chunk数
+     */
+    private Integer chunkCount;
+
+    // ==================== 动态业务字段(完全动态,根据实际数据类型加载) ====================
+
+    /**
+     * 扩展字段 - 存储所有业务数据、标签、元数据
+     *
+     * 使用场景:
+     * 1. 合同类文档:contractType, partyA, partyB, contractAmount, signDate 等
+     * 2. 音频类文档:duration, speaker, language, transcript 等
+     * 3. 视频类文档:resolution, frameRate, duration, subtitles 等
+     * 4. 标签类数据:tagLevel1, tagLevel2, tagLevel3 等
+     * 5. 任何自定义字段
+     *
+     * 设计原则:
+     * - 所有非核心字段都存放在这里
+     * - 新增字段无需修改代码
+     * - 完全动态化,根据数据类型灵活扩展
+     */
+    @Builder.Default
+    private Map<String, Object> extendedFields = new HashMap<>();
+
+    /**
+     * 添加扩展字段
+     */
+    public void addExtendedField(String key, Object value) {
+        if (this.extendedFields == null) {
+            this.extendedFields = new HashMap<>();
+        }
+        this.extendedFields.put(key, value);
+    }
+
+    /**
+     * 批量添加扩展字段
+     */
+    public void addExtendedFields(Map<String, Object> fields) {
+        if (this.extendedFields == null) {
+            this.extendedFields = new HashMap<>();
+        }
+        if (fields != null) {
+            this.extendedFields.putAll(fields);
+        }
+    }
+
+    /**
+     * 获取扩展字段
+     */
+    public Object getExtendedField(String key) {
+        return this.extendedFields != null ? this.extendedFields.get(key) : null;
+    }
+
+    /**
+     * 获取所有扩展字段
+     */
+    public Map<String, Object> getAllExtendedFields() {
+        return this.extendedFields != null ? this.extendedFields : new HashMap<>();
+    }
+}

+ 50 - 0
schedule-embedding-api/src/main/java/cn/com/yusys/manager/model/entity/service/DocumentService.java

@@ -0,0 +1,50 @@
+package cn.com.yusys.manager.model.entity.service;
+
+import cn.com.yusys.manager.model.entity.TextDocument;
+import cn.com.yusys.manager.repository.ElasticsearchRepository;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+/**
+ * 文档管理服务
+ */
+@Slf4j
+@Service
+public class DocumentService {
+
+    @Autowired
+    private ElasticsearchRepository elasticsearchRepository;
+
+    /**
+     * 查询文档的所有chunks
+     * @param docId 文档ID
+     * @return 文档列表
+     */
+    public List<TextDocument> getDocument(String docId) {
+        log.info("查询文档: docId={}", docId);
+        return elasticsearchRepository.findByDocId(docId);
+    }
+
+    /**
+     * 删除文档的所有chunks
+     * @param docId 文档ID
+     * @return 删除的文档数量
+     */
+    public long deleteDocument(String docId) {
+        log.info("删除文档: docId={}", docId);
+        return elasticsearchRepository.deleteByDocId(docId);
+    }
+
+    /**
+     * 删除单个chunk
+     * @param chunkId chunk ID
+     * @return 是否成功
+     */
+    public boolean deleteChunk(String chunkId) {
+        log.info("删除chunk: chunkId={}", chunkId);
+        return elasticsearchRepository.deleteDocument(chunkId);
+    }
+}

+ 204 - 0
schedule-embedding-api/src/main/java/cn/com/yusys/manager/model/entity/service/TextEmbeddingService.java

@@ -0,0 +1,204 @@
+package cn.com.yusys.manager.model.entity.service;
+
+import cn.com.yusys.manager.client.EmbeddingClient;
+import cn.com.yusys.manager.model.dto.BatchIndexRequest;
+import cn.com.yusys.manager.model.dto.IndexRequest;
+import cn.com.yusys.manager.model.dto.SearchRequest;
+import cn.com.yusys.manager.model.dto.SearchResult;
+import cn.com.yusys.manager.model.entity.Chunk;
+import cn.com.yusys.manager.model.entity.TextDocument;
+import cn.com.yusys.manager.model.vo.BatchIndexResult;
+import cn.com.yusys.manager.model.vo.IndexResult;
+import cn.com.yusys.manager.repository.ElasticsearchRepository;
+import cn.com.yusys.manager.splitter.TextSplitter;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 文本向量化服务
+ */
+@Slf4j
+@Service
+public class TextEmbeddingService {
+
+    @Autowired
+    private TextSplitter textSplitter;
+
+    @Autowired
+    private EmbeddingClient embeddingClient;
+
+    @Autowired
+    private ElasticsearchRepository elasticsearchRepository;
+
+    /**
+     * 单个文档入库
+     * @param request 入库请求
+     * @return 入库结果
+     */
+    public IndexResult indexDocument(IndexRequest request) {
+        try {
+            log.info("开始处理文档入库: docId={}", request.getDocId());
+
+            // 1. 文本分段
+            List<Chunk> chunks = textSplitter.split(request.getFullText(), request.getDocId());
+            if (chunks.isEmpty()) {
+                return IndexResult.failure(request.getDocId(), "文本分段结果为空");
+            }
+
+            // 2. 批量向量化
+            List<String> texts = chunks.stream()
+                    .map(Chunk::getContent)
+                    .collect(Collectors.toList());
+            List<float[]> embeddings = embeddingClient.getBatchEmbeddings(texts);
+
+            if (embeddings.size() != chunks.size()) {
+                return IndexResult.failure(request.getDocId(), "向量化结果数量不匹配");
+            }
+
+            // 3. 构建文档列表
+            List<TextDocument> documents = new ArrayList<>();
+            Date now = new Date();
+            for (int i = 0; i < chunks.size(); i++) {
+                Chunk chunk = chunks.get(i);
+                TextDocument doc = buildTextDocument(chunk, embeddings.get(i), request, now);
+                documents.add(doc);
+            }
+
+            // 4. 批量入库ES
+            elasticsearchRepository.bulkIndex(documents);
+
+            log.info("文档入库成功: docId={}, chunkCount={}", request.getDocId(), chunks.size());
+            return IndexResult.success(request.getDocId(), chunks.size());
+        } catch (Exception e) {
+            log.error("文档入库失败: docId={}, error={}", request.getDocId(), e.getMessage(), e);
+            return IndexResult.failure(request.getDocId(), e.getMessage());
+        }
+    }
+
+    /**
+     * 批量文档入库
+     * @param request 批量入库请求
+     * @return 批量入库结果
+     */
+    public BatchIndexResult batchIndexDocuments(BatchIndexRequest request) {
+        log.info("开始处理批量文档入库: totalCount={}", request.getItems().size());
+
+        int totalCount = request.getItems().size();
+        int successCount = 0;
+        int failedCount = 0;
+        List<BatchIndexResult.FailedItem> failedItems = new ArrayList<>();
+
+        for (IndexRequest item : request.getItems()) {
+            try {
+                IndexResult result = indexDocument(item);
+                if (result.getSuccess()) {
+                    successCount++;
+                } else {
+                    failedCount++;
+                    failedItems.add(BatchIndexResult.FailedItem.builder()
+                            .docId(item.getDocId())
+                            .error(result.getError())
+                            .build());
+                }
+            } catch (Exception e) {
+                failedCount++;
+                failedItems.add(BatchIndexResult.FailedItem.builder()
+                        .docId(item.getDocId())
+                        .error(e.getMessage())
+                        .build());
+            }
+        }
+
+        log.info("批量文档入库完成: totalCount={}, successCount={}, failedCount={}",
+                totalCount, successCount, failedCount);
+
+        return BatchIndexResult.builder()
+                .totalCount(totalCount)
+                .successCount(successCount)
+                .failedCount(failedCount)
+                .failedItems(failedItems)
+                .build();
+    }
+
+    /**
+     * 向量搜索
+     * @param request 搜索请求
+     * @return 搜索结果
+     */
+    public List<SearchResult> search(SearchRequest request) {
+        try {
+            log.info("开始向量搜索: query={}, topK={}", request.getQuery(), request.getTopK());
+
+            // 1. 查询文本向量化
+            float[] queryVector = embeddingClient.getEmbedding(request.getQuery());
+
+            // 2. 根据是否有过滤条件选择搜索方式
+            List<SearchResult> results;
+            if (request.getFilters() != null && !request.getFilters().isEmpty()) {
+                results = elasticsearchRepository.hybridSearch(
+                        queryVector,
+                        request.getTopK(),
+                        request.getFilters()
+                );
+            } else {
+                results = elasticsearchRepository.knnSearch(
+                        queryVector,
+                        request.getTopK()
+                );
+            }
+
+            log.info("向量搜索完成: resultCount={}", results.size());
+            return results;
+        } catch (Exception e) {
+            log.error("向量搜索失败: {}", e.getMessage(), e);
+            throw new RuntimeException("向量搜索失败: " + e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 构建TextDocument对象
+     */
+    /**
+     * 构建 TextDocument
+     *
+     * 设计说明:
+     * 1. 固定字段直接设置(docId, chunkId, content, embedding 等)
+     * 2. 所有 metadata 直接放入 extendedFields,无需逐个字段判断
+     * 3. 常用业务字段可从 extendedFields 快速访问
+     * 4. 新增字段不需要修改此方法
+     */
+    private TextDocument buildTextDocument(Chunk chunk, float[] embedding,
+                                          IndexRequest request, Date now) {
+        // 1. 设置固定字段(核心字段)
+        TextDocument doc = TextDocument.builder()
+                .docId(chunk.getDocId())
+                .chunkId(chunk.getChunkId())
+                .chunkIndex(chunk.getChunkIndex())
+                .content(chunk.getContent())
+                .embedding(embedding)
+                .filePath(request.getFilePath())
+                .fileSize(request.getFileSize())
+                .fileType(request.getFileType())
+                .chunkCount(0)
+                .createTime(now)
+                .updateTime(now)
+                .build();
+
+        // 2. 计算文件大小(MB)
+        if (request.getFileSize() != null) {
+            doc.setFileSizeMb(request.getFileSize() / (1024.0 * 1024.0));
+        }
+
+        // 3. 处理所有 metadata(自动扩展,无需逐个判断)
+        // 所有业务字段、标签、动态字段都直接存入 extendedFields
+        if (request.getMetadata() != null && !request.getMetadata().isEmpty()) {
+            doc.addExtendedFields(request.getMetadata());
+        }
+
+        return doc;
+    }
+}

+ 54 - 0
schedule-embedding-api/src/main/java/cn/com/yusys/manager/model/vo/BatchIndexResult.java

@@ -0,0 +1,54 @@
+package cn.com.yusys.manager.model.vo;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * 批量文档入库结果VO
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class BatchIndexResult {
+
+    /**
+     * 总文档数
+     */
+    private Integer totalCount;
+
+    /**
+     * 成功文档数
+     */
+    private Integer successCount;
+
+    /**
+     * 失败文档数
+     */
+    private Integer failedCount;
+
+    /**
+     * 失败文档列表
+     */
+    private List<FailedItem> failedItems;
+
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    public static class FailedItem {
+        /**
+         * 文档ID
+         */
+        private String docId;
+
+        /**
+         * 错误信息
+         */
+        private String error;
+    }
+}

+ 60 - 0
schedule-embedding-api/src/main/java/cn/com/yusys/manager/model/vo/IndexResult.java

@@ -0,0 +1,60 @@
+package cn.com.yusys.manager.model.vo;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 文档入库结果VO
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class IndexResult {
+
+    /**
+     * 是否成功
+     */
+    private Boolean success;
+
+    /**
+     * 文档ID
+     */
+    private String docId;
+
+    /**
+     * chunk数量
+     */
+    private Integer chunkCount;
+
+    /**
+     * 消息
+     */
+    private String message;
+
+    /**
+     * 错误信息(失败时)
+     */
+    private String error;
+
+    public static IndexResult success(String docId, Integer chunkCount) {
+        return IndexResult.builder()
+                .success(true)
+                .docId(docId)
+                .chunkCount(chunkCount)
+                .message("文档入库成功")
+                .build();
+    }
+
+    public static IndexResult failure(String docId, String error) {
+        return IndexResult.builder()
+                .success(false)
+                .docId(docId)
+                .chunkCount(0)
+                .message("文档入库失败")
+                .error(error)
+                .build();
+    }
+}

+ 512 - 0
schedule-embedding-api/src/main/java/cn/com/yusys/manager/repository/ElasticsearchRepository.java

@@ -0,0 +1,512 @@
+package cn.com.yusys.manager.repository;
+
+import cn.com.yusys.manager.model.dto.SearchResult;
+import cn.com.yusys.manager.model.entity.TextDocument;
+import cn.com.yusys.manager.exception.ElasticsearchException;
+import co.elastic.clients.elasticsearch.ElasticsearchClient;
+
+import co.elastic.clients.elasticsearch._types.SortOrder;
+import co.elastic.clients.elasticsearch._types.mapping.DynamicMapping;
+import co.elastic.clients.elasticsearch._types.query_dsl.*;
+import co.elastic.clients.elasticsearch.core.*;
+import co.elastic.clients.elasticsearch.core.bulk.BulkOperation;
+import co.elastic.clients.elasticsearch.core.search.Hit;
+import co.elastic.clients.elasticsearch.indices.CreateIndexResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.PostConstruct;
+import java.util.*;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+/**
+ * Elasticsearch操作封装
+ * 使用ES 8.x新的Java客户端,支持原生KNN查询
+ * 优化版本:充分利用 JDK 17 特性
+ */
+@Slf4j
+@Component
+public class ElasticsearchRepository {
+
+    @Autowired
+    private ElasticsearchClient client;
+
+    @Value("${elasticsearch.index.name}")
+    private String indexName;
+
+    @Value("${elasticsearch.index.shards:3}")
+    private Integer shards;
+
+    @Value("${elasticsearch.index.replicas:1}")
+    private Integer replicas;
+
+    /**
+     * 启动时检查索引是否存在,不存在则创建
+     */
+    @PostConstruct
+    public void initIndex() {
+        try {
+            if (!indexExists()) {
+                log.info("索引 {} 不存在,开始创建...", indexName);
+                createIndex();
+                log.info("索引 {} 创建成功", indexName);
+            } else {
+                log.info("索引 {} 已存在", indexName);
+            }
+        } catch (Exception e) {
+            log.error("索引初始化失败: {}", e.getMessage(), e);
+            throw new ElasticsearchException("索引初始化失败", e);
+        }
+    }
+
+    /**
+     * 检查索引是否存在
+     */
+    public boolean indexExists() {
+        try {
+            return client.indices()
+                    .exists(e -> e.index(indexName))
+                    .value();
+        } catch (Exception e) {
+            return false;
+        }
+    }
+
+    /**
+     * 创建索引
+     */
+    public boolean createIndex() {
+        try {
+            CreateIndexResponse response = client.indices()
+                    .create(c -> c
+                            .index(indexName)
+                            .settings(s -> s
+                                    .numberOfShards(String.valueOf(shards))
+                                    .numberOfReplicas(String.valueOf(replicas))
+                            )
+                            .mappings(m -> m
+                                    // 启用动态映射,自动识别任意新字段(包括metadata)
+                                    .dynamic(DynamicMapping.True)
+                                    // 核心固定字段(必须显式定义)
+                                    .properties("doc_id", p -> p.keyword(k -> k))
+                                    .properties("chunk_id", p -> p.keyword(k -> k))
+                                    .properties("chunk_index", p -> p.long_(l -> l))
+                                    .properties("content", p -> p.text(t -> t.analyzer("standard")))
+                                    // 向量字段(4096维,余弦相似度)
+                                    .properties("embedding", p -> p.denseVector(dv -> dv
+                                            .dims(4096)
+                                            .index(true)
+                                            .similarity("cosine")
+                                    ))
+                                    // 时间戳
+                                    .properties("create_time", p -> p.date(d -> d.format("yyyy-MM-dd'T'HH:mm:ss")))
+                                    .properties("update_time", p -> p.date(d -> d.format("yyyy-MM-dd'T'HH:mm:ss")))
+                            )
+                    );
+
+            log.info("创建索引 {}, acknowledged: {}", indexName, response.acknowledged());
+            return response.acknowledged();
+        } catch (Exception e) {
+            log.error("创建索引失败: {}", e.getMessage(), e);
+            throw new ElasticsearchException("创建索引失败", e);
+        }
+    }
+
+    /**
+     * 索引单个文档
+     */
+    public String indexDocument(TextDocument document) {
+        try {
+            IndexResponse response = client.index(i -> i
+                    .index(indexName)
+                    .id(document.getChunkId())
+                    .document(buildSourceMap(document))
+            );
+
+            log.debug("文档 {} 写入成功", document.getChunkId());
+            return response.id();
+        } catch (Exception e) {
+            log.error("索引文档失败: {}", e.getMessage(), e);
+            throw new ElasticsearchException("索引文档失败", e);
+        }
+    }
+
+    /**
+     * 批量索引文档
+     */
+    public BulkResponse bulkIndex(List<TextDocument> documents) {
+        try {
+            List<BulkOperation> operations = documents.stream()
+                    .map(doc -> BulkOperation.of(b -> b
+                            .index(idx -> idx
+                                    .index(indexName)
+                                    .id(doc.getChunkId())
+                                    .document(buildSourceMap(doc))
+                            )
+                    ))
+                    .collect(Collectors.toList());
+
+            BulkRequest bulkRequest = new BulkRequest.Builder()
+                    .operations(operations)
+                    .build();
+
+            co.elastic.clients.elasticsearch.core.BulkResponse response = client.bulk(bulkRequest);
+
+            if (response.errors()) {
+                String errorMsg = "批量写入ES失败";
+                log.error("{},总数: {}", errorMsg, documents.size());
+                log.error("BulkResponse详情: {}", response.toString());
+
+                throw new ElasticsearchException(errorMsg + ",请查看日志获取详细信息");
+            } else {
+                log.info("批量写入 {} 个文档成功", documents.size());
+            }
+
+            // 转换为旧的BulkResponse格式以保持兼容性
+            return new BulkResponse(response.errors(), documents.size());
+        } catch (Exception e) {
+            log.error("批量索引失败: {}", e.getMessage(), e);
+            throw new ElasticsearchException("批量索引失败", e);
+        }
+    }
+
+    /**
+     * KNN向量搜索(ES 8.x原生支持)
+     * 使用 JDK 17 的特性优化代码
+     */
+    public List<SearchResult> knnSearch(float[] queryVector, int topK) {
+        try {
+            // 使用 Stream API 将 float[] 转换为 List<Float>
+            List<Float> vectorList = FloatBinarySearchResult(queryVector);
+
+            SearchResponse<Map> response = client.search(s -> s
+                            .index(indexName)
+                            .size(topK)
+                            .knn(k -> k
+                                    .field("embedding")
+                                    .queryVector(vectorList)
+                                    .k(topK)
+                                    .numCandidates(Math.min(topK * 10, 10000))
+                            ),
+                    Map.class
+            );
+
+            return parseSearchResponse(response);
+        } catch (Exception e) {
+            log.error("KNN搜索失败: {}", e.getMessage(), e);
+            throw new ElasticsearchException("KNN搜索失败", e);
+        }
+    }
+
+    /**
+     * 混合搜索(KNN + 业务过滤)
+     * ES 8.x 中 KNN 不能放在 bool 查询中,需要使用 filter 参数
+     */
+    public List<SearchResult> hybridSearch(float[] queryVector, int topK, Map<String, Object> filters) {
+        try {
+            List<Float> vectorList = FloatBinarySearchResult(queryVector);
+
+            SearchRequest.Builder requestBuilder = new SearchRequest.Builder()
+                    .index(indexName)
+                    .size(topK);
+
+            // 先执行 KNN 查询
+            requestBuilder.knn(k -> k
+                    .field("embedding")
+                    .queryVector(vectorList)
+                    .k(topK)
+                    .numCandidates(Math.min(topK * 10, 10000))
+            );
+
+            // 如果有过滤条件,添加 post_filter
+            if (filters != null && !filters.isEmpty()) {
+                List<Query> filterQueries = filters.entrySet().stream()
+                        .map(e -> Query.of(q -> q
+                                .term(t -> t
+                                        .field(e.getKey())
+                                        .value(e.getValue().toString())
+                                )
+                        ))
+                        .collect(Collectors.toList());
+
+                // 使用 post_filter 过滤结果(在 KNN 搜索后应用)
+                if (filterQueries.size() == 1) {
+                    requestBuilder.postFilter(filterQueries.get(0));
+                } else {
+                    requestBuilder.postFilter(q -> q
+                            .bool(b -> b
+                                    .filter(filterQueries)
+                            )
+                    );
+                }
+            }
+
+            SearchResponse<Map> response = client.search(requestBuilder.build(), Map.class);
+            return parseSearchResponse(response);
+        } catch (Exception e) {
+            log.error("混合搜索失败: {}", e.getMessage(), e);
+            throw new ElasticsearchException("混合搜索失败", e);
+        }
+    }
+
+    /**
+     * 按docId查询所有chunks
+     */
+    public List<TextDocument> findByDocId(String docId) {
+        try {
+            SearchResponse<Map> response = client.search(s -> s
+                            .index(indexName)
+                            .query(q -> q
+                                    .term(t -> t
+                                            .field("doc_id")
+                                            .value(docId)
+                                    )
+                            )
+                            .sort(sort -> sort
+                                    .field(f -> f
+                                            .field("chunk_index")
+                                            .order(SortOrder.Asc)
+                                    )
+                            )
+                            .size(10000)
+                    , Map.class
+            );
+
+            return response.hits().hits().stream()
+                    .map(this::parseSearchHit)
+                    .collect(Collectors.toList());
+        } catch (Exception e) {
+            log.error("按docId查询失败: {}", e.getMessage(), e);
+            throw new ElasticsearchException("按docId查询失败", e);
+        }
+    }
+
+    /**
+     * 按docId删除所有chunks
+     */
+    public long deleteByDocId(String docId) {
+        try {
+            // 先查询所有匹配的chunks
+            List<TextDocument> documents = findByDocId(docId);
+
+            if (documents.isEmpty()) {
+                log.info("文档 {} 没有找到chunks", docId);
+                return 0;
+            }
+
+            // 批量删除
+            List<BulkOperation> operations = documents.stream()
+                    .map(doc -> BulkOperation.of(b -> b
+                            .delete(d -> d
+                                    .index(indexName)
+                                    .id(doc.getChunkId())
+                            )
+                    ))
+                    .collect(Collectors.toList());
+
+            BulkRequest bulkRequest = new BulkRequest.Builder()
+                    .operations(operations)
+                    .build();
+
+            co.elastic.clients.elasticsearch.core.BulkResponse response = client.bulk(bulkRequest);
+
+            if (response.errors()) {
+                log.warn("批量删除部分失败");
+            } else {
+                log.info("成功删除文档 {} 的 {} 个chunks", docId, documents.size());
+            }
+
+            return documents.size();
+        } catch (Exception e) {
+            log.error("删除文档失败: {}", e.getMessage(), e);
+            throw new ElasticsearchException("删除文档失败", e);
+        }
+    }
+
+    /**
+     * 删除单个文档
+     */
+    public boolean deleteDocument(String chunkId) {
+        try {
+            DeleteResponse response = client.delete(d -> d
+                    .index(indexName)
+                    .id(chunkId)
+            );
+
+            return response.result().name().equals("deleted");
+        } catch (Exception e) {
+            log.error("删除文档失败: {}", e.getMessage(), e);
+            throw new ElasticsearchException("删除文档失败", e);
+        }
+    }
+
+    /**
+     * 构建ES文档source map
+     *
+     * 设计说明:
+     * 1. 固定字段(doc_id, chunk_id, content, embedding 等)直接设置
+     * 2. 技术元数据(file_path, file_size 等)直接设置
+     * 3. extendedFields 的所有字段自动合并到 ES map
+     * 4. 新增字段不需要修改此方法
+     */
+    private Map<String, Object> buildSourceMap(TextDocument document) {
+        Map<String, Object> map = new HashMap<>();
+
+        // 1. 核心固定字段
+        map.put("doc_id", document.getDocId());
+        map.put("chunk_id", document.getChunkId());
+        map.put("chunk_index", document.getChunkIndex());
+        map.put("content", document.getContent());
+        map.put("embedding", document.getEmbedding());
+
+        // 2. 技术元数据
+        map.put("file_path", document.getFilePath());
+        map.put("file_size", document.getFileSize());
+        map.put("file_size_mb", document.getFileSizeMb());
+        map.put("file_type", document.getFileType());
+        map.put("chunk_count", document.getChunkCount());
+
+        // 3. 时间字段(格式化)
+        java.text.SimpleDateFormat dateFormat = new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss");
+        map.put("create_time", document.getCreateTime() != null ? dateFormat.format(document.getCreateTime()) : null);
+        map.put("update_time", document.getUpdateTime() != null ? dateFormat.format(document.getUpdateTime()) : null);
+
+        // 4. 合并所有扩展字段(业务字段、标签、动态字段)
+        // 这些字段包括:contract_type, party_a, tags, 以及任何自定义字段
+        if (document.getAllExtendedFields() != null && !document.getAllExtendedFields().isEmpty()) {
+            map.putAll(document.getAllExtendedFields());
+        }
+
+        return map;
+    }
+
+    /**
+     * 解析搜索响应
+     * 公共字段放在顶层,metadata只保留业务扩展字段
+     */
+    private List<SearchResult> parseSearchResponse(SearchResponse<Map> response) {
+        // 公共字段列表(从metadata中移除)
+        String[] coreFields = {"doc_id", "chunk_id", "chunk_index", "content",
+                "file_path", "file_size", "file_size_mb", "file_type", "chunk_count",
+                "create_time", "update_time", "embedding"};
+
+        return response.hits().hits().stream()
+                .map(hit -> {
+                    Map<String, Object> sourceMap = new HashMap<>(hit.source());
+
+                    // 不返回 embedding 字段,节省带宽
+                    sourceMap.remove("embedding");
+
+                    // 提取公共字段,构建纯业务metadata
+                    Map<String, Object> metadata = new HashMap<>(sourceMap);
+                    for (String field : coreFields) {
+                        metadata.remove(field);
+                    }
+
+                    return SearchResult.builder()
+                            .docId((String) sourceMap.get("doc_id"))
+                            .chunkId((String) sourceMap.get("chunk_id"))
+                            .chunkIndex((Integer) sourceMap.get("chunk_index"))
+                            .content((String) sourceMap.get("content"))
+                            .score((float) (hit.score() != null ? hit.score() : 0.0))
+                            .metadata(metadata)
+                            .build();
+                })
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * 解析搜索命中项
+     * 只设置核心字段,其他所有字段都放入 extendedFields
+     */
+    private TextDocument parseSearchHit(Hit<Map> hit) {
+        Map<String, Object> sourceMap = hit.source();
+
+        // 解析日期字段
+        java.text.SimpleDateFormat dateFormat = new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss");
+        Date createTime = null;
+        Date updateTime = null;
+
+        try {
+            String createTimeStr = (String) sourceMap.get("create_time");
+            if (createTimeStr != null) {
+                createTime = dateFormat.parse(createTimeStr);
+            }
+            String updateTimeStr = (String) sourceMap.get("update_time");
+            if (updateTimeStr != null) {
+                updateTime = dateFormat.parse(updateTimeStr);
+            }
+        } catch (java.text.ParseException e) {
+            log.warn("解析日期失败: {}", e.getMessage());
+        }
+
+        // 构建TextDocument,只设置核心字段
+        TextDocument doc = TextDocument.builder()
+                .docId((String) sourceMap.get("doc_id"))
+                .chunkId((String) sourceMap.get("chunk_id"))
+                .chunkIndex((Integer) sourceMap.get("chunk_index"))
+                .content((String) sourceMap.get("content"))
+                .filePath((String) sourceMap.get("file_path"))
+                .fileType((String) sourceMap.get("file_type"))
+                .createTime(createTime)
+                .updateTime(updateTime)
+                .build();
+
+        // 所有其他字段都放入 extendedFields
+        // 排除核心字段后,剩余的全部是业务字段
+        String[] coreFields = {"doc_id", "chunk_id", "chunk_index", "content", "embedding",
+                               "file_path", "file_size", "file_size_mb", "file_type", "chunk_count",
+                               "create_time", "update_time"};
+
+        for (Map.Entry<String, Object> entry : sourceMap.entrySet()) {
+            String key = entry.getKey();
+            // 跳过核心字段
+            boolean isCoreField = false;
+            for (String coreField : coreFields) {
+                if (coreField.equals(key)) {
+                    isCoreField = true;
+                    break;
+                }
+            }
+            if (!isCoreField) {
+                doc.addExtendedField(key, entry.getValue());
+            }
+        }
+
+        return doc;
+    }
+
+    /**
+     * 工具方法:将 float[] 转换为 List<Float>
+     * 使用 JDK 17 的 Stream API
+     */
+    private List<Float> FloatBinarySearchResult(float[] vector) {
+        return IntStream.range(0, vector.length)
+                .mapToObj(i -> vector[i])
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * 简化的BulkResponse类(兼容性)
+     */
+    public static class BulkResponse {
+        private final boolean hasFailures;
+        private final int itemCount;
+
+        public BulkResponse(boolean hasFailures, int itemCount) {
+            this.hasFailures = hasFailures;
+            this.itemCount = itemCount;
+        }
+
+        public boolean hasFailures() {
+            return hasFailures;
+        }
+
+        public int getItemCount() {
+            return itemCount;
+        }
+    }
+}

+ 51 - 0
schedule-embedding-api/src/main/java/cn/com/yusys/manager/repository/KafkaProcessingLogRepository.java

@@ -0,0 +1,51 @@
+package cn.com.yusys.manager.repository;
+
+import cn.com.yusys.manager.model.entity.KafkaProcessingLog;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Kafka处理日志 Repository
+ */
+@Repository
+public interface KafkaProcessingLogRepository extends JpaRepository<KafkaProcessingLog, Long> {
+
+    /**
+     * 根据消息ID查询处理日志
+     */
+    Optional<KafkaProcessingLog> findByMessageId(String messageId);
+
+    /**
+     * 根据文档ID查询处理日志
+     */
+    List<KafkaProcessingLog> findByDocIdOrderByCreateTimeDesc(String docId);
+
+    /**
+     * 根据处理阶段查询处理日志
+     */
+    List<KafkaProcessingLog> findByProcessingStageOrderByCreateTimeDesc(String processingStage);
+
+    /**
+     * 根据状态查询处理日志
+     */
+    List<KafkaProcessingLog> findByStatusOrderByCreateTimeDesc(String status);
+
+    /**
+     * 根据时间范围查询处理日志
+     */
+    List<KafkaProcessingLog> findByCreateTimeBetweenOrderByCreateTimeDesc(LocalDateTime startTime, LocalDateTime endTime);
+
+    /**
+     * 根据消息ID和文档ID查询
+     */
+    List<KafkaProcessingLog> findByMessageIdAndDocIdOrderByCreateTimeDesc(String messageId, String docId);
+
+    /**
+     * 查询最新的处理日志
+     */
+    List<KafkaProcessingLog> findTop100ByOrderByCreateTimeDesc();
+}

+ 45 - 0
schedule-embedding-api/src/main/java/cn/com/yusys/manager/repository/OperationLogRepository.java

@@ -0,0 +1,45 @@
+package cn.com.yusys.manager.repository;
+
+import cn.com.yusys.manager.model.entity.OperationLog;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 操作日志 Repository
+ */
+@Repository
+public interface OperationLogRepository extends JpaRepository<OperationLog, Long> {
+
+    /**
+     * 根据文档ID查询操作日志
+     */
+    List<OperationLog> findByDocIdOrderByCreateTimeDesc(String docId);
+
+    /**
+     * 根据操作类型查询操作日志
+     */
+    List<OperationLog> findByOperationTypeOrderByCreateTimeDesc(String operationType);
+
+    /**
+     * 根据状态查询操作日志
+     */
+    List<OperationLog> findByStatusOrderByCreateTimeDesc(String status);
+
+    /**
+     * 根据时间范围查询操作日志
+     */
+    List<OperationLog> findByCreateTimeBetweenOrderByCreateTimeDesc(LocalDateTime startTime, LocalDateTime endTime);
+
+    /**
+     * 根据操作类型和文档ID查询
+     */
+    List<OperationLog> findByOperationTypeAndDocIdOrderByCreateTimeDesc(String operationType, String docId);
+
+    /**
+     * 查询最近的日志
+     */
+    List<OperationLog> findTop100ByOrderByCreateTimeDesc();
+}

+ 170 - 0
schedule-embedding-api/src/main/java/cn/com/yusys/manager/service/LogService.java

@@ -0,0 +1,170 @@
+package cn.com.yusys.manager.service;
+
+import cn.com.yusys.manager.model.entity.KafkaProcessingLog;
+import cn.com.yusys.manager.model.entity.OperationLog;
+import cn.com.yusys.manager.repository.KafkaProcessingLogRepository;
+import cn.com.yusys.manager.repository.OperationLogRepository;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * 日志记录服务
+ * 用于记录操作日志和Kafka处理日志
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class LogService {
+
+    private final OperationLogRepository operationLogRepository;
+    private final KafkaProcessingLogRepository kafkaProcessingLogRepository;
+    private final ObjectMapper objectMapper;
+
+    // ==================== 操作日志 ====================
+
+    /**
+     * 记录操作日志(异步)
+     */
+    @Async
+    @Transactional(propagation = Propagation.REQUIRES_NEW)
+    public void recordOperationLog(String operationType, String docId, String chunkId,
+                                   String operator, Object requestParams, Object responseResult,
+                                   String status, String errorMessage, Long durationMs, String ipAddress) {
+        try {
+            OperationLog operationLog = new OperationLog();
+            operationLog.setOperationType(operationType);
+            operationLog.setDocId(docId);
+            operationLog.setChunkId(chunkId);
+            operationLog.setOperator(operator);
+            operationLog.setRequestParams(objectMapper.writeValueAsString(requestParams));
+            operationLog.setResponseResult(objectMapper.writeValueAsString(responseResult));
+            operationLog.setStatus(status);
+            operationLog.setErrorMessage(errorMessage);
+            operationLog.setDurationMs(durationMs);
+            operationLog.setIpAddress(ipAddress);
+            operationLog.setCreateTime(LocalDateTime.now());
+
+            operationLogRepository.save(operationLog);
+            log.debug("操作日志记录成功: operationType={}, docId={}", operationType, docId);
+        } catch (Exception e) {
+            log.error("记录操作日志失败: operationType={}, docId={}", operationType, docId, e);
+        }
+    }
+
+    /**
+     * 记录操作日志(简化版)
+     */
+    @Async
+    @Transactional(propagation = Propagation.REQUIRES_NEW)
+    public void recordOperationLog(String operationType, String docId, Object requestParams,
+                                   Object responseResult, String status, String errorMessage, Long durationMs) {
+        recordOperationLog(operationType, docId, null, null, requestParams, responseResult,
+                status, errorMessage, durationMs, null);
+    }
+
+    // ==================== Kafka处理日志 ====================
+
+    /**
+     * 创建Kafka处理日志
+     */
+    @Transactional(propagation = Propagation.REQUIRES_NEW)
+    public KafkaProcessingLog createKafkaProcessingLog(String messageId, String docId, String taskType,
+                                                       String processingStage) {
+        try {
+            KafkaProcessingLog kafkaProcessingLog = new KafkaProcessingLog();
+            kafkaProcessingLog.setMessageId(messageId != null ? messageId : UUID.randomUUID().toString());
+            kafkaProcessingLog.setDocId(docId);
+            kafkaProcessingLog.setTaskType(taskType);
+            kafkaProcessingLog.setProcessingStage(processingStage);
+            kafkaProcessingLog.setStatus(KafkaProcessingLog.Status.PROCESSING);
+            kafkaProcessingLog.setCreateTime(LocalDateTime.now());
+            kafkaProcessingLog.setUpdateTime(LocalDateTime.now());
+
+            KafkaProcessingLog saved = kafkaProcessingLogRepository.save(kafkaProcessingLog);
+            log.debug("Kafka处理日志创建成功: messageId={}, docId={}, stage={}", messageId, docId, processingStage);
+            return saved;
+        } catch (Exception e) {
+            log.error("创建Kafka处理日志失败: messageId={}, docId={}", messageId, docId, e);
+            return null;
+        }
+    }
+
+    /**
+     * 更新Kafka处理日志
+     */
+    @Transactional(propagation = Propagation.REQUIRES_NEW)
+    public void updateKafkaProcessingLog(KafkaProcessingLog kafkaProcessingLog, String processingStage,
+                                         String status, String errorMessage, Long durationMs) {
+        try {
+            kafkaProcessingLog.setProcessingStage(processingStage);
+            kafkaProcessingLog.setStatus(status);
+            kafkaProcessingLog.setErrorMessage(errorMessage);
+            kafkaProcessingLog.setDurationMs(durationMs);
+            kafkaProcessingLog.setUpdateTime(LocalDateTime.now());
+
+            kafkaProcessingLogRepository.save(kafkaProcessingLog);
+            log.debug("Kafka处理日志更新成功: id={}, stage={}, status={}", kafkaProcessingLog.getId(), processingStage, status);
+        } catch (Exception e) {
+            log.error("更新Kafka处理日志失败: id={}", kafkaProcessingLog.getId(), e);
+        }
+    }
+
+    /**
+     * 完成Kafka处理日志
+     */
+    @Transactional(propagation = Propagation.REQUIRES_NEW)
+    public void completeKafkaProcessingLog(KafkaProcessingLog kafkaProcessingLog, Long durationMs) {
+        updateKafkaProcessingLog(kafkaProcessingLog, KafkaProcessingLog.ProcessingStage.COMPLETED,
+                KafkaProcessingLog.Status.SUCCESS, null, durationMs);
+    }
+
+    /**
+     * 失败Kafka处理日志
+     */
+    @Transactional(propagation = Propagation.REQUIRES_NEW)
+    public void failKafkaProcessingLog(KafkaProcessingLog kafkaProcessingLog, String errorMessage, Long durationMs) {
+        updateKafkaProcessingLog(kafkaProcessingLog, KafkaProcessingLog.ProcessingStage.FAILED,
+                KafkaProcessingLog.Status.FAILED, errorMessage, durationMs);
+    }
+
+    // ==================== 查询方法 ====================
+
+    /**
+     * 根据文档ID查询操作日志
+     */
+    public List<OperationLog> getOperationLogsByDocId(String docId) {
+        return operationLogRepository.findByDocIdOrderByCreateTimeDesc(docId);
+    }
+
+    /**
+     * 根据文档ID查询Kafka处理日志
+     */
+    public List<KafkaProcessingLog> getKafkaProcessingLogsByDocId(String docId) {
+        return kafkaProcessingLogRepository.findByDocIdOrderByCreateTimeDesc(docId);
+    }
+
+    /**
+     * 查询最近的日志
+     */
+    public List<OperationLog> getRecentOperationLogs(int limit) {
+        return operationLogRepository.findTop100ByOrderByCreateTimeDesc()
+                .subList(0, Math.min(limit, operationLogRepository.findTop100ByOrderByCreateTimeDesc().size()));
+    }
+
+    /**
+     * 查询最近的Kafka处理日志
+     */
+    public List<KafkaProcessingLog> getRecentKafkaProcessingLogs(int limit) {
+        return kafkaProcessingLogRepository.findTop100ByOrderByCreateTimeDesc()
+                .subList(0, Math.min(limit, kafkaProcessingLogRepository.findTop100ByOrderByCreateTimeDesc().size()));
+    }
+}

+ 210 - 0
schedule-embedding-api/src/main/java/cn/com/yusys/manager/splitter/TextSplitter.java

@@ -0,0 +1,210 @@
+package cn.com.yusys.manager.splitter;
+
+import cn.com.yusys.manager.config.ChunkConfig;
+import cn.com.yusys.manager.model.entity.Chunk;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * 文本分段器
+ * 将全量文本分段为多个chunk
+ */
+@Slf4j
+@Component
+public class TextSplitter {
+
+    @Autowired
+    private ChunkConfig chunkConfig;
+
+    /**
+     * 分段方法
+     * @param fullText 全量文本
+     * @param docId 文档ID
+     * @return 分段列表
+     */
+    public List<Chunk> split(String fullText, String docId) {
+        if (StringUtils.isBlank(fullText)) {
+            return new ArrayList<>();
+        }
+
+        // 1. 预处理
+        String text = preprocess(fullText);
+
+        // 2. 根据模式分段
+        List<String> chunks;
+        String mode = chunkConfig.getMode();
+        if ("paragraph".equalsIgnoreCase(mode)) {
+            chunks = splitByParagraph(text);
+        } else if ("fixed".equalsIgnoreCase(mode)) {
+            chunks = splitByFixedLength(text);
+        } else {
+            // 默认使用段落分段
+            chunks = splitByParagraph(text);
+        }
+
+        // 3. 构建Chunk对象
+        List<Chunk> result = new ArrayList<>();
+        for (int i = 0; i < chunks.size(); i++) {
+            String chunkContent = chunks.get(i);
+            if (StringUtils.isNotBlank(chunkContent)) {
+                result.add(createChunk(docId, i, chunkContent.trim()));
+            }
+        }
+
+        log.debug("文档 {} 分段完成,共 {} 个chunk", docId, result.size());
+        return result;
+    }
+
+    /**
+     * 预处理文本
+     * @param text 原始文本
+     * @return 处理后的文本
+     */
+    private String preprocess(String text) {
+        return text
+                .replaceAll("\\r\\n", "\n")     // 统一换行符
+                .replaceAll("\\r", "\n")         // 统一换行符
+                .replaceAll("\\n{3,}", "\n\n")   // 多个连续换行保留两个
+                .trim();
+    }
+
+    /**
+     * 按段落切分
+     * @param text 文本
+     * @return 段落列表
+     */
+    private List<String> splitByParagraph(String text) {
+        List<String> paragraphs = Arrays.asList(text.split("\\n\\s*\\n"));
+        List<String> chunks = new ArrayList<>();
+
+        StringBuilder currentChunk = new StringBuilder();
+        int maxLength = chunkConfig.getMaxLength();
+
+        for (String paragraph : paragraphs) {
+            paragraph = paragraph.trim();
+            if (StringUtils.isBlank(paragraph)) {
+                continue;
+            }
+
+            // 如果当前段落单独超过最大长度,需要切分
+            if (paragraph.length() > maxLength) {
+                // 先保存当前chunk
+                if (currentChunk.length() > 0) {
+                    chunks.add(currentChunk.toString());
+                    currentChunk = new StringBuilder();
+                }
+                // 切分超长段落
+                chunks.addAll(splitLongParagraph(paragraph, maxLength));
+                continue;
+            }
+
+            // 检查是否超过最大长度
+            if (currentChunk.length() + paragraph.length() + 1 > maxLength) {
+                if (currentChunk.length() > 0) {
+                    chunks.add(currentChunk.toString());
+                    currentChunk = new StringBuilder();
+                }
+            }
+
+            // 添加段落
+            if (currentChunk.length() > 0) {
+                currentChunk.append("\n\n");
+            }
+            currentChunk.append(paragraph);
+        }
+
+        // 最后一个chunk
+        if (currentChunk.length() > 0) {
+            chunks.add(currentChunk.toString());
+        }
+
+        return chunks;
+    }
+
+    /**
+     * 按固定长度切分
+     * @param text 文本
+     * @return chunk列表
+     */
+    private List<String> splitByFixedLength(String text) {
+        List<String> chunks = new ArrayList<>();
+        int maxLength = chunkConfig.getMaxLength();
+        int overlap = chunkConfig.getOverlap();
+
+        int start = 0;
+        while (start < text.length()) {
+            int end = Math.min(start + maxLength, text.length());
+            String chunk = text.substring(start, end).trim();
+            if (StringUtils.isNotBlank(chunk)) {
+                chunks.add(chunk);
+            }
+            start = end - overlap;
+            if (start < 0) {
+                start = end;
+            }
+        }
+
+        return chunks;
+    }
+
+    /**
+     * 切分超长段落
+     * @param paragraph 段落
+     * @param maxLength 最大长度
+     * @return chunk列表
+     */
+    private List<String> splitLongParagraph(String paragraph, int maxLength) {
+        List<String> chunks = new ArrayList<>();
+        int start = 0;
+        while (start < paragraph.length()) {
+            int end = Math.min(start + maxLength, paragraph.length());
+            // 尝试在句子边界切分
+            if (end < paragraph.length()) {
+                // 查找最近的句号、问号、感叹号
+                int lastPunctuation = findLastPunctuation(paragraph, start, end);
+                if (lastPunctuation > start + maxLength / 2) {
+                    end = lastPunctuation + 1;
+                }
+            }
+            chunks.add(paragraph.substring(start, end).trim());
+            start = end;
+        }
+        return chunks;
+    }
+
+    /**
+     * 查找最后一个标点符号位置
+     */
+    private int findLastPunctuation(String text, int start, int end) {
+        String punctuation = ".。!!??;;";
+        for (int i = end - 1; i >= start; i--) {
+            if (punctuation.indexOf(text.charAt(i)) >= 0) {
+                return i;
+            }
+        }
+        return end - 1;
+    }
+
+    /**
+     * 创建chunk对象
+     * @param docId 文档ID
+     * @param index chunk序号
+     * @param content 内容
+     * @return Chunk对象
+     */
+    private Chunk createChunk(String docId, int index, String content) {
+        return Chunk.builder()
+                .docId(docId)
+                .chunkId(docId + "_chunk_" + index)
+                .chunkIndex(index)
+                .content(content)
+                .contentLength(content.length())
+                .build();
+    }
+}

+ 101 - 0
schedule-embedding-api/src/main/resources/application.yml

@@ -0,0 +1,101 @@
+server:
+  port: 8084
+
+spring:
+  application:
+    name: schedule-embedding-api
+
+  # ========== MySQL配置 ==========
+  datasource:
+    url: jdbc:mysql://127.0.0.1:3306/schedule_embedding?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
+    username: root
+    password: 123456
+    driver-class-name: com.mysql.cj.jdbc.Driver
+    hikari:
+      maximum-pool-size: 10
+      minimum-idle: 5
+      connection-timeout: 30000
+
+  # ========== JPA配置 ==========
+  jpa:
+    hibernate:
+      ddl-auto: update
+    show-sql: false
+    properties:
+      hibernate:
+        dialect: org.hibernate.dialect.MySQLDialect
+        format_sql: true
+
+  # ========== Kafka配置 ==========
+kafka:
+  enabled: false  # Kafka开关,平时关闭
+  topics:
+    embedding: embedding-topic
+
+
+  # Kafka配置(仅在kafka.enabled=true时生效)
+  kafka:
+    bootstrap-servers: localhost:9092
+    consumer:
+      group-id: embedding-api-group
+      auto-offset-reset: earliest
+      enable-auto-commit: false
+      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
+      value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
+      properties:
+        max.poll.records: 10
+    listener:
+      ack-mode: manual_immediate
+
+# ========== Embedding接口配置 ==========
+embedding:
+  api:
+    url: http://10.192.72.11:18081/v1
+    key: 1
+    model: Qwen3-Embedding-8B
+    timeout: 30000
+  batch:
+    size: 128
+
+# ========== 文本分段配置 ==========
+chunk:
+  max-length: 500
+  min-length: 100
+  overlap: 50
+  mode: paragraph
+
+# ========== Elasticsearch配置 ==========
+elasticsearch:
+  host: http://10.192.72.13:9200
+  username:
+  password:
+  index:
+    name: contract_chunks
+    shards: 3
+    replicas: 1
+  connection:
+    timeout: 30000
+    max-retry-timeout-millis: 30000
+
+# ========== 监控配置 ==========
+management:
+  endpoints:
+    web:
+      exposure:
+        include: health,info,metrics
+  endpoint:
+    health:
+      show-details: always
+  metrics:
+    export:
+      prometheus:
+        enabled: true
+
+# ========== 日志配置 ==========
+logging:
+  level:
+    cn.com.yusys.manager: INFO
+    org.elasticsearch.client: WARN
+    org.springframework.kafka: INFO
+  pattern:
+    console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"

+ 287 - 0
schedule-embedding-api/src/test/java/cn/com/yusys/manager/controller/ApiControllerTest.java

@@ -0,0 +1,287 @@
+package cn.com.yusys.manager.controller;
+
+import cn.com.yusys.manager.model.dto.BatchIndexRequest;
+import cn.com.yusys.manager.model.dto.IndexRequest;
+import cn.com.yusys.manager.model.dto.SearchRequest;
+import cn.com.yusys.manager.model.dto.SearchResult;
+import cn.com.yusys.manager.model.entity.TextDocument;
+import cn.com.yusys.manager.model.vo.BatchIndexResult;
+import cn.com.yusys.manager.model.vo.IndexResult;
+import cn.com.yusys.manager.model.entity.service.DocumentService;
+import cn.com.yusys.manager.model.entity.service.TextEmbeddingService;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockitoAnnotations;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.util.*;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+/**
+ * REST API 集成测试
+ */
+@WebMvcTest({TextEmbeddingController.class, VectorSearchController.class, DocumentController.class})
+public class ApiControllerTest {
+
+    @Autowired
+    private MockMvc mockMvc;
+
+    @Autowired
+    private ObjectMapper objectMapper;
+
+    @MockBean
+    private TextEmbeddingService textEmbeddingService;
+
+    @MockBean
+    private DocumentService documentService;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    @Test
+    public void testIndexDocumentAPI() throws Exception {
+        // 准备测试数据
+        IndexRequest request = IndexRequest.builder()
+                .docId("test-doc-1")
+                .fileName("test.pdf")
+                .fullText("这是测试文档内容")
+                .filePath("/path/to/test.pdf")
+                .fileSize(1024L)
+                .build();
+
+        IndexResult mockResult = IndexResult.success("test-doc-1", 3);
+
+        // Mock行为
+        when(textEmbeddingService.indexDocument(any(IndexRequest.class)))
+                .thenReturn(mockResult);
+
+        // 执行测试
+        mockMvc.perform(post("/api/v1/documents/index")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(objectMapper.writeValueAsString(request)))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.success").value(true))
+                .andExpect(jsonPath("$.docId").value("test-doc-1"))
+                .andExpect(jsonPath("$.chunkCount").value(3))
+                .andExpect(jsonPath("$.message").value("文档入库成功"));
+    }
+
+    @Test
+    public void testBatchIndexAPI() throws Exception {
+        // 准备测试数据
+        IndexRequest request1 = IndexRequest.builder()
+                .docId("doc-1")
+                .fullText("内容1")
+                .build();
+
+        IndexRequest request2 = IndexRequest.builder()
+                .docId("doc-2")
+                .fullText("内容2")
+                .build();
+
+        BatchIndexRequest batchRequest = BatchIndexRequest.builder()
+                .items(Arrays.asList(request1, request2))
+                .build();
+
+        BatchIndexResult mockResult = BatchIndexResult.builder()
+                .totalCount(2)
+                .successCount(2)
+                .failedCount(0)
+                .failedItems(new ArrayList<>())
+                .build();
+
+        // Mock行为
+        when(textEmbeddingService.batchIndexDocuments(any(BatchIndexRequest.class)))
+                .thenReturn(mockResult);
+
+        // 执行测试
+        mockMvc.perform(post("/api/v1/documents/batch-index")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(objectMapper.writeValueAsString(batchRequest)))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.totalCount").value(2))
+                .andExpect(jsonPath("$.successCount").value(2))
+                .andExpect(jsonPath("$.failedCount").value(0));
+    }
+
+    @Test
+    public void testSearchAPI() throws Exception {
+        // 准备测试数据
+        SearchRequest request = SearchRequest.builder()
+                .query("测试查询")
+                .topK(5)
+                .build();
+
+        SearchResult result1 = SearchResult.builder()
+                .docId("doc-1")
+                .chunkId("doc-1_chunk_0")
+                .content("相关内容1")
+                .score(0.92f)
+                .build();
+
+        SearchResult result2 = SearchResult.builder()
+                .docId("doc-2")
+                .chunkId("doc-2_chunk_0")
+                .content("相关内容2")
+                .score(0.88f)
+                .build();
+
+        List<SearchResult> mockResults = Arrays.asList(result1, result2);
+
+        // Mock行为
+        when(textEmbeddingService.search(any(SearchRequest.class)))
+                .thenReturn(mockResults);
+
+        // 执行测试
+        mockMvc.perform(post("/api/v1/search")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(objectMapper.writeValueAsString(request)))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$").isArray())
+                .andExpect(jsonPath("$[0].docId").value("doc-1"))
+                .andExpect(jsonPath("$[0].score").value(0.92))
+                .andExpect(jsonPath("$[1].docId").value("doc-2"));
+    }
+
+    @Test
+    public void testGetDocumentAPI() throws Exception {
+        // 准备测试数据
+        String docId = "test-doc-1";
+
+        TextDocument doc1 = TextDocument.builder()
+                .docId(docId)
+                .chunkId(docId + "_chunk_0")
+                .chunkIndex(0)
+                .content("第一段内容")
+                .build();
+
+        TextDocument doc2 = TextDocument.builder()
+                .docId(docId)
+                .chunkId(docId + "_chunk_1")
+                .chunkIndex(1)
+                .content("第二段内容")
+                .build();
+
+        List<TextDocument> mockDocuments = Arrays.asList(doc1, doc2);
+
+        // Mock行为
+        when(documentService.getDocument(docId))
+                .thenReturn(mockDocuments);
+
+        // 执行测试
+        mockMvc.perform(get("/api/v1/documents/{docId}", docId))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$").isArray())
+                .andExpect(jsonPath("$[0].docId").value(docId))
+                .andExpect(jsonPath("$[0].chunkIndex").value(0))
+                .andExpect(jsonPath("$[1].chunkIndex").value(1));
+    }
+
+    @Test
+    public void testDeleteDocumentAPI() throws Exception {
+        // 准备测试数据
+        String docId = "test-doc-1";
+
+        // Mock行为
+        when(documentService.deleteDocument(docId))
+                .thenReturn(3L);
+
+        // 执行测试
+        mockMvc.perform(delete("/api/v1/documents/{docId}", docId))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.success").value(true))
+                .andExpect(jsonPath("$.docId").value(docId))
+                .andExpect(jsonPath("$.deletedCount").value(3))
+                .andExpect(jsonPath("$.message").value("删除成功"));
+    }
+
+    @Test
+    public void testValidation_EmptyDocId() throws Exception {
+        // 准备测试数据(缺少docId)
+        IndexRequest request = IndexRequest.builder()
+                .fullText("测试内容")
+                .build();
+
+        // 执行测试
+        mockMvc.perform(post("/api/v1/documents/index")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(objectMapper.writeValueAsString(request)))
+                .andExpect(status().isBadRequest());
+    }
+
+    @Test
+    public void testValidation_EmptyQuery() throws Exception {
+        // 准备测试数据(query为空)
+        SearchRequest request = SearchRequest.builder()
+                .topK(5)
+                .build();
+
+        // 执行测试
+        mockMvc.perform(post("/api/v1/search")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(objectMapper.writeValueAsString(request)))
+                .andExpect(status().isBadRequest());
+    }
+
+    @Test
+    public void testHybridSearchAPI() throws Exception {
+        // 准备测试数据
+        SearchRequest request = SearchRequest.builder()
+                .query("合同条款")
+                .topK(10)
+                .filters(new HashMap<String, Object>() {{
+                    put("contract_type", "消费贷");
+                    put("status", "执行中");
+                }})
+                .build();
+
+        SearchResult result1 = SearchResult.builder()
+                .docId("doc-1")
+                .content("消费贷合同条款...")
+                .score(0.95f)
+                .build();
+
+        List<SearchResult> mockResults = Arrays.asList(result1);
+
+        // Mock行为
+        when(textEmbeddingService.search(any(SearchRequest.class)))
+                .thenReturn(mockResults);
+
+        // 执行测试
+        mockMvc.perform(post("/api/v1/search/hybrid")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(objectMapper.writeValueAsString(request)))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$[0].docId").value("doc-1"))
+                .andExpect(jsonPath("$[0].score").value(0.95));
+    }
+
+    @Test
+    public void testDeleteChunkAPI() throws Exception {
+        // 准备测试数据
+        String chunkId = "test-doc-1_chunk_0";
+
+        // Mock行为
+        when(documentService.deleteChunk(chunkId))
+                .thenReturn(true);
+
+        // 执行测试
+        mockMvc.perform(delete("/api/v1/documents/chunk/{chunkId}", chunkId))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.success").value(true))
+                .andExpect(jsonPath("$.chunkId").value(chunkId))
+                .andExpect(jsonPath("$.message").value("删除成功"));
+    }
+}

+ 151 - 0
schedule-embedding-api/src/test/java/cn/com/yusys/manager/service/DocumentServiceTest.java

@@ -0,0 +1,151 @@
+package cn.com.yusys.manager.service;
+
+import cn.com.yusys.manager.model.entity.TextDocument;
+import cn.com.yusys.manager.model.entity.service.DocumentService;
+import cn.com.yusys.manager.repository.ElasticsearchRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+/**
+ * DocumentService 单元测试
+ */
+@SpringBootTest
+public class DocumentServiceTest {
+
+    @Mock
+    private ElasticsearchRepository elasticsearchRepository;
+
+    @InjectMocks
+    private DocumentService documentService;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    @Test
+    public void testGetDocument() {
+        // 准备测试数据
+        String docId = "test-doc-1";
+
+        TextDocument doc1 = TextDocument.builder()
+                .docId(docId)
+                .chunkId(docId + "_chunk_0")
+                .chunkIndex(0)
+                .content("第一段内容")
+                .createTime(new Date())
+                .build();
+
+        TextDocument doc2 = TextDocument.builder()
+                .docId(docId)
+                .chunkId(docId + "_chunk_1")
+                .chunkIndex(1)
+                .content("第二段内容")
+                .createTime(new Date())
+                .build();
+
+        List<TextDocument> mockDocuments = Arrays.asList(doc1, doc2);
+
+        // Mock行为
+        when(elasticsearchRepository.findByDocId(docId)).thenReturn(mockDocuments);
+
+        // 执行测试
+        List<TextDocument> documents = documentService.getDocument(docId);
+
+        // 验证结果
+        assertNotNull(documents);
+        assertEquals(2, documents.size());
+        assertEquals(docId, documents.get(0).getDocId());
+        assertEquals(0, documents.get(0).getChunkIndex());
+        assertEquals(1, documents.get(1).getChunkIndex());
+
+        // 验证调用
+        verify(elasticsearchRepository, times(1)).findByDocId(docId);
+    }
+
+    @Test
+    public void testDeleteDocument() {
+        // 准备测试数据
+        String docId = "test-doc-1";
+
+        // Mock行为
+        when(elasticsearchRepository.deleteByDocId(docId)).thenReturn(3L);
+
+        // 执行测试
+        long count = documentService.deleteDocument(docId);
+
+        // 验证结果
+        assertEquals(3L, count);
+
+        // 验证调用
+        verify(elasticsearchRepository, times(1)).deleteByDocId(docId);
+    }
+
+    @Test
+    public void testDeleteChunk() {
+        // 准备测试数据
+        String chunkId = "test-doc-1_chunk_0";
+
+        // Mock行为
+        when(elasticsearchRepository.deleteDocument(chunkId)).thenReturn(true);
+
+        // 执行测试
+        boolean result = documentService.deleteChunk(chunkId);
+
+        // 验证结果
+        assertTrue(result);
+
+        // 验证调用
+        verify(elasticsearchRepository, times(1)).deleteDocument(chunkId);
+    }
+
+    @Test
+    public void testGetDocument_Empty() {
+        // 准备测试数据
+        String docId = "non-existent-doc";
+
+        // Mock行为
+        when(elasticsearchRepository.findByDocId(docId)).thenReturn(new ArrayList<>());
+
+        // 执行测试
+        List<TextDocument> documents = documentService.getDocument(docId);
+
+        // 验证结果
+        assertNotNull(documents);
+        assertTrue(documents.isEmpty());
+
+        // 验证调用
+        verify(elasticsearchRepository, times(1)).findByDocId(docId);
+    }
+
+    @Test
+    public void testDeleteChunk_Failed() {
+        // 准备测试数据
+        String chunkId = "non-existent-chunk";
+
+        // Mock行为
+        when(elasticsearchRepository.deleteDocument(chunkId)).thenReturn(false);
+
+        // 执行测试
+        boolean result = documentService.deleteChunk(chunkId);
+
+        // 验证结果
+        assertFalse(result);
+
+        // 验证调用
+        verify(elasticsearchRepository, times(1)).deleteDocument(chunkId);
+    }
+}

+ 220 - 0
schedule-embedding-api/src/test/java/cn/com/yusys/manager/service/TextEmbeddingServiceTest.java

@@ -0,0 +1,220 @@
+package cn.com.yusys.manager.service;
+
+import cn.com.yusys.manager.client.EmbeddingClient;
+import cn.com.yusys.manager.model.dto.BatchIndexRequest;
+import cn.com.yusys.manager.model.dto.IndexRequest;
+import cn.com.yusys.manager.model.dto.SearchRequest;
+import cn.com.yusys.manager.model.dto.SearchResult;
+import cn.com.yusys.manager.model.entity.service.TextEmbeddingService;
+import cn.com.yusys.manager.model.vo.BatchIndexResult;
+import cn.com.yusys.manager.model.vo.IndexResult;
+import cn.com.yusys.manager.repository.ElasticsearchRepository;
+import cn.com.yusys.manager.splitter.TextSplitter;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import java.util.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * TextEmbeddingService 单元测试
+ */
+@SpringBootTest
+public class TextEmbeddingServiceTest {
+
+    @Mock
+    private TextSplitter textSplitter;
+
+    @Mock
+    private EmbeddingClient embeddingClient;
+
+    @Mock
+    private ElasticsearchRepository elasticsearchRepository;
+
+    @InjectMocks
+    private TextEmbeddingService textEmbeddingService;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    @Test
+    public void testIndexDocument_Success() {
+        // 准备测试数据
+        IndexRequest request = IndexRequest.builder()
+                .docId("test-doc-1")
+                .fileName("test.pdf")
+                .fullText("这是第一段。\n\n这是第二段。\n\n这是第三段。")
+                .filePath("/path/to/test.pdf")
+                .fileSize(1024L)
+                .fileType("pdf")
+                .metadata(new HashMap<String, Object>() {{
+                    put("contractType", "测试合同");
+                    put("partyA", "甲方公司");
+                    put("partyB", "乙方公司");
+                }})
+                .build();
+
+        cn.com.yusys.manager.model.entity.Chunk chunk1 = cn.com.yusys.manager.model.entity.Chunk.builder()
+                .docId("test-doc-1")
+                .chunkId("test-doc-1_chunk_0")
+                .chunkIndex(0)
+                .content("这是第一段。")
+                .contentLength(6)
+                .build();
+
+        cn.com.yusys.manager.model.entity.Chunk chunk2 = cn.com.yusys.manager.model.entity.Chunk.builder()
+                .docId("test-doc-1")
+                .chunkId("test-doc-1_chunk_1")
+                .chunkIndex(1)
+                .content("这是第二段。")
+                .contentLength(6)
+                .build();
+
+        List<cn.com.yusys.manager.model.entity.Chunk> chunks = Arrays.asList(chunk1, chunk2);
+        float[] embedding1 = new float[4096];
+        float[] embedding2 = new float[4096];
+        Arrays.fill(embedding1, 0.1f);
+        Arrays.fill(embedding2, 0.2f);
+        List<float[]> embeddings = Arrays.asList(embedding1, embedding2);
+
+        // Mock行为
+        when(textSplitter.split(anyString(), eq("test-doc-1"))).thenReturn(chunks);
+        when(embeddingClient.getBatchEmbeddings(anyList())).thenReturn(embeddings);
+        when(elasticsearchRepository.bulkIndex(anyList())).thenReturn(null);
+
+        // 执行测试
+        IndexResult result = textEmbeddingService.indexDocument(request);
+
+        // 验证结果
+        assertNotNull(result);
+        assertTrue(result.getSuccess());
+        assertEquals("test-doc-1", result.getDocId());
+        assertEquals(2, result.getChunkCount());
+        assertEquals("文档入库成功", result.getMessage());
+
+        // 验证调用
+        verify(textSplitter, times(1)).split(request.getFullText(), request.getDocId());
+        verify(embeddingClient, times(1)).getBatchEmbeddings(anyList());
+        verify(elasticsearchRepository, times(1)).bulkIndex(anyList());
+    }
+
+    @Test
+    public void testIndexDocument_EmptyText() {
+        // 准备测试数据
+        IndexRequest request = IndexRequest.builder()
+                .docId("test-doc-2")
+                .fullText("")
+                .build();
+
+        // Mock行为
+        when(textSplitter.split(eq(""), eq("test-doc-2"))).thenReturn(new ArrayList<>());
+
+        // 执行测试
+        IndexResult result = textEmbeddingService.indexDocument(request);
+
+        // 验证结果
+        assertNotNull(result);
+        assertFalse(result.getSuccess());
+        assertEquals("test-doc-2", result.getDocId());
+        assertEquals("文本分段结果为空", result.getError());
+    }
+
+    @Test
+    public void testBatchIndexDocuments() {
+        // 准备测试数据
+        IndexRequest request1 = IndexRequest.builder()
+                .docId("test-doc-1")
+                .fullText("文档1内容")
+                .build();
+
+        IndexRequest request2 = IndexRequest.builder()
+                .docId("test-doc-2")
+                .fullText("文档2内容")
+                .build();
+
+        IndexRequest request3 = IndexRequest.builder()
+                .docId("test-doc-3")
+                .fullText("文档3内容")
+                .build();
+
+        BatchIndexRequest batchRequest = BatchIndexRequest.builder()
+                .items(Arrays.asList(request1, request2, request3))
+                .build();
+
+        // Mock行为
+        doReturn(new IndexResult(true, "test-doc-1", 1, "文档入库成功", null))
+                .when(textEmbeddingService).indexDocument(any(IndexRequest.class));
+
+        // 执行测试
+        BatchIndexResult result = textEmbeddingService.batchIndexDocuments(batchRequest);
+
+        // 验证结果
+        assertNotNull(result);
+        assertEquals(3, result.getTotalCount());
+        assertEquals(3, result.getSuccessCount());
+        assertEquals(0, result.getFailedCount());
+        assertTrue(result.getFailedItems().isEmpty());
+    }
+
+    @Test
+    public void testSearch() {
+        // 准备测试数据
+        SearchRequest request = SearchRequest.builder()
+                .query("测试查询")
+                .topK(5)
+                .filters(new HashMap<String, Object>() {{
+                    put("contract_type", "测试合同");
+                }})
+                .build();
+
+        float[] queryVector = new float[4096];
+        Arrays.fill(queryVector, 0.15f);
+
+        SearchResult result1 = SearchResult.builder()
+                .docId("doc-1")
+                .chunkId("doc-1_chunk_0")
+                .chunkIndex(0)
+                .content("相似内容1")
+                .score(0.92f)
+                .metadata(new HashMap<>())
+                .build();
+
+        SearchResult result2 = SearchResult.builder()
+                .docId("doc-2")
+                .chunkId("doc-2_chunk_0")
+                .chunkIndex(0)
+                .content("相似内容2")
+                .score(0.88f)
+                .metadata(new HashMap<>())
+                .build();
+
+        List<SearchResult> mockResults = Arrays.asList(result1, result2);
+
+        // Mock行为
+        when(embeddingClient.getEmbedding("测试查询")).thenReturn(queryVector);
+        when(elasticsearchRepository.hybridSearch(any(float[].class), eq(5), anyMap()))
+                .thenReturn(mockResults);
+
+        // 执行测试
+        List<SearchResult> results = textEmbeddingService.search(request);
+
+        // 验证结果
+        assertNotNull(results);
+        assertEquals(2, results.size());
+        assertEquals("doc-1", results.get(0).getDocId());
+        assertEquals("doc-2", results.get(1).getDocId());
+
+        // 验证调用
+        verify(embeddingClient, times(1)).getEmbedding("测试查询");
+        verify(elasticsearchRepository, times(1)).hybridSearch(any(float[].class), eq(5), anyMap());
+    }
+}

+ 50 - 0
schedule-embedding-api/src/test/java/cn/com/yusys/manager/splitter/TextSplitterTest.java

@@ -0,0 +1,50 @@
+package cn.com.yusys.manager.splitter;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * TextSplitter测试
+ */
+@SpringBootTest
+public class TextSplitterTest {
+
+    @Test
+    public void testSplitParagraph() {
+        TextSplitter splitter = new TextSplitter();
+        String text = "第一段内容。\n\n第二段内容。\n\n第三段内容。";
+        List<cn.com.yusys.manager.model.entity.Chunk> chunks = splitter.split(text, "test-doc-1");
+
+        assertNotNull(chunks);
+        assertTrue(chunks.size() > 0);
+        assertEquals("test-doc-1", chunks.get(0).getDocId());
+        assertEquals("test-doc-1_chunk_0", chunks.get(0).getChunkId());
+    }
+
+    @Test
+    public void testEmptyText() {
+        TextSplitter splitter = new TextSplitter();
+        List<cn.com.yusys.manager.model.entity.Chunk> chunks = splitter.split("", "test-doc-2");
+
+        assertNotNull(chunks);
+        assertTrue(chunks.isEmpty());
+    }
+
+    @Test
+    public void testLongParagraph() {
+        TextSplitter splitter = new TextSplitter();
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < 1000; i++) {
+            sb.append("这是一段很长的文本内容,用于测试分段功能。");
+        }
+        String text = sb.toString();
+        List<cn.com.yusys.manager.model.entity.Chunk> chunks = splitter.split(text, "test-doc-3");
+
+        assertNotNull(chunks);
+        assertTrue(chunks.size() > 1);
+    }
+}

+ 464 - 0
schedule-embedding-api/test_complete.sh

@@ -0,0 +1,464 @@
+#!/bin/bash
+
+# schedule-embedding-api 完整功能测试脚本
+# 测试场景:基于解析结果示例.md的材料
+# 使用说明:确保应用已启动在 http://localhost:8084
+
+BASE_URL="http://localhost:8084"
+RESULTS_FILE="complete_test_results.txt"
+PASSED=0
+FAILED=0
+
+# 颜色输出
+GREEN='\033[0;32m'
+RED='\033[0;31m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m'
+
+# 清空结果文件
+> "$RESULTS_FILE"
+
+echo "========================================" | tee -a "$RESULTS_FILE"
+echo "  完整功能测试 - 解析结果示例数据" | tee -a "$RESULTS_FILE"
+echo "  测试时间: $(date '+%Y-%m-%d %H:%M:%S')" | tee -a "$RESULTS_FILE"
+echo "========================================" | tee -a "$RESULTS_FILE"
+echo "" | tee -a "$RESULTS_FILE"
+
+# 测试函数
+test_api() {
+    local test_name="$1"
+    local method="$2"
+    local url="$3"
+    local data="$4"
+    local expected_code="$5"
+
+    echo -n "[$test_name] ... " | tee -a "$RESULTS_FILE"
+
+    if [ "$method" = "GET" ]; then
+        response=$(curl -s -w "\n%{http_code}" -X GET "$BASE_URL$url")
+    elif [ "$method" = "DELETE" ]; then
+        response=$(curl -s -w "\n%{http_code}" -X DELETE "$BASE_URL$url")
+    elif [ "$method" = "POST" ]; then
+        if [ -z "$data" ]; then
+            response=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL$url" \
+                -H "Content-Type: application/json")
+        else
+            response=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL$url" \
+                -H "Content-Type: application/json" \
+                -d "$data")
+        fi
+    fi
+
+    http_code=$(echo "$response" | tail -n1)
+    body=$(echo "$response" | sed '$d')
+
+    if [ "$http_code" = "$expected_code" ]; then
+        echo -e "${GREEN}PASSED${NC}" | tee -a "$RESULTS_FILE"
+        ((PASSED++))
+    else
+        echo -e "${RED}FAILED${NC} (HTTP $http_code)" | tee -a "$RESULTS_FILE"
+        ((FAILED++))
+    fi
+
+    # 显示关键结果
+    if [[ "$body" == *"success"* ]] || [[ "$body" == *"chunkCount"* ]] || [[ "$body" == *"score"* ]]; then
+        echo "  -> $body" | tee -a "$RESULTS_FILE"
+    fi
+    echo "" | tee -a "$RESULTS_FILE"
+}
+
+# ========================================================================
+# 1. 健康检查
+# ========================================================================
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | tee -a "$RESULTS_FILE"
+echo "1. 健康检查" | tee -a "$RESULTS_FILE"
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | tee -a "$RESULTS_FILE"
+
+test_api "健康检查" "GET" "/actuator/health" "" "200"
+
+# ========================================================================
+# 2. 单个文档入库 - 银行流水示例
+# ========================================================================
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | tee -a "$RESULTS_FILE"
+echo "2. 单个文档入库 - 银行流水" | tee -a "$RESULTS_FILE"
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | tee -a "$RESULTS_FILE"
+
+test_api "入库-银行流水" \
+    "POST" \
+    "/api/v1/documents/index" \
+    '{
+      "docId": "bank-flow-001",
+      "fileName": "111111133.png",
+      "fullText": "账户明细\n\n账号:09-75******6775 户名:上海***有限公司 币种:人民币\n\n中国农业银行\n\n明细回单\n\n专用章\n\n日期:2023年01月05日-2023年04月16日\n\n交易时间 | 金额 | 余额 | 交易用途 | 交易对方\n2023/01/05 09:15:00 | 320 | 320 | 水稻销售收入(优质粳稻) | 金穗粮食贸易公司\n2023/01/08 10:45:00 | -150 | 170 | 化肥采购(复合肥) | 丰收农资连锁\n2023/01/12 14:20:00 | 280 | 450 | 蔬菜销售款(西红柿/黄瓜) | 绿源农产品公司\n2023/01/15 11:30:00 | -120 | 330 | 农机维修保养 | 北方农机服务\n2023/01/18 15:50:00 | 180 | 510 | 生猪出栏销售收入 | 双汇食品集团\n2023/01/22 09:25:00 | -80 | 430 | 种子采购(玉米种子) | 中种农业科技\n2023/01/25 13:10:00 | 250 | 680 | 中药材销售(黄芪) | 同仁堂药业\n2023/01/28 16:35:00 | -60 | 620 | 饲料采购 | 正大饲料公司\n2023/02/01 08:40:00 | 150 | 770 | 农资返利结算 | 丰收农资连锁\n2023/02/05 10:15:00 | -90 | 680 | 土地流转费支付 | 村委会\n2023/02/08 14:00:00 | 220 | 900 | 农产品冷链运输服务费 | 顺丰冷链物流\n2023/02/12 11:20:00 | -75 | 825 | 员工薪酬发放 | 员工个人账户\n2023/02/15 15:30:00 | 190 | 1015 | 蔬菜零收款 | 社区生鲜店\n2023/02/18 09:45:00 | -110 | 905 | 农业保险续保 | 太平洋保险\n2023/02/22 13:25:00 | 270 | 1175 | 种粮补贴收入 | 市农业农村局",
+      "filePath": "/data/files/20260313_8f9e7d6c5b4a.png",
+      "fileSize": 102400,
+      "fileType": "png",
+      "metadata": {
+        "file_unique_id": "20260313_8f9e7d6c5b4a",
+        "business_topic": "金融-信贷审批",
+        "document_type": "银行流水",
+        "belong_department": "风控部",
+        "tags": ["信贷", "流水", "合规", "农业"]
+      }
+    }' \
+    "200"
+
+# ========================================================================
+# 3. 单个文档入库 - 操作手册
+# ========================================================================
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | tee -a "$RESULTS_FILE"
+echo "3. 单个文档入库 - 操作手册" | tee -a "$RESULTS_FILE"
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | tee -a "$RESULTS_FILE"
+
+test_api "入库-操作手册" \
+    "POST" \
+    "/api/v1/documents/index" \
+    '{
+      "docId": "manual-001",
+      "fileName": "宇信科技管理数智化星云平台-操作手册.docx",
+      "fullText": "宇信科技管理数智化星云平台 快速入门操作手册\n\n北京宇信科技集团股份有限公司\n\n产品概述\n本平台是针对宇信科技管理数智化星云平台的快速入门手册,涵盖从系统初始操作,建立账号信息,建立个人工作台到进行数据分析的完整流程。\n\n系统初始配置\n系统管理员需对平台进行初始设置,包括:\n1. 公共参数维护:逻辑系统管理、控制点管理、系统参数、数据字典配置\n2. 用户建立及权限划分:机构管理、部门管理、岗位管理、角色管理、用户管理\n3. 应用维护及授权\n4. 业务流程定义\n\n用户权限管理\n系统支持基于角色的权限控制,不同角色拥有不同的功能权限和数据权限。角色类型包括:系统管理人员、业务人员、业务管理人员。\n\n数据准备与指标管理\n业务人员可通过数据源配置、数据集管理、维度管理等功能准备数据,创建根指标、组合指标、派生指标,并进行指标测试和上线管理。\n\n经营分析看板\n支持通过指标配置经营分析看板,包括普通模式和专业模式两种类型,可进行看板配置、公开、授权和查看。",
+      "filePath": "/data/files/20260313_8f9e7d6c5b4a123123.docx",
+      "fileSize": 2048000,
+      "fileType": "docx",
+      "metadata": {
+        "file_unique_id": "20260313_8f9e7d6c5b4a1123",
+        "business_topic": "金融-系统文件",
+        "document_type": "操作手册",
+        "belong_department": "产品部",
+        "tags": ["金融", "企管", "星云", "操作手册"]
+      }
+    }' \
+    "200"
+
+# ========================================================================
+# 4. 批量文档入库 - 多类型文档
+# ========================================================================
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | tee -a "$RESULTS_FILE"
+echo "4. 批量文档入库 - 多类型文档" | tee -a "$RESULTS_FILE"
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | tee -a "$RESULTS_FILE"
+
+test_api "批量入库-3个文档" \
+    "POST" \
+    "/api/v1/documents/batch-index" \
+    '{
+      "items": [
+        {
+          "docId": "contract-loan-test",
+          "fileName": "贷款合同.pdf",
+          "fullText": "贷款合同范本,主要条款包括贷款金额、利率、还款方式、担保条款等。甲方为贷款人,乙方为借款人。合同约定贷款金额为人民币500万元,期限36个月,按等额本息方式还款。",
+          "filePath": "/data/contracts/loan001.pdf",
+          "fileSize": 512000,
+          "fileType": "pdf",
+          "metadata": {
+            "business_topic": "金融-信贷审批",
+            "document_type": "贷款合同",
+            "belong_department": "信贷部",
+            "tags": ["贷款", "合同", "信贷"]
+          }
+        },
+        {
+          "docId": "audit-report-001",
+          "fileName": "审计报告2025.pdf",
+          "fullText": "年度审计报告,对公司2024年度财务状况进行审计。审计范围包括资产负债表、利润表、现金流量表等财务报表,以及内部控制制度的有效性。审计意见为标准无保留意见。",
+          "filePath": "/data/reports/audit2025.pdf",
+          "fileSize": 1024000,
+          "fileType": "pdf",
+          "metadata": {
+            "business_topic": "金融-审计",
+            "document_type": "审计报告",
+            "belong_department": "审计部",
+            "tags": ["审计", "财务", "合规"]
+          }
+        },
+        {
+          "docId": "risk-assessment-001",
+          "fileName": "风险评估报告.pdf",
+          "fullText": "企业风险评估报告,评估了公司的信用风险、市场风险、操作风险和合规风险。根据评估结果,建议加强贷前调查和贷后管理,完善内部控制制度。",
+          "filePath": "/data/reports/risk001.pdf",
+          "fileSize": 768000,
+          "fileType": "pdf",
+          "metadata": {
+            "business_topic": "金融-风险管理",
+            "document_type": "风险评估报告",
+            "belong_department": "风控部",
+            "tags": ["风险", "评估", "合规"]
+          }
+        }
+      ]
+    }' \
+    "200"
+
+# ========================================================================
+# 5. 等待索引
+# ========================================================================
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | tee -a "$RESULTS_FILE"
+echo "5. 等待文档索引完成..." | tee -a "$RESULTS_FILE"
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | tee -a "$RESULTS_FILE"
+sleep 3
+
+# ========================================================================
+# 6. 向量搜索测试 - 按语义搜索
+# ========================================================================
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | tee -a "$RESULTS_FILE"
+echo "6. 向量搜索 - 语义搜索测试" | tee -a "$RESULTS_FILE"
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | tee -a "$RESULTS_FILE"
+
+# 6.1 搜索银行流水相关内容
+test_api "向量搜索-银行流水" \
+    "POST" \
+    "/api/v1/search" \
+    '{
+      "query": "农业银行账户交易明细流水",
+      "topK": 3
+    }' \
+    "200"
+
+# 6.2 搜索操作手册相关内容
+test_api "向量搜索-系统操作" \
+    "POST" \
+    "/api/v1/search" \
+    '{
+      "query": "如何设置用户权限和角色管理",
+      "topK": 3
+    }' \
+    "200"
+
+# 6.3 搜索贷款合同相关内容
+test_api "向量搜索-贷款合同" \
+    "POST" \
+    "/api/v1/search" \
+    '{
+      "query": "贷款合同还款方式和担保条款",
+      "topK": 3
+    }' \
+    "200"
+
+# 6.4 搜索审计相关内容
+test_api "向量搜索-审计报告" \
+    "POST" \
+    "/api/v1/search" \
+    '{
+      "query": "财务审计年度报表",
+      "topK": 3
+    }' \
+    "200"
+
+# 6.5 搜索风控相关内容
+test_api "向量搜索-风险评估" \
+    "POST" \
+    "/api/v1/search" \
+    '{
+      "query": "企业信用风险评估",
+      "topK": 3
+    }' \
+    "200"
+
+# ========================================================================
+# 7. 混合搜索测试 - 按业务维度过滤
+# ========================================================================
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | tee -a "$RESULTS_FILE"
+echo "7. 混合搜索 - 业务维度过滤" | tee -a "$RESULTS_FILE"
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | tee -a "$RESULTS_FILE"
+
+# 7.1 按业务主题过滤 - 信贷审批
+test_api "混合搜索-信贷审批主题" \
+    "POST" \
+    "/api/v1/search/hybrid" \
+    '{
+      "query": "合同条款",
+      "topK": 5,
+      "filters": {
+        "business_topic": "金融-信贷审批"
+      }
+    }' \
+    "200"
+
+# 7.2 按文档类型过滤 - 贷款合同
+test_api "混合搜索-贷款合同类型" \
+    "POST" \
+    "/api/v1/search/hybrid" \
+    '{
+      "query": "贷款",
+      "topK": 5,
+      "filters": {
+        "document_type": "贷款合同"
+      }
+    }' \
+    "200"
+
+# 7.3 按部门过滤 - 风控部
+test_api "混合搜索-风控部" \
+    "POST" \
+    "/api/v1/search/hybrid" \
+    '{
+      "query": "风险",
+      "topK": 5,
+      "filters": {
+        "belong_department": "风控部"
+      }
+    }' \
+    "200"
+
+# 7.4 按标签过滤 - 包含"合规"标签
+test_api "混合搜索-合规标签" \
+    "POST" \
+    "/api/v1/search/hybrid" \
+    '{
+      "query": "报告",
+      "topK": 5,
+      "filters": {
+        "tags": "合规"
+      }
+    }' \
+    "200"
+
+# 7.5 多条件过滤 - 信贷部 + 贷款合同
+test_api "混合搜索-多条件过滤" \
+    "POST" \
+    "/api/v1/search/hybrid" \
+    '{
+      "query": "贷款",
+      "topK": 5,
+      "filters": {
+        "belong_department": "信贷部",
+        "document_type": "贷款合同"
+      }
+    }' \
+    "200"
+
+# ========================================================================
+# 8. 查询文档测试
+# ========================================================================
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | tee -a "$RESULTS_FILE"
+echo "8. 查询文档测试" | tee -a "$RESULTS_FILE"
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | tee -a "$RESULTS_FILE"
+
+test_api "查询-银行流水" \
+    "GET" \
+    "/api/v1/documents/bank-flow-001" \
+    "" \
+    "200"
+
+test_api "查询-操作手册" \
+    "GET" \
+    "/api/v1/documents/manual-001" \
+    "" \
+    "200"
+
+test_api "查询-不存在的文档" \
+    "GET" \
+    "/api/v1/documents/not-exist-doc" \
+    "" \
+    "200"
+
+# ========================================================================
+# 9. 验证返回格式 - metadata是否只包含业务字段
+# ========================================================================
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | tee -a "$RESULTS_FILE"
+echo "9. 验证返回格式" | tee -a "$RESULTS_FILE"
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | tee -a "$RESULTS_FILE"
+
+echo "验证:搜索结果中metadata是否只包含业务扩展字段..." | tee -a "$RESULTS_FILE"
+response=$(curl -s -X POST "$BASE_URL/api/v1/search" \
+    -H "Content-Type: application/json" \
+    -d '{"query": "银行", "topK": 1}')
+
+# 检查返回的metadata是否包含公共字段
+if echo "$response" | grep -q '"file_path"'; then
+    echo -e "${RED}FAILED${NC}: metadata中包含公共字段file_path" | tee -a "$RESULTS_FILE"
+    ((FAILED++))
+else
+    echo -e "${GREEN}PASSED${NC}: metadata中无公共字段" | tee -a "$RESULTS_FILE"
+    ((PASSED++))
+fi
+
+echo "验证:搜索结果是否包含score字段..." | tee -a "$RESULTS_FILE"
+if echo "$response" | grep -q '"score"'; then
+    echo -e "${GREEN}PASSED${NC}: 返回结果包含score" | tee -a "$RESULTS_FILE"
+    ((PASSED++))
+else
+    echo -e "${RED}FAILED${NC}: 返回结果缺少score" | tee -a "$RESULTS_FILE"
+    ((FAILED++))
+fi
+
+echo "验证:搜索结果是否不包含embedding..." | tee -a "$RESULTS_FILE"
+if echo "$response" | grep -q '"embedding"'; then
+    echo -e "${RED}FAILED${NC}: 返回结果包含embedding" | tee -a "$RESULTS_FILE"
+    ((FAILED++))
+else
+    echo -e "${GREEN}PASSED${NC}: 返回结果不包含embedding" | tee -a "$RESULTS_FILE"
+    ((PASSED++))
+fi
+
+echo "" | tee -a "$RESULTS_FILE"
+
+# ========================================================================
+# 10. 清理测试数据
+# ========================================================================
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | tee -a "$RESULTS_FILE"
+echo "10. 清理测试数据" | tee -a "$RESULTS_FILE"
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | tee -a "$RESULTS_FILE"
+
+test_api "清理-银行流水" \
+    "DELETE" \
+    "/api/v1/documents/bank-flow-001" \
+    "" \
+    "200"
+
+test_api "清理-操作手册" \
+    "DELETE" \
+    "/api/v1/documents/manual-001" \
+    "" \
+    "200"
+
+test_api "清理-批量文档" \
+    "DELETE" \
+    "/api/v1/documents/contract-loan-test" \
+    "" \
+    "200"
+
+test_api "清理-审计报告" \
+    "DELETE" \
+    "/api/v1/documents/audit-report-001" \
+    "" \
+    "200"
+
+test_api "清理-风险评估" \
+    "DELETE" \
+    "/api/v1/documents/risk-assessment-001" \
+    "" \
+    "200"
+
+# ========================================================================
+# 测试总结
+# ========================================================================
+echo "========================================" | tee -a "$RESULTS_FILE"
+echo "  测试总结" | tee -a "$RESULTS_FILE"
+echo "========================================" | tee -a "$RESULTS_FILE"
+TOTAL=$((PASSED + FAILED))
+echo "总用例数: $TOTAL" | tee -a "$RESULTS_FILE"
+echo -e "通过: ${GREEN}$PASSED${NC}" | tee -a "$RESULTS_FILE"
+echo -e "失败: ${RED}$FAILED${NC}" | tee -a "$RESULTS_FILE"
+
+if [ $TOTAL -gt 0 ]; then
+    PASS_RATE=$((PASSED * 100 / TOTAL))
+    echo "通过率: $PASS_RATE%" | tee -a "$RESULTS_FILE"
+fi
+
+echo "" | tee -a "$RESULTS_FILE"
+echo "测试内容说明:" | tee -a "$RESULTS_FILE"
+echo "1. 入库测试:银行流水、操作手册、批量多类型文档" | tee -a "$RESULTS_FILE"
+echo "2. 向量搜索:按语义搜索不同业务内容" | tee -a "$RESULTS_FILE"
+echo "3. 混合搜索:按业务主题、文档类型、部门、标签过滤" | tee -a "$RESULTS_FILE"
+echo "4. 查询测试:查询存在/不存在文档" | tee -a "$RESULTS_FILE"
+echo "5. 格式验证:metadata不包含公共字段、无embedding" | tee -a "$RESULTS_FILE"
+echo "" | tee -a "$RESULTS_FILE"
+
+if [ $FAILED -eq 0 ]; then
+    echo -e "${GREEN}✓ 所有测试通过!${NC}" | tee -a "$RESULTS_FILE"
+    exit 0
+else
+    echo -e "${RED}✗ 有 $FAILED 个测试失败${NC}" | tee -a "$RESULTS_FILE"
+    exit 1
+fi

+ 0 - 167
schedule-embedding-api/开发计划.md

@@ -1,167 +0,0 @@
-# schedule-embedding-api 开发计划
-
-## 一、模块定位
-
-- **REST API**:对外提供 HTTP 接口,支持同步入库、搜索、文档管理
-- **Kafka 消费**:监听 embedding 专用 Topic,异步消费文档入库任务,内部完成分段、向量化、入库
-
-## 二、数据流
-
-```
-                    ┌─────────────────────────────────────────┐
-                    │           schedule-producer              │
-                    │    发送文档入库任务到 embedding-topic     │
-                    └────────────────────┬──────────────────────┘
-                                        │
-                                        ↓
-                    ┌─────────────────────────────────────────┐
-                    │        schedule-embedding-api             │
-                    │  ┌─────────────────┐ ┌────────────────┐ │
-                    │  │ Kafka Consumer  │ │   REST API      │ │
-                    │  │ @KafkaListener  │ │  POST/GET/...  │ │
-                    │  └────────┬────────┘ └───────┬──────────┘ │
-                    │          │                  │            │
-                    │          └────────┬─────────┘            │
-                    │                   ↓                       │
-                    │     TextEmbeddingService (分段→向量化→入库)│
-                    └───────────────────┬──────────────────────┘
-                                        │
-                      ┌─────────────────┼─────────────────┐
-                      ↓                 ↓                  ↓
-              Embedding 接口      Elasticsearch      (可选) 回调通知
-```
-
----
-
-## 三、TodoList
-
-### 1. 项目骨架与依赖
-
-- [ ] 1.1 新建 `schedule-embedding-api` 模块,继承父 pom
-- [ ] 1.2 添加 Spring Boot Web、Validation 依赖
-- [ ] 1.3 添加 Spring Kafka 依赖
-- [ ] 1.4 添加 Elasticsearch Rest High Level Client (7.17.x,兼容 JDK8)
-- [ ] 1.5 添加 OkHttp 依赖(调用 Embedding 接口)
-- [ ] 1.6 添加 Jackson、Lombok、Commons-Lang3
-- [ ] 1.7 新建 `ScheduleEmbeddingApplication` 启动类
-- [ ] 1.8 配置 `application.yml` 基础结构(端口、应用名)
-
-### 2. 配置与基础设施
-
-- [ ] 2.1 配置 Embedding 接口:url、apiKey、model、timeout、batchSize
-- [ ] 2.2 配置 Elasticsearch:host、indexName、shards、replicas、超时
-- [ ] 2.3 配置 Chunk 分段:maxLength、minLength、overlap、mode
-- [ ] 2.4 配置 Kafka:bootstrap-servers、group-id、embedding-topic、enable-auto-commit
-- [ ] 2.5 新建 `ElasticsearchConfig`,创建 `RestHighLevelClient` Bean
-- [ ] 2.6 新建 `EmbeddingConfig`,读取 Embedding 相关配置
-- [ ] 2.7 新建 `ChunkConfig`,读取分段参数
-- [ ] 2.8 新建 `KafkaConsumerConfig`,配置反序列化、ack 模式
-
-### 3. 数据模型
-
-- [ ] 3.1 新建 `Chunk` 实体:docId、chunkId、chunkIndex、content、contentLength、metadata
-- [ ] 3.2 新建 `TextDocument` 实体:与 ES 文档结构对应
-- [ ] 3.3 新建 `IndexRequest` DTO:docId、fileName、fullText、metadata
-- [ ] 3.4 新建 `BatchIndexRequest` DTO:List\<IndexRequest\>
-- [ ] 3.5 新建 `SearchRequest` DTO:query、topK、filters
-- [ ] 3.6 新建 `SearchResult` DTO:chunkId、content、score、metadata
-- [ ] 3.7 新建 `IndexResult` VO:success、docId、chunkCount、message
-- [ ] 3.8 新建 `BatchIndexResult` VO:totalCount、successCount、failedCount、failedItems
-- [ ] 3.9 定义 Kafka 消息体 DTO(如 `EmbeddingTaskMessage`):docId、fullText、metadata、回调 URL 等
-
-### 4. 文本分段
-
-- [ ] 4.1 新建 `TextSplitter` 组件,注入分段配置
-- [ ] 4.2 实现 `preprocess`:统一换行、合并空格、trim
-- [ ] 4.3 实现 `splitByParagraph`:按双换行切分段落
-- [ ] 4.4 实现 `split`:按段落优先,超长再按 maxLength 切分,支持 overlap
-- [ ] 4.5 实现 `createChunk`:生成 chunkId、chunkIndex,填充 metadata
-- [ ] 4.6 编写 `TextSplitterTest` 单元测试
-
-### 5. Embedding 客户端
-
-- [ ] 5.1 新建 `EmbeddingClient` 组件,注入 apiUrl、apiKey、model
-- [ ] 5.2 实现 `getEmbedding(String text)`:单文本向量化,返回 4096 维 float[]
-- [ ] 5.3 实现 `getBatchEmbeddings(List<String> texts)`:批量向量化(≤128 条)
-- [ ] 5.4 实现 HTTP 请求构造:POST /embeddings,Authorization: Bearer {key}
-- [ ] 5.5 实现响应解析:从 data[0].embedding 提取向量
-- [ ] 5.6 实现重试:指数退避,最多 3 次,超时 30 秒
-- [ ] 5.7 校验返回向量维度是否为 4096
-- [ ] 5.8 新建 `EmbeddingException` 自定义异常
-
-### 6. Elasticsearch 操作
-
-- [ ] 6.1 新建 `ElasticsearchRepository`,注入 RestHighLevelClient 与 indexName
-- [ ] 6.2 实现 `createIndex`:创建 contract_chunks 索引,mapping 含 dense_vector(4096)、cosine
-- [ ] 6.3 实现 `indexDocument`:单文档写入,使用 chunkId 作为 _id(幂等)
-- [ ] 6.4 实现 `bulkIndex`:BulkRequest 批量写入
-- [ ] 6.5 实现 `knnSearch`:KNN 查询,cosine 相似度,返回 topK
-- [ ] 6.6 实现 `searchWithFilters`:KNN + bool filter(contractType、partyA 等)
-- [ ] 6.7 实现 `findByDocId`:按 doc_id 查询,按 chunk_index 排序
-- [ ] 6.8 实现 `deleteByDocId`:_delete_by_query 按 doc_id 删除
-- [ ] 6.9 实现 `updateByDocId`:_update_by_query 批量更新 metadata
-- [ ] 6.10 新建 `ElasticsearchException` 自定义异常
-- [ ] 6.11 启动时检查索引是否存在,不存在则自动创建
-
-### 7. 业务服务层
-
-- [ ] 7.1 新建 `TextEmbeddingService`,注入 EmbeddingClient、ElasticsearchRepository、TextSplitter
-- [ ] 7.2 实现 `indexDocument`:全量文本 → 分段 → 批量向量化 → 批量入库
-- [ ] 7.3 实现 `batchIndexDocuments`:多文档入库,按 128 条分批向量化
-- [ ] 7.4 实现 `search`:query 向量化 → KNN 搜索 → 映射为 SearchResult
-- [ ] 7.5 实现 `hybridSearch`:query 向量化 + 业务过滤 → 混合查询
-- [ ] 7.6 新建 `DocumentService`,注入 ElasticsearchRepository
-- [ ] 7.7 实现 `getDocument`:按 docId 查所有 chunks
-- [ ] 7.8 实现 `deleteDocument`:按 docId 删除所有 chunks
-- [ ] 7.9 实现 `updateDocument`:按 docId 更新 metadata(_update_by_query)
-
-### 8. REST 控制器
-
-- [ ] 8.1 新建 `TextEmbeddingController`,基础路径 `/api/v1`
-- [ ] 8.2 `POST /documents/index`:单个文档入库,请求体 IndexRequest,返回 IndexResult
-- [ ] 8.3 `POST /documents/batch-index`:批量入库,请求体 BatchIndexRequest,返回 BatchIndexResult
-- [ ] 8.4 新建 `VectorSearchController`
-- [ ] 8.5 `POST /search`:向量搜索,请求体 SearchRequest,返回 List\<SearchResult\>
-- [ ] 8.6 `POST /search/hybrid`:混合搜索(向量 + 业务过滤)
-- [ ] 8.7 新建 `DocumentController`
-- [ ] 8.8 `GET /documents/{docId}`:查询文档 chunks,支持 sortBy、order 参数
-- [ ] 8.9 `DELETE /documents/{docId}`:删除文档所有 chunks
-- [ ] 8.10 `PUT /documents/{docId}`:更新文档 metadata
-- [ ] 8.11 配置 Actuator,暴露 `/actuator/health`
-- [ ] 8.12 添加统一异常处理(@ControllerAdvice),返回标准错误结构
-
-### 9. Kafka 消费
-
-- [ ] 9.1 新建 `EmbeddingTaskMessage`:解析 Kafka 消息体(docId、fullText、metadata、callbackUrl 等)
-- [ ] 9.2 新建 `EmbeddingTaskListener`,使用 @KafkaListener 监听 embedding-topic
-- [ ] 9.3 消费逻辑:解析消息 → 调用 TextEmbeddingService.indexDocument → ack
-- [ ] 9.4 处理失败:捕获异常、记录日志,根据策略决定 ack 或 nack(支持重试)
-- [ ] 9.5 可选:消费成功后,如有 callbackUrl,HTTP 回调通知上游
-- [ ] 9.6 配置 `kafka.topics.embedding` 为 embedding 专用 Topic 名称
-- [ ] 9.7 配置消费者 group-id(如 embedding-api-group),与现有 consumer 区分
-
-### 10. Producer 联动(可选)
-
-- [ ] 10.1 在 schedule-producer 中增加“发送文档入库任务”能力(若尚未支持)
-- [ ] 10.2 定义 embedding-topic,与 producer 约定消息格式(JSON:docId、fullText、metadata)
-- [ ] 10.3 文档解析完成后,将 fullText 发送到 embedding-topic
-
-### 11. 异常与重试
-
-- [ ] 11.1 Embedding 调用:超时 30s、最多重试 3 次、指数退避
-- [ ] 11.2 ES Bulk 部分失败:记录 failedItems,整体不抛异常,返回失败明细
-- [ ] 11.3 Kafka 消费异常:记录 failed 消息,支持 nack 或写入死信队列(若需)
-
-### 12. 测试与验证
-
-- [ ] 12.1 TextSplitter 单元测试:多段文本、超长段落、边界情况
-- [ ] 12.2 EmbeddingClient 单元测试:Mock HTTP,校验请求与解析
-- [ ] 12.3 ElasticsearchRepository 集成测试:需本地或测试 ES
-- [ ] 12.4 TextEmbeddingService 集成测试:分段 + 向量化 + 入库 全流程
-- [ ] 12.5 REST API 接口测试:curl 或 Postman 验证
-- [ ] 12.6 Kafka 端到端测试:发送消息 → 消费 → 查 ES 验证入库
-- [ ] 12.7 健康检查:/actuator/health 含 ES、Embedding 连通性(可选)
-
----
-
-**参考文档**:`schedule-embedding-api/向量化入库方案.md`

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor