فهرست منبع

qdrant向量知识库

yh 2 روز پیش
والد
کامیت
56a4e25671

+ 879 - 0
docs/Qdrant API 文档.md

@@ -0,0 +1,879 @@
+# Qdrant REST API 文档
+
+> **基础地址**: `https://saaschroma.ylrzcloud.com`
+> **Qdrant 版本**: 1.17.1
+> **向量维度**: 1024
+> **距离算法**: Cosine
+> **已有 Collection**: `saasai_3`
+
+> 所有请求 **Content-Type**: `application/json`
+
+---
+
+## 目录
+
+1. [基础信息](#1-基础信息)
+2. [Collection 管理](#2-collection-管理)
+3. [Point 管理](#3-point-管理)
+4. [向量搜索](#4-向量搜索)
+5. [Cluster 管理](#5-cluster-管理)
+
+---
+
+## 1. 基础信息
+
+### 1.1 获取服务状态
+
+```
+GET https://saaschroma.ylrzcloud.com/
+```
+
+**响应示例**:
+
+```json
+{
+  "title": "qdrant - vector search engine",
+  "version": "1.17.1",
+  "commit": "eabee371fda447974a94d29fbaa675a6a596cc7b"
+}
+```
+
+---
+
+## 2. Collection 管理
+
+### 2.1 获取所有 Collection 列表
+
+```
+GET https://saaschroma.ylrzcloud.com/collections
+```
+
+**响应示例**:
+
+```json
+{
+  "result": {
+    "collections": [
+      {
+        "name": "saasai_3"
+      }
+    ]
+  },
+  "status": "ok",
+  "time": 5.9e-6
+}
+```
+
+---
+
+### 2.2 获取 Collection 详情
+
+```
+GET https://saaschroma.ylrzcloud.com/collections/saasai_3
+```
+
+**响应示例**:
+
+```json
+{
+  "result": {
+    "status": "green",
+    "optimizer_status": "ok",
+    "indexed_vectors_count": 0,
+    "points_count": 1,
+    "segments_count": 4,
+    "config": {
+      "params": {
+        "vectors": {
+          "size": 1024,
+          "distance": "Cosine"
+        },
+        "shard_number": 1,
+        "replication_factor": 1,
+        "write_consistency_factor": 1,
+        "on_disk_payload": true
+      },
+      "hnsw_config": {
+        "m": 16,
+        "ef_construct": 100,
+        "full_scan_threshold": 10000,
+        "max_indexing_threads": 0,
+        "on_disk": false
+      },
+      "optimizer_config": {
+        "deleted_threshold": 0.2,
+        "vacuum_min_vector_number": 1000,
+        "default_segment_number": 0,
+        "max_segment_size": null,
+        "indexing_threshold": 10000,
+        "flush_interval_sec": 5
+      },
+      "wal_config": {
+        "wal_capacity_mb": 32,
+        "wal_segments_ahead": 0
+      },
+      "quantization_config": null
+    },
+    "payload_schema": {},
+    "update_queue": {
+      "length": 0
+    }
+  },
+  "status": "ok",
+  "time": 0.0002909
+}
+```
+
+---
+
+### 2.3 创建 Collection
+
+```
+PUT https://saaschroma.ylrzcloud.com/collections/{collectionName}
+```
+
+**请求参数**(Path 参数):
+
+| 参数 | 类型 | 说明 |
+|------|------|------|
+| collectionName | String | 集合名称,例如 `my_knowledge_base` |
+
+**请求体**:
+
+```json
+{
+  "vectors": {
+    "size": 1024,
+    "distance": "Cosine"
+  }
+}
+```
+
+**参数说明**:
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| vectors.size | int | 是 | 向量维度(当前固定 1024) |
+| vectors.distance | String | 是 | 距离算法:`Cosine` / `Euclid` / `Dot` |
+
+**响应示例**:
+
+```json
+{
+  "result": true,
+  "status": "ok",
+  "time": 0.058248
+}
+```
+
+---
+
+### 2.4 删除 Collection
+
+```
+DELETE https://saaschroma.ylrzcloud.com/collections/{collectionName}
+```
+
+**请求参数**(Path 参数):
+
+| 参数 | 类型 | 说明 |
+|------|------|------|
+| collectionName | String | 要删除的集合名称 |
+
+**响应示例**:
+
+```json
+{
+  "result": true,
+  "status": "ok",
+  "time": 0.016075
+}
+```
+
+---
+
+### 2.5 更新 Collection 配置
+
+```
+PATCH https://saaschroma.ylrzcloud.com/collections/{collectionName}
+```
+
+**请求体**:
+
+```json
+{
+  "optimizers_config": {
+    "indexing_threshold": 10000
+  },
+  "params": {
+    "replication_factor": 2,
+    "write_consistency_factor": 1
+  }
+}
+```
+
+**参数说明**:
+
+| 参数 | 类型 | 说明 |
+|------|------|------|
+| optimizers_config.indexing_threshold | int | 索引阈值,超过此数量自动建索引 |
+| params.replication_factor | int | 副本因子 |
+| params.write_consistency_factor | int | 写入一致性因子 |
+
+**响应示例**:
+
+```json
+{
+  "result": true,
+  "status": "ok",
+  "time": 0.053372
+}
+```
+
+---
+
+## 3. Point 管理
+
+### 3.1 批量写入(Upsert)Points
+
+```
+PUT https://saaschroma.ylrzcloud.com/collections/{collectionName}/points
+```
+
+**请求参数**(Path 参数):
+
+| 参数 | 类型 | 说明 |
+|------|------|------|
+| collectionName | String | 集合名称 |
+
+**请求体**:
+
+```json
+{
+  "points": [
+    {
+      "id": 1,
+      "vector": [0.01, 0.02, 0.03, ...],  ← 1024 个 float
+      "payload": {
+        "document": "这份文件的主要内容是关于XX疾病的基本知识。",
+        "chunk_index": 0,
+        "doc_id": 1001,
+        "dataset_id": 1,
+        "file_name": "XX疾病知识手册.pdf"
+      }
+    },
+    {
+      "id": 2,
+      "vector": [0.04, 0.05, 0.06, ...],  ← 1024 个 float
+      "payload": {
+        "document": "疾病的病因包括遗传因素和环境因素两个方面。",
+        "chunk_index": 1,
+        "doc_id": 1001,
+        "dataset_id": 1,
+        "file_name": "XX疾病知识手册.pdf"
+      }
+    }
+  ],
+  "wait": true
+}
+```
+
+**参数说明**:
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| points[].id | int/long | 是 | Point 唯一 ID |
+| points[].vector | float[] | 是 | 向量数据,长度 1024 |
+| points[].payload | object | 否 | 附加元数据 |
+| wait | boolean | 否 | true=等待写入完成再返回;false=异步返回 |
+
+**响应示例**:
+
+```json
+{
+  "result": {
+    "operation_id": 123,
+    "status": "completed"
+  },
+  "status": "ok",
+  "time": 0.015
+}
+```
+
+---
+
+### 3.2 查询单个 Point
+
+```
+GET https://saaschroma.ylrzcloud.com/collections/{collectionName}/points/{id}?with_payload=true&with_vector=true
+```
+
+**请求参数**:
+
+| 参数 | 位置 | 类型 | 必填 | 说明 |
+|------|------|------|------|------|
+| collectionName | Path | String | 是 | 集合名称 |
+| id | Path | long | 是 | Point ID |
+| with_payload | Query | boolean | 否 | 是否返回 payload,默认 false |
+| with_vector | Query | boolean | 否 | 是否返回 vector,默认 false |
+
+**响应示例**:
+
+```json
+{
+  "result": {
+    "id": 1,
+    "version": 2,
+    "payload": {
+      "document": "这份文件的主要内容是关于XX疾病的基本知识。",
+      "chunk_index": 0,
+      "doc_id": 1001,
+      "file_name": "XX疾病知识手册.pdf"
+    },
+    "vector": [0.01, 0.02, 0.03, ...]
+  },
+  "status": "ok",
+  "time": 0.000227
+}
+```
+
+---
+
+### 3.3 滚动查询 Points(分页)
+
+```
+POST https://saaschroma.ylrzcloud.com/collections/{collectionName}/points/scroll
+```
+
+**请求体**:
+
+```json
+{
+  "limit": 10,
+  "offset": 0,
+  "with_payload": true,
+  "with_vector": false,
+  "filter": {
+    "must": [
+      {
+        "key": "doc_id",
+        "match": {
+          "value": 1001
+        }
+      }
+    ]
+  }
+}
+```
+
+**参数说明**:
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| limit | int | 否 | 返回数量,默认 10 |
+| offset | long/int | 否 | 偏移量(从 0 开始) |
+| with_payload | boolean | 否 | 是否返回 payload |
+| with_vector | boolean | 否 | 是否返回 vector |
+| filter | object | 否 | 筛选条件 |
+
+**响应示例**:
+
+```json
+{
+  "result": {
+    "points": [
+      {
+        "id": 1,
+        "payload": {
+          "document": "这份文件的主要内容是关于XX疾病的基本知识。",
+          "chunk_index": 0,
+          "doc_id": 1001
+        },
+        "vector": null
+      },
+      {
+        "id": 2,
+        "payload": {
+          "document": "疾病的病因包括遗传因素和环境因素两个方面。",
+          "chunk_index": 1,
+          "doc_id": 1001
+        },
+        "vector": null
+      }
+    ],
+    "next_page_offset": 2
+  },
+  "status": "ok",
+  "time": 0.0001
+}
+```
+
+> 注:`next_page_offset` 为 null 时表示无更多数据。将 `next_page_offset` 作为下一次请求的 `offset` 参数即可翻页。
+
+---
+
+### 3.4 删除 Points
+
+```
+POST https://saaschroma.ylrzcloud.com/collections/{collectionName}/points/delete
+```
+
+**请求体(按 ID 删除)**:
+
+```json
+{
+  "points": [1, 2, 3],
+  "wait": true
+}
+```
+
+**请求体(按 Filter 删除)**:
+
+```json
+{
+  "filter": {
+    "must": [
+      {
+        "key": "doc_id",
+        "match": {
+          "value": 1001
+        }
+      }
+    ]
+  },
+  "wait": true
+}
+```
+
+**参数说明**:
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| points | long[] | 二选一 | 按 ID 列表删除 |
+| filter | object | 二选一 | 按筛选条件删除 |
+| wait | boolean | 否 | 是否等待完成 |
+
+**响应示例**:
+
+```json
+{
+  "result": {
+    "operation_id": 456,
+    "status": "completed"
+  },
+  "status": "ok",
+  "time": 0.005
+}
+```
+
+---
+
+### 3.5 计数 Points
+
+```
+POST https://saaschroma.ylrzcloud.com/collections/{collectionName}/points/count
+```
+
+> 注意:不是 GET,是 POST
+
+**请求体**:
+
+```json
+{
+  "filter": {
+    "must": [
+      {
+        "key": "doc_id",
+        "match": {
+          "value": 1001
+        }
+      }
+    ]
+  }
+}
+```
+
+**参数说明**:
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| filter | object | 否 | 筛选条件;不传则统计整个 Collection 总数 |
+
+**响应示例**:
+
+```json
+{
+  "result": {
+    "count": 5
+  },
+  "status": "ok",
+  "time": 0.00005
+}
+```
+
+---
+
+### 3.6 更新向量(不修改 Payload)
+
+```
+PUT https://saaschroma.ylrzcloud.com/collections/{collectionName}/points/vectors
+```
+
+**请求体**:
+
+```json
+{
+  "points": [
+    {
+      "id": 1,
+      "vector": [0.05, 0.06, 0.07, ...]
+    },
+    {
+      "id": 2,
+      "vector": [0.08, 0.09, 0.10, ...]
+    }
+  ],
+  "wait": true
+}
+```
+
+**响应示例**:
+
+```json
+{
+  "result": {
+    "operation_id": 789,
+    "status": "completed"
+  },
+  "status": "ok",
+  "time": 0.012
+}
+```
+
+---
+
+### 3.7 设置 Payload
+
+```
+POST https://saaschroma.ylrzcloud.com/collections/{collectionName}/points/payload
+```
+
+**请求体**:
+
+```json
+{
+  "payload": {
+    "category": "内科",
+    "tags": ["高血压", "糖尿病"],
+    "importance": 5
+  },
+  "points": [1, 2, 3],
+  "wait": true
+}
+```
+
+> 可选使用 `filter` 替代 `points` 按筛选条件设置
+
+**响应示例**:
+
+```json
+{
+  "result": {
+    "operation_id": 101,
+    "status": "completed"
+  },
+  "status": "ok",
+  "time": 0.008
+}
+```
+
+---
+
+### 3.8 按 Key 删除 Payload
+
+```
+POST https://saaschroma.ylrzcloud.com/collections/{collectionName}/points/payload/delete
+```
+
+**请求体**:
+
+```json
+{
+  "keys": ["category", "tags"],
+  "points": [1, 2],
+  "wait": true
+}
+```
+
+> 可选使用 `filter` 替代 `points` 按筛选条件删除
+
+**响应示例**:
+
+```json
+{
+  "result": {
+    "operation_id": 102,
+    "status": "completed"
+  },
+  "status": "ok",
+  "time": 0.006
+}
+```
+
+---
+
+### 3.9 清空所有 Payload
+
+```
+PUT https://saaschroma.ylrzcloud.com/collections/{collectionName}/points/payload/clear
+```
+
+**请求体**:
+
+```json
+{
+  "points": [1, 2, 3],
+  "wait": true
+}
+```
+
+> 可选使用 `filter` 替代 `points` 按筛选条件清空
+
+**响应示例**:
+
+```json
+{
+  "result": {
+    "operation_id": 103,
+    "status": "completed"
+  },
+  "status": "ok",
+  "time": 0.007
+}
+```
+
+---
+
+## 4. 向量搜索
+
+### 4.1 向量相似度搜索
+
+```
+POST https://saaschroma.ylrzcloud.com/collections/{collectionName}/points/search
+```
+
+**请求体**:
+
+```json
+{
+  "vector": [0.01, 0.02, 0.03, ...],
+  "limit": 5,
+  "with_payload": true,
+  "with_vector": false,
+  "filter": {
+    "must": [
+      {
+        "key": "dataset_id",
+        "match": {
+          "value": 1
+        }
+      },
+      {
+        "key": "doc_id",
+        "match": {
+          "value": 1001
+        }
+      }
+    ]
+  },
+  "params": {
+    "hnsw_ef": 128,
+    "exact": false
+  }
+}
+```
+
+**参数说明**:
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| vector | float[] | 是 | 查询向量,长度 1024 |
+| limit | int | 否 | 返回 TopK 数量,默认 10 |
+| with_payload | boolean | 否 | 是否返回 payload |
+| with_vector | boolean | 否 | 是否返回 vector |
+| filter.must[] | object[] | 否 | 筛选条件(AND 关系) |
+| filter.should[] | object[] | 否 | 筛选条件(OR 关系) |
+| params.hnsw_ef | int | 否 | 搜索精度(越大越精确越慢),默认 128 |
+| params.exact | boolean | 否 | true=精确搜索,false=近似搜索 |
+
+**Filter 条件详细说明**:
+
+```json
+{
+  "must": [
+    { "key": "string_field",   "match":         { "value": "xxx" } },     // 精确匹配
+    { "key": "keyword_field",  "match":         { "keyword": "xxx" } },   // 关键词匹配
+    { "key": "text_field",     "match":         { "text": "xxx" } },      // 全文匹配(需配置全文索引)
+    { "key": "int_field",      "range":         { "gte": 1, "lte": 100 } },  // 范围匹配
+    { "key": "id_field",       "has_id":        [1, 2, 3] },              // ID 匹配
+    { "key": "ids_field",      "values_count":  { "gte": 2 } }            // 数组数量匹配
+  ],
+  "should": [ /* 同 must 结构,OR 逻辑 */ ],
+  "must_not": [ /* 同 must 结构,排除匹配 */ ]
+}
+```
+
+**响应示例**:
+
+```json
+{
+  "result": [
+    {
+      "id": 2,
+      "version": 2,
+      "score": 0.923456,
+      "payload": {
+        "document": "疾病的病因包括遗传因素和环境因素两个方面。",
+        "chunk_index": 1,
+        "doc_id": 1001,
+        "dataset_id": 1,
+        "file_name": "XX疾病知识手册.pdf"
+      },
+      "vector": null
+    },
+    {
+      "id": 1,
+      "version": 2,
+      "score": 0.891234,
+      "payload": {
+        "document": "这份文件的主要内容是关于XX疾病的基本知识。",
+        "chunk_index": 0,
+        "doc_id": 1001,
+        "dataset_id": 1,
+        "file_name": "XX疾病知识手册.pdf"
+      },
+      "vector": null
+    }
+  ],
+  "status": "ok",
+  "time": 0.002
+}
+```
+
+> **注意**:`score` 值范围 0~1(Cosine 距离),值越大表示越相似。
+
+---
+
+### 4.2 分组搜索
+
+```
+POST https://saaschroma.ylrzcloud.com/collections/{collectionName}/points/search/groups
+```
+
+**请求体**:
+
+```json
+{
+  "vector": [0.01, 0.02, 0.03, ...],
+  "limit": 5,
+  "group_by": "doc_id",
+  "group_size": 3,
+  "with_payload": true,
+  "with_vector": false
+}
+```
+
+**参数说明**:
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| group_by | String | 是 | 按哪个 payload key 分组 |
+| group_size | int | 是 | 每组返回多少个结果 |
+| limit | int | 是 | 返回多少组 |
+
+**响应示例**:
+
+```json
+{
+  "result": {
+    "groups": [
+      {
+        "id": "1001",
+        "hits": [
+          { "id": 2, "score": 0.923, "payload": { "doc_id": 1001, ... } },
+          { "id": 1, "score": 0.891, "payload": { "doc_id": 1001, ... } }
+        ]
+      },
+      {
+        "id": "1002",
+        "hits": [
+          { "id": 5, "score": 0.815, "payload": { "doc_id": 1002, ... } }
+        ]
+      }
+    ]
+  },
+  "status": "ok",
+  "time": 0.003
+}
+```
+
+---
+
+## 5. Cluster 管理
+
+### 5.1 获取 Collection 集群信息
+
+```
+GET https://saaschroma.ylrzcloud.com/collections/{collectionName}/cluster
+```
+
+**响应示例**:
+
+```json
+{
+  "result": {
+    "peer_id": 1752358415013712,
+    "shard_count": 1,
+    "local_shards": [
+      {
+        "shard_id": 0,
+        "points_count": 1,
+        "state": "Active"
+      }
+    ],
+    "remote_shards": [],
+    "shard_transfers": []
+  },
+  "status": "ok",
+  "time": 0.0002224
+}
+```
+
+---
+
+## 附:APIPost 导入说明
+
+### 快速使用步骤
+
+1. 打开 APIPost
+2. 创建项目或文件夹(如 "Qdrant")
+3. 按上面的格式逐个添加请求:
+   - 方法:根据接口选择 GET/POST/PUT/DELETE/PATCH
+   - URL:`https://saaschroma.ylrzcloud.com/xxx`
+   - Headers:`Content-Type: application/json`
+   - Body:选择 JSON 格式并粘贴对应请求体
+4. 点击发送
+
+### 常见问题
+
+| 问题 | 原因 | 解决 |
+|------|------|------|
+| 返回 404 | Collection 不存在 | 先创建 Collection 或检查名称拼写 |
+| 返回 400 | 请求参数格式错误 | 检查 JSON 格式和必填字段 |
+| 搜索返回 score = 0 | 向量维度不匹配 | 确认 vector 长度 = 1024 |
+| 响应慢 | 数据量太大或未建索引 | 设置 `params.exact: false` 使用近似搜索 |
+
+---
+
+> 本文档对应 Qdrant v1.17.1 REST API,完整官方文档请参考:
+> https://api.qdrant.tech/api-reference

+ 59 - 0
fs-ai-api/src/main/java/com/fs/ai/rag/controller/QdrantController.java

@@ -0,0 +1,59 @@
+package com.fs.ai.rag.controller;
+
+import com.fs.ai.rag.dto.QdrantCollectionReq;
+import com.fs.ai.rag.dto.QdrantPointDeleteReq;
+import com.fs.ai.rag.dto.QdrantPointGetReq;
+import com.fs.ai.rag.dto.QdrantPointSearchReq;
+import com.fs.ai.rag.dto.QdrantPointUpsertReq;
+import com.fs.ai.rag.service.QdrantService;
+import com.fs.common.core.domain.AjaxResult;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+
+@RestController
+@RequestMapping("/qdrant")
+public class QdrantController {
+
+    @Resource
+    private QdrantService qdrantService;
+
+    @PostMapping("/collection/create")
+    public AjaxResult createCollection(@RequestBody QdrantCollectionReq req) {
+        qdrantService.createCollection(req);
+        return AjaxResult.success();
+    }
+
+    @PostMapping("/collection/delete")
+    public AjaxResult deleteCollection(@RequestParam("collectionName") String collectionName) {
+        qdrantService.deleteCollection(collectionName);
+        return AjaxResult.success();
+    }
+
+    @PostMapping("/point/upsert")
+    public AjaxResult upsertPoint(@RequestBody QdrantPointUpsertReq req) {
+        qdrantService.upsertPoints(req);
+        return AjaxResult.success();
+    }
+
+    @PostMapping("/point/delete")
+    public AjaxResult deletePoint(@RequestBody QdrantPointDeleteReq req) {
+        qdrantService.deletePoints(req);
+        return AjaxResult.success();
+    }
+
+    @PostMapping("/point/get")
+    public AjaxResult getPoint(@RequestBody QdrantPointGetReq req) {
+        return AjaxResult.success(qdrantService.getPoint(req));
+    }
+
+    @PostMapping("/point/search")
+    public AjaxResult searchPoint(@RequestBody QdrantPointSearchReq req) {
+        return AjaxResult.success(qdrantService.search(req));
+    }
+}

+ 9 - 0
fs-ai-api/src/main/java/com/fs/ai/rag/dto/QdrantCollectionReq.java

@@ -0,0 +1,9 @@
+package com.fs.ai.rag.dto;
+
+import lombok.Data;
+
+@Data
+public class QdrantCollectionReq {
+    private String collectionName;
+    private Integer vectorSize;
+}

+ 11 - 0
fs-ai-api/src/main/java/com/fs/ai/rag/dto/QdrantPointDeleteReq.java

@@ -0,0 +1,11 @@
+package com.fs.ai.rag.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class QdrantPointDeleteReq {
+    private String collectionName;
+    private List<Long> ids;
+}

+ 9 - 0
fs-ai-api/src/main/java/com/fs/ai/rag/dto/QdrantPointGetReq.java

@@ -0,0 +1,9 @@
+package com.fs.ai.rag.dto;
+
+import lombok.Data;
+
+@Data
+public class QdrantPointGetReq {
+    private String collectionName;
+    private Long id;
+}

+ 14 - 0
fs-ai-api/src/main/java/com/fs/ai/rag/dto/QdrantPointSearchReq.java

@@ -0,0 +1,14 @@
+package com.fs.ai.rag.dto;
+
+import lombok.Data;
+
+import java.util.List;
+import java.util.Map;
+
+@Data
+public class QdrantPointSearchReq {
+    private String collectionName;
+    private List<Float> vector;
+    private Integer topK;
+    private Map<String, Object> filter;
+}

+ 15 - 0
fs-ai-api/src/main/java/com/fs/ai/rag/dto/QdrantPointUpsertReq.java

@@ -0,0 +1,15 @@
+package com.fs.ai.rag.dto;
+
+import lombok.Data;
+
+import java.util.List;
+import java.util.Map;
+
+@Data
+public class QdrantPointUpsertReq {
+    private String collectionName;
+    private List<Long> ids;
+    private List<List<Float>> vectors;
+    private List<String> documents;
+    private List<Map<String, Object>> payloads;
+}

+ 25 - 0
fs-ai-api/src/main/java/com/fs/ai/rag/service/QdrantService.java

@@ -0,0 +1,25 @@
+package com.fs.ai.rag.service;
+
+import com.fs.ai.rag.dto.QdrantCollectionReq;
+import com.fs.ai.rag.dto.QdrantPointDeleteReq;
+import com.fs.ai.rag.dto.QdrantPointGetReq;
+import com.fs.ai.rag.dto.QdrantPointSearchReq;
+import com.fs.ai.rag.dto.QdrantPointUpsertReq;
+
+import java.util.List;
+import java.util.Map;
+
+public interface QdrantService {
+
+    void createCollection(QdrantCollectionReq req);
+
+    void deleteCollection(String collectionName);
+
+    void upsertPoints(QdrantPointUpsertReq req);
+
+    void deletePoints(QdrantPointDeleteReq req);
+
+    Map<String, Object> getPoint(QdrantPointGetReq req);
+
+    List<Map<String, Object>> search(QdrantPointSearchReq req);
+}

+ 313 - 0
fs-ai-api/src/main/java/com/fs/ai/rag/service/impl/QdrantServiceImpl.java

@@ -0,0 +1,313 @@
+package com.fs.ai.rag.service.impl;
+
+import cn.hutool.http.HttpRequest;
+import cn.hutool.http.HttpResponse;
+import cn.hutool.http.Method;
+import cn.hutool.json.JSONUtil;
+import com.fs.ai.rag.dto.*;
+import com.fs.ai.rag.service.QdrantService;
+import com.fs.framework.config.properties.QdrantProperties;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@Slf4j
+@Service
+public class QdrantServiceImpl implements QdrantService {
+
+    private final QdrantProperties qdrantProperties;
+
+    public QdrantServiceImpl(QdrantProperties qdrantProperties) {
+        this.qdrantProperties = qdrantProperties;
+    }
+
+    @Override
+    public void createCollection(QdrantCollectionReq req) {
+        String collectionName = requireCollectionName(req.getCollectionName());
+        Integer vectorSize = req.getVectorSize() != null ? req.getVectorSize() : qdrantProperties.getVectorSize();
+        try {
+            if (collectionExists(collectionName)) {
+                log.info("Qdrant collection 已存在, 跳过创建, collectionName={}", collectionName);
+                return;
+            }
+            Map<String, Object> body = new LinkedHashMap<>();
+            Map<String, Object> vectors = new LinkedHashMap<>();
+            vectors.put("size", vectorSize);
+            vectors.put("distance", "Cosine");
+            body.put("vectors", vectors);
+            exchange(collectionUrl(collectionName), Method.PUT, body);
+        } catch (Exception e) {
+            throw new IllegalStateException("创建 Qdrant collection 失败: " + collectionName, e);
+        }
+    }
+
+    @Override
+    public void deleteCollection(String collectionName) {
+        String requiredName = requireCollectionName(collectionName);
+        try {
+            exchange(collectionUrl(requiredName), Method.DELETE, null);
+        } catch (Exception e) {
+            throw new IllegalStateException("删除 Qdrant collection 失败: " + requiredName, e);
+        }
+    }
+
+    @Override
+    public void upsertPoints(QdrantPointUpsertReq req) {
+        validateUpsertReq(req);
+        createCollectionIfAbsent(req.getCollectionName(), resolveVectorSize(req.getVectors()));
+        List<Map<String, Object>> points = new ArrayList<>();
+        for (int i = 0; i < req.getIds().size(); i++) {
+            Map<String, Object> point = new LinkedHashMap<>();
+            point.put("id", req.getIds().get(i));
+            point.put("vector", req.getVectors().get(i));
+            Map<String, Object> payload = buildPayload(req, i);
+            if (!payload.isEmpty()) {
+                point.put("payload", payload);
+            }
+            points.add(point);
+        }
+        Map<String, Object> body = new LinkedHashMap<>();
+        body.put("points", points);
+        body.put("wait", true);
+        try {
+            exchange(pointsUrl(req.getCollectionName()), Method.PUT, body);
+        } catch (Exception e) {
+            throw new IllegalStateException("Qdrant upsert 失败: " + req.getCollectionName(), e);
+        }
+    }
+
+    @Override
+    public void deletePoints(QdrantPointDeleteReq req) {
+        if (req == null || StringUtils.isBlank(req.getCollectionName()) || req.getIds() == null || req.getIds().isEmpty()) {
+            throw new IllegalArgumentException("collectionName 和 ids 不能为空");
+        }
+        Map<String, Object> body = new LinkedHashMap<>();
+        body.put("points", req.getIds());
+        body.put("wait", true);
+        try {
+            exchange(pointsDeleteUrl(req.getCollectionName()), Method.POST, body);
+        } catch (Exception e) {
+            throw new IllegalStateException("Qdrant delete 失败: " + req.getCollectionName(), e);
+        }
+    }
+
+    @Override
+    public Map<String, Object> getPoint(QdrantPointGetReq req) {
+        if (req == null || StringUtils.isBlank(req.getCollectionName()) || req.getId() == null) {
+            throw new IllegalArgumentException("collectionName 和 id 不能为空");
+        }
+        try {
+            Map<String, Object> resp = exchange(pointUrl(req.getCollectionName(), req.getId()), Method.GET, null);
+            Object result = resp.get("result");
+            if (!(result instanceof Map)) {
+                return new LinkedHashMap<>();
+            }
+            return toPointMap((Map<String, Object>) result);
+        } catch (IllegalStateException e) {
+            if (is404(e)) {
+                return new LinkedHashMap<>();
+            }
+            throw e;
+        } catch (Exception e) {
+            throw new IllegalStateException("Qdrant get point 失败: " + req.getCollectionName(), e);
+        }
+    }
+
+    @Override
+    public List<Map<String, Object>> search(QdrantPointSearchReq req) {
+        if (req == null || StringUtils.isBlank(req.getCollectionName()) || req.getVector() == null || req.getVector().isEmpty()) {
+            throw new IllegalArgumentException("collectionName 和 vector 不能为空");
+        }
+        Map<String, Object> body = new LinkedHashMap<>();
+        body.put("vector", req.getVector());
+        body.put("limit", req.getTopK() == null ? 5 : req.getTopK());
+        body.put("with_payload", true);
+        body.put("with_vector", true);
+        if (req.getFilter() != null && !req.getFilter().isEmpty()) {
+            body.put("filter", buildFilter(req.getFilter()));
+        }
+        try {
+            Map<String, Object> resp = exchange(searchUrl(req.getCollectionName()), Method.POST, body);
+            Object result = resp.get("result");
+            if (!(result instanceof List)) {
+                return new ArrayList<>();
+            }
+            List<Map<String, Object>> list = new ArrayList<>();
+            for (Object item : (List<?>) result) {
+                if (!(item instanceof Map)) {
+                    continue;
+                }
+                Map<String, Object> source = (Map<String, Object>) item;
+                Map<String, Object> map = new LinkedHashMap<>();
+                map.put("id", source.get("id"));
+                map.put("score", source.get("score"));
+                Object payload = source.get("payload");
+                map.put("payload", payload instanceof Map ? payload : new LinkedHashMap<>());
+                list.add(map);
+            }
+            return list;
+        } catch (Exception e) {
+            throw new IllegalStateException("Qdrant search 失败: " + req.getCollectionName(), e);
+        }
+    }
+
+    private void createCollectionIfAbsent(String collectionName, int vectorSize) {
+        QdrantCollectionReq req = new QdrantCollectionReq();
+        req.setCollectionName(collectionName);
+        req.setVectorSize(vectorSize);
+        createCollection(req);
+    }
+
+    private boolean collectionExists(String collectionName) {
+        try {
+            Map<String, Object> resp = exchange(collectionUrl(collectionName), Method.GET, null);
+            return resp.get("result") != null || resp.get("status") != null;
+        } catch (IllegalStateException e) {
+            if (is404(e)) {
+                return false;
+            }
+            throw e;
+        }
+    }
+
+    private boolean is404(IllegalStateException e) {
+        return e.getMessage() != null && e.getMessage().contains("HTTP 404");
+    }
+
+    private String requireCollectionName(String collectionName) {
+        if (StringUtils.isBlank(collectionName)) {
+            throw new IllegalArgumentException("collectionName 不能为空");
+        }
+        return collectionName.trim();
+    }
+
+    private void validateUpsertReq(QdrantPointUpsertReq req) {
+        if (req == null || StringUtils.isBlank(req.getCollectionName())) {
+            throw new IllegalArgumentException("collectionName 不能为空");
+        }
+        if (req.getIds() == null || req.getIds().isEmpty()) {
+            throw new IllegalArgumentException("ids 不能为空");
+        }
+        if (req.getVectors() == null || req.getVectors().isEmpty()) {
+            throw new IllegalArgumentException("vectors 不能为空");
+        }
+        if (req.getIds().size() != req.getVectors().size()) {
+            throw new IllegalArgumentException("ids 和 vectors 数量不一致");
+        }
+        if (req.getDocuments() != null && !req.getDocuments().isEmpty() && req.getDocuments().size() != req.getIds().size()) {
+            throw new IllegalArgumentException("documents 和 ids 数量不一致");
+        }
+        if (req.getPayloads() != null && !req.getPayloads().isEmpty() && req.getPayloads().size() != req.getIds().size()) {
+            throw new IllegalArgumentException("payloads 和 ids 数量不一致");
+        }
+    }
+
+    private int resolveVectorSize(List<List<Float>> vectors) {
+        if (vectors == null || vectors.isEmpty() || vectors.get(0) == null) {
+            return qdrantProperties.getVectorSize();
+        }
+        return vectors.get(0).size();
+    }
+
+    private Map<String, Object> buildPayload(QdrantPointUpsertReq req, int index) {
+        Map<String, Object> payload = new LinkedHashMap<>();
+        if (req.getDocuments() != null && req.getDocuments().size() > index) {
+            payload.put("document", req.getDocuments().get(index));
+        }
+        if (req.getPayloads() != null && req.getPayloads().size() > index && req.getPayloads().get(index) != null) {
+            payload.putAll(req.getPayloads().get(index));
+        }
+        return payload;
+    }
+
+    private Map<String, Object> buildFilter(Map<String, Object> filterMap) {
+        List<Map<String, Object>> must = new ArrayList<>();
+        for (Map.Entry<String, Object> entry : filterMap.entrySet()) {
+            if (entry.getValue() == null) {
+                continue;
+            }
+            Map<String, Object> match = new LinkedHashMap<>();
+            match.put("value", entry.getValue());
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("key", entry.getKey());
+            item.put("match", match);
+            must.add(item);
+        }
+        Map<String, Object> filter = new LinkedHashMap<>();
+        filter.put("must", must);
+        return filter;
+    }
+
+    private Map<String, Object> toPointMap(Map<String, Object> result) {
+        Map<String, Object> map = new LinkedHashMap<>();
+        map.put("id", result.get("id"));
+        Object payload = result.get("payload");
+        map.put("payload", payload instanceof Map ? payload : new LinkedHashMap<>());
+        map.put("vectors", result.get("vector"));
+        return map;
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<String, Object> exchange(String url, Method method, Object body) {
+        String reqBody = body == null ? null : JSONUtil.toJsonStr(body);
+        HttpRequest request = new HttpRequest(url).method(method).header("Content-Type", "application/json");
+        if (StringUtils.isNotBlank(qdrantProperties.getApiKey())) {
+            request.header("api-key", qdrantProperties.getApiKey());
+        }
+        if (reqBody != null) {
+            request.body(reqBody);
+        }
+        HttpResponse response = request.execute();
+        String respBody = response.body();
+        log.info("Qdrant HTTP 调用完成, method={}, url={}, status={}, req={}, resp={}",
+                method, url, response.getStatus(), truncate(reqBody), truncate(respBody));
+        if (response.getStatus() < 200 || response.getStatus() >= 300) {
+            throw new IllegalStateException("Qdrant HTTP " + response.getStatus() + " 调用失败: " + url + ", body=" + respBody);
+        }
+        if (StringUtils.isBlank(respBody)) {
+            return new LinkedHashMap<>();
+        }
+        Object parsed = JSONUtil.toBean(respBody, Map.class);
+        return parsed instanceof Map ? (Map<String, Object>) parsed : new LinkedHashMap<>();
+    }
+
+    private String truncate(String text) {
+        if (text == null) {
+            return null;
+        }
+        return text.length() > 2000 ? text.substring(0, 2000) + "..." : text;
+    }
+
+    private String baseUrl() {
+        String baseUrl = StringUtils.trimToEmpty(qdrantProperties.getBaseUrl());
+        while (baseUrl.endsWith("/")) {
+            baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
+        }
+        return baseUrl;
+    }
+
+    private String collectionUrl(String collectionName) {
+        return baseUrl() + "/collections/" + collectionName;
+    }
+
+    private String pointsUrl(String collectionName) {
+        return collectionUrl(collectionName) + "/points";
+    }
+
+    private String pointsDeleteUrl(String collectionName) {
+        return pointsUrl(collectionName) + "/delete";
+    }
+
+    private String pointUrl(String collectionName, Long id) {
+        return pointsUrl(collectionName) + "/" + id + "?with_payload=true&with_vector=true";
+    }
+
+    private String searchUrl(String collectionName) {
+        return pointsUrl(collectionName) + "/search";
+    }
+}

+ 18 - 0
fs-ai-api/src/main/java/com/fs/framework/config/properties/QdrantProperties.java

@@ -0,0 +1,18 @@
+package com.fs.framework.config.properties;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "ai.qdrant")
+public class QdrantProperties {
+    private String baseUrl = "https://saaschroma.ylrzcloud.com";
+    private String apiKey = "";
+    private String host = "saaschroma.ylrzcloud.com";
+    private Integer port = 443;
+    private Boolean useTls = true;
+    private Boolean preferRest = true;
+    private Integer vectorSize = 1024;
+}

+ 33 - 0
fs-qwhook/src/main/java/com/fs/app/interceptor/TenantDataSourceInterceptor.java

@@ -0,0 +1,33 @@
+package com.fs.app.interceptor;
+
+import com.fs.common.utils.StringUtils;
+import com.fs.framework.datasource.TenantDataSourceManager;
+import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+import javax.annotation.Resource;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+@Component
+public class TenantDataSourceInterceptor implements HandlerInterceptor {
+
+    private static final String HEADER_QW_CORP_ID = "X-Qw-CorpId";
+
+    @Resource
+    private TenantDataSourceManager tenantDataSourceManager;
+
+    @Override
+    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
+        String corpId = request.getHeader(HEADER_QW_CORP_ID);
+        if (StringUtils.isNotBlank(corpId)) {
+            tenantDataSourceManager.ensureSwitchByCorpId(corpId);
+        }
+        return true;
+    }
+
+    @Override
+    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
+        tenantDataSourceManager.clear();
+    }
+}

+ 9 - 0
fs-service/src/main/java/com/fs/company/dto/QdrantCollectionReq.java

@@ -0,0 +1,9 @@
+package com.fs.company.dto;
+
+import lombok.Data;
+
+@Data
+public class QdrantCollectionReq {
+    private String collectionName;
+    private Integer vectorSize;
+}