sdpy-kit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kit/__init__.py +68 -0
- kit/ai/__init__.py +47 -0
- kit/ai/base_service.py +94 -0
- kit/ai/chat_service.py +110 -0
- kit/ai/embedding_service.py +133 -0
- kit/ai/exceptions.py +57 -0
- kit/ai/failover.py +244 -0
- kit/ai/image_service.py +99 -0
- kit/ai/model_builder.py +119 -0
- kit/ai/model_client.py +214 -0
- kit/ai/pool.py +411 -0
- kit/ai/profiles.py +373 -0
- kit/ai/rerank_service.py +93 -0
- kit/ai/runner.py +580 -0
- kit/ai/types.py +28 -0
- kit/common_kit.py +221 -0
- kit/config/__init__.py +44 -0
- kit/config/kit_config.py +147 -0
- kit/config/log_config.py +134 -0
- kit/db/__init__.py +70 -0
- kit/db/base_db_kit.py +406 -0
- kit/db/crud.py +361 -0
- kit/db/engine.py +230 -0
- kit/db/exceptions.py +34 -0
- kit/db/models/__init__.py +33 -0
- kit/db/models/base.py +56 -0
- kit/db/mysql_kit.py +89 -0
- kit/db/pg_kit.py +87 -0
- kit/db/session.py +85 -0
- kit/db/sqlite_kit.py +284 -0
- kit/db/transaction.py +109 -0
- kit/doc_kit.py +1502 -0
- kit/file_kit.py +429 -0
- kit/mcp_kit.py +547 -0
- kit/mineru_kit.py +918 -0
- kit/redis_kit.py +392 -0
- kit/zvec_kit.py +671 -0
- sdpy_kit-0.1.0.dist-info/METADATA +128 -0
- sdpy_kit-0.1.0.dist-info/RECORD +40 -0
- sdpy_kit-0.1.0.dist-info/WHEEL +4 -0
kit/zvec_kit.py
ADDED
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) 2021-2026 Clark Chang. All Rights Reserved.
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
|
|
16
|
+
Project: sdpy
|
|
17
|
+
Author: Clark Chang
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
# date: 2026-08-05
|
|
21
|
+
"""zvec 向量数据库工具类
|
|
22
|
+
|
|
23
|
+
基于 zvec(本地嵌入式向量库)封装,提供开箱即用的向量数据服务方法。
|
|
24
|
+
采用独立工具范式(类似 RedisKit),不依赖 kit.db 的 SQLAlchemy 体系。
|
|
25
|
+
|
|
26
|
+
zvec 核心概念:
|
|
27
|
+
- Collection:类比数据库表,数据落盘到指定目录,schema-first(需先定义结构)
|
|
28
|
+
- Doc:类比一行记录,包含标量字段(fields) + 向量字段(vectors) + 唯一id
|
|
29
|
+
|
|
30
|
+
两类使用模式:
|
|
31
|
+
|
|
32
|
+
1. 单条操作(自动管理 Collection 句柄)::
|
|
33
|
+
|
|
34
|
+
# 业务方先定义 schema 并创建 collection
|
|
35
|
+
schema = ZvecKit.make_text_embedding_schema("docs", dim=768)
|
|
36
|
+
ZvecKit.create_collection("docs", schema)
|
|
37
|
+
|
|
38
|
+
# CRUD
|
|
39
|
+
ZvecKit.insert(
|
|
40
|
+
"docs",
|
|
41
|
+
{"id": "d1", "vectors": {"embedding": [0.1] * 768}, "fields": {"year": 2024}},
|
|
42
|
+
)
|
|
43
|
+
doc = ZvecKit.fetch("docs", "d1")
|
|
44
|
+
ZvecKit.update("docs", {"id": "d1", "fields": {"year": 2025}})
|
|
45
|
+
ZvecKit.delete("docs", "d1")
|
|
46
|
+
|
|
47
|
+
2. 向量检索(阶段2补充)::
|
|
48
|
+
|
|
49
|
+
results = ZvecKit.vector_query("docs", field="embedding", vector=[0.3]*768, topk=10)
|
|
50
|
+
|
|
51
|
+
注意:
|
|
52
|
+
- zvec 的 zvec.init() 在进程内应只调用一次,本类已做幂等守卫。
|
|
53
|
+
- 删除/更新后检索召回率受 index_completeness 影响,大批量写入后建议调用 optimize()。
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
import json
|
|
57
|
+
import os
|
|
58
|
+
import threading
|
|
59
|
+
from typing import Any
|
|
60
|
+
|
|
61
|
+
import zvec
|
|
62
|
+
from zvec import (
|
|
63
|
+
Collection,
|
|
64
|
+
CollectionSchema,
|
|
65
|
+
DataType,
|
|
66
|
+
Doc,
|
|
67
|
+
FieldSchema,
|
|
68
|
+
HnswIndexParam,
|
|
69
|
+
InvertIndexParam,
|
|
70
|
+
MetricType,
|
|
71
|
+
Query,
|
|
72
|
+
VectorSchema,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
from kit.config.kit_config import get_settings
|
|
76
|
+
from kit.config.log_config import get_default_logger
|
|
77
|
+
|
|
78
|
+
logger = get_default_logger(__name__)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ZvecError(Exception):
|
|
82
|
+
"""zvec 向量库操作异常基类
|
|
83
|
+
|
|
84
|
+
独立于 kit.db.exceptions.DBError(zvec 不走 SQLAlchemy 体系)。
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ==================== 全局单例守卫 ====================
|
|
89
|
+
|
|
90
|
+
_zvec_initialized: bool = False
|
|
91
|
+
_init_lock = threading.Lock()
|
|
92
|
+
|
|
93
|
+
# Collection 实例注册表:path -> Collection(线程安全单例,避免重复 open 同一目录)
|
|
94
|
+
_collections: dict[str, Collection] = {}
|
|
95
|
+
_collection_lock = threading.Lock()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _ensure_zvec_initialized() -> None:
|
|
99
|
+
"""幂等初始化 zvec 引擎(线程安全单例)
|
|
100
|
+
|
|
101
|
+
zvec.init() 文档要求只调用一次,本函数用模块级标志位 + 锁保证幂等。
|
|
102
|
+
即使 zvec 内部允许重复调用,此守卫也能避免无谓的重复工作。
|
|
103
|
+
"""
|
|
104
|
+
global _zvec_initialized
|
|
105
|
+
if _zvec_initialized:
|
|
106
|
+
return
|
|
107
|
+
with _init_lock:
|
|
108
|
+
if _zvec_initialized:
|
|
109
|
+
return
|
|
110
|
+
settings = get_settings()
|
|
111
|
+
# 确保 zvec 引擎日志目录存在(zvec 不会自动创建父目录)
|
|
112
|
+
log_dir = os.path.abspath(settings.ZVEC_LOG_DIR)
|
|
113
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
114
|
+
|
|
115
|
+
# 映射日志级别字符串到 LogLevel 枚举
|
|
116
|
+
level_map = {
|
|
117
|
+
"debug": zvec.LogLevel.DEBUG,
|
|
118
|
+
"info": zvec.LogLevel.INFO,
|
|
119
|
+
"warn": zvec.LogLevel.WARN,
|
|
120
|
+
"error": zvec.LogLevel.ERROR,
|
|
121
|
+
"fatal": zvec.LogLevel.FATAL,
|
|
122
|
+
}
|
|
123
|
+
log_level = level_map.get(settings.ZVEC_LOG_LEVEL.lower(), zvec.LogLevel.WARN)
|
|
124
|
+
|
|
125
|
+
init_kwargs: dict[str, Any] = {
|
|
126
|
+
"log_type": zvec.LogType.FILE,
|
|
127
|
+
"log_dir": log_dir,
|
|
128
|
+
"log_level": log_level,
|
|
129
|
+
}
|
|
130
|
+
# 仅当配置非 None 时传入,让 zvec 使用内置默认值
|
|
131
|
+
if settings.ZVEC_QUERY_THREADS is not None:
|
|
132
|
+
init_kwargs["query_threads"] = settings.ZVEC_QUERY_THREADS
|
|
133
|
+
if settings.ZVEC_OPTIMIZE_THREADS is not None:
|
|
134
|
+
init_kwargs["optimize_threads"] = settings.ZVEC_OPTIMIZE_THREADS
|
|
135
|
+
if settings.ZVEC_MEMORY_LIMIT_MB is not None:
|
|
136
|
+
init_kwargs["memory_limit_mb"] = settings.ZVEC_MEMORY_LIMIT_MB
|
|
137
|
+
|
|
138
|
+
zvec.init(**init_kwargs)
|
|
139
|
+
_zvec_initialized = True
|
|
140
|
+
logger.info("✅ zvec 引擎已初始化")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _resolve_path(name_or_path: str) -> str:
|
|
144
|
+
"""规范化 collection 路径
|
|
145
|
+
|
|
146
|
+
支持两种入参:
|
|
147
|
+
- 相对/绝对的 collection 名称(如 "docs")→ 拼接到 ZVEC_DATA_DIR 下
|
|
148
|
+
- 已是绝对路径 → 原样使用
|
|
149
|
+
"""
|
|
150
|
+
if os.path.isabs(name_or_path):
|
|
151
|
+
return os.path.abspath(name_or_path)
|
|
152
|
+
settings = get_settings()
|
|
153
|
+
return os.path.abspath(os.path.join(settings.ZVEC_DATA_DIR, name_or_path))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _to_doc(data: dict[str, Any]) -> Doc:
|
|
157
|
+
"""将业务字典转换为 zvec.Doc
|
|
158
|
+
|
|
159
|
+
约定字典键:id(必需)、vectors(可选)、fields(可选)。
|
|
160
|
+
"""
|
|
161
|
+
if "id" not in data:
|
|
162
|
+
raise ZvecError("文档字典必须包含 'id' 字段")
|
|
163
|
+
return Doc(
|
|
164
|
+
id=str(data["id"]),
|
|
165
|
+
vectors=data.get("vectors"),
|
|
166
|
+
fields=data.get("fields"),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _doc_to_dict(doc: Doc, include_vector: bool = True) -> dict[str, Any]:
|
|
171
|
+
"""将 zvec.Doc 转换为业务字典(与 SQL Kit 返回字典列表风格一致)
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
doc: zvec 文档对象
|
|
175
|
+
include_vector: 是否包含向量数据
|
|
176
|
+
"""
|
|
177
|
+
result: dict[str, Any] = {"id": doc.id}
|
|
178
|
+
if doc.score is not None:
|
|
179
|
+
result["score"] = doc.score
|
|
180
|
+
# 展开标量字段到顶层,便于业务直接取值
|
|
181
|
+
for name in doc.field_names():
|
|
182
|
+
result[name] = doc.field(name)
|
|
183
|
+
if include_vector:
|
|
184
|
+
for vname in doc.vector_names():
|
|
185
|
+
result[vname] = doc.vector(vname)
|
|
186
|
+
return result
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class ZvecKit:
|
|
190
|
+
"""zvec 向量数据库工具类
|
|
191
|
+
|
|
192
|
+
采用独立工具范式(类似 RedisKit),自管理 Collection 实例池。
|
|
193
|
+
所有方法均为类方法,业务代码无需实例化。
|
|
194
|
+
|
|
195
|
+
与 SQL Kit(PgKit/MySqlKit/SQLiteKit)的一致性:
|
|
196
|
+
- 保留:connect / close / is_connected / count 等同名同语义方法
|
|
197
|
+
- 替换:无 SQL 相关方法(query/execute/transaction),
|
|
198
|
+
改为向量等价方法(vector_query 在阶段2实现)
|
|
199
|
+
- 变体:CRUD 入参无 ORM model,改为 collection 名称 + 业务字典
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
# ==================== 连接管理 ====================
|
|
203
|
+
|
|
204
|
+
@classmethod
|
|
205
|
+
def connect(cls) -> bool:
|
|
206
|
+
"""初始化 zvec 引擎并验证数据目录可写
|
|
207
|
+
|
|
208
|
+
幂等:多次调用安全。zvec.init() 在进程内只执行一次。
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
True 表示初始化成功
|
|
212
|
+
"""
|
|
213
|
+
try:
|
|
214
|
+
_ensure_zvec_initialized()
|
|
215
|
+
# 验证数据目录可创建可写
|
|
216
|
+
settings = get_settings()
|
|
217
|
+
data_dir = os.path.abspath(settings.ZVEC_DATA_DIR)
|
|
218
|
+
os.makedirs(data_dir, exist_ok=True)
|
|
219
|
+
logger.info("✅ zvec 向量库连接成功")
|
|
220
|
+
return True
|
|
221
|
+
except Exception as e:
|
|
222
|
+
logger.error(f"❌ zvec 向量库连接失败: {e}")
|
|
223
|
+
return False
|
|
224
|
+
|
|
225
|
+
@classmethod
|
|
226
|
+
def close(cls) -> None:
|
|
227
|
+
"""关闭所有已打开的 Collection 句柄
|
|
228
|
+
|
|
229
|
+
注意:zvec 的 zvec.init() 是进程级单例,无法重置;
|
|
230
|
+
本方法仅清理 Collection 注册表,让后续 open 重建句柄。
|
|
231
|
+
"""
|
|
232
|
+
with _collection_lock:
|
|
233
|
+
for path, collection in _collections.items():
|
|
234
|
+
try:
|
|
235
|
+
collection.flush()
|
|
236
|
+
except Exception as e:
|
|
237
|
+
logger.warning(f"关闭 collection {path} 时 flush 失败: {e}")
|
|
238
|
+
_collections.clear()
|
|
239
|
+
logger.info("🔌 zvec 所有 collection 句柄已关闭")
|
|
240
|
+
|
|
241
|
+
@classmethod
|
|
242
|
+
def is_connected(cls) -> bool:
|
|
243
|
+
"""检查 zvec 引擎是否已初始化"""
|
|
244
|
+
return _zvec_initialized
|
|
245
|
+
|
|
246
|
+
# ==================== Collection 管理 ====================
|
|
247
|
+
|
|
248
|
+
@classmethod
|
|
249
|
+
def create_collection(
|
|
250
|
+
cls,
|
|
251
|
+
name_or_path: str,
|
|
252
|
+
schema: CollectionSchema,
|
|
253
|
+
) -> Collection:
|
|
254
|
+
"""创建新的 Collection 并打开
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
name_or_path: collection 名称(拼接到 ZVEC_DATA_DIR)或绝对路径
|
|
258
|
+
schema: collection 结构定义
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
打开的 Collection 实例
|
|
262
|
+
"""
|
|
263
|
+
cls.connect()
|
|
264
|
+
path = _resolve_path(name_or_path)
|
|
265
|
+
if os.path.exists(path):
|
|
266
|
+
raise ZvecError(f"collection 目录已存在,请先 destroy 或换名: {path}")
|
|
267
|
+
try:
|
|
268
|
+
collection = zvec.create_and_open(path=path, schema=schema)
|
|
269
|
+
with _collection_lock:
|
|
270
|
+
_collections[path] = collection
|
|
271
|
+
logger.info(f"✅ 创建 collection: {path}")
|
|
272
|
+
return collection
|
|
273
|
+
except Exception as e:
|
|
274
|
+
logger.error(f"❌ 创建 collection 失败 {path}: {e}")
|
|
275
|
+
raise ZvecError(f"创建 collection 失败: {e}") from e
|
|
276
|
+
|
|
277
|
+
@classmethod
|
|
278
|
+
def open_collection(cls, name_or_path: str) -> Collection:
|
|
279
|
+
"""打开已有 Collection(线程安全单例,重复打开返回同一实例)
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
name_or_path: collection 名称或绝对路径
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
打开的 Collection 实例
|
|
286
|
+
"""
|
|
287
|
+
cls.connect()
|
|
288
|
+
path = _resolve_path(name_or_path)
|
|
289
|
+
# 双重检查:已打开则直接返回,避免重复 open 导致资源占用
|
|
290
|
+
if path in _collections:
|
|
291
|
+
return _collections[path]
|
|
292
|
+
with _collection_lock:
|
|
293
|
+
if path in _collections:
|
|
294
|
+
return _collections[path]
|
|
295
|
+
if not os.path.exists(path):
|
|
296
|
+
raise ZvecError(f"collection 目录不存在: {path}")
|
|
297
|
+
try:
|
|
298
|
+
collection = zvec.open(path)
|
|
299
|
+
_collections[path] = collection
|
|
300
|
+
logger.info(f"✅ 打开 collection: {path}")
|
|
301
|
+
return collection
|
|
302
|
+
except Exception as e:
|
|
303
|
+
logger.error(f"❌ 打开 collection 失败 {path}: {e}")
|
|
304
|
+
raise ZvecError(f"打开 collection 失败: {e}") from e
|
|
305
|
+
|
|
306
|
+
@classmethod
|
|
307
|
+
def collection_exists(cls, name_or_path: str) -> bool:
|
|
308
|
+
"""检查 Collection 是否存在(基于目录是否存在)"""
|
|
309
|
+
return os.path.exists(_resolve_path(name_or_path))
|
|
310
|
+
|
|
311
|
+
@classmethod
|
|
312
|
+
def list_collections(cls) -> list[str]:
|
|
313
|
+
"""列出数据目录下的所有 collection 名称"""
|
|
314
|
+
settings = get_settings()
|
|
315
|
+
data_dir = os.path.abspath(settings.ZVEC_DATA_DIR)
|
|
316
|
+
if not os.path.exists(data_dir):
|
|
317
|
+
return []
|
|
318
|
+
return [
|
|
319
|
+
name
|
|
320
|
+
for name in os.listdir(data_dir)
|
|
321
|
+
if os.path.isdir(os.path.join(data_dir, name))
|
|
322
|
+
]
|
|
323
|
+
|
|
324
|
+
@classmethod
|
|
325
|
+
def destroy_collection(cls, name_or_path: str) -> bool:
|
|
326
|
+
"""永久删除 Collection(不可恢复)
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
name_or_path: collection 名称或绝对路径
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
True 表示删除成功,False 表示不存在
|
|
333
|
+
"""
|
|
334
|
+
path = _resolve_path(name_or_path)
|
|
335
|
+
# 先从注册表移除
|
|
336
|
+
with _collection_lock:
|
|
337
|
+
collection = _collections.pop(path, None)
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
if collection is not None:
|
|
341
|
+
collection.destroy()
|
|
342
|
+
elif os.path.exists(path):
|
|
343
|
+
# 未在注册表中的,尝试 open 后 destroy
|
|
344
|
+
col = zvec.open(path)
|
|
345
|
+
col.destroy()
|
|
346
|
+
else:
|
|
347
|
+
return False
|
|
348
|
+
logger.info(f"✅ 已销毁 collection: {path}")
|
|
349
|
+
return True
|
|
350
|
+
except Exception as e:
|
|
351
|
+
logger.error(f"❌ 销毁 collection 失败 {path}: {e}")
|
|
352
|
+
raise ZvecError(f"销毁 collection 失败: {e}") from e
|
|
353
|
+
|
|
354
|
+
@classmethod
|
|
355
|
+
def get_schema(cls, name_or_path: str) -> CollectionSchema:
|
|
356
|
+
"""获取 Collection 的 schema 定义"""
|
|
357
|
+
return cls.open_collection(name_or_path).schema
|
|
358
|
+
|
|
359
|
+
@classmethod
|
|
360
|
+
def get_stats(cls, name_or_path: str) -> dict[str, Any]:
|
|
361
|
+
"""获取 Collection 的统计信息(解析 JSON 字符串为字典)
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
统计字典,常见键:doc_count、index_completeness
|
|
365
|
+
|
|
366
|
+
Note:
|
|
367
|
+
zvec 返回的 stats 可能是原生 str、也可能是 C++ 绑定的字符串包装类
|
|
368
|
+
(isinstance(stats, str) 为 False 但 str(stats) 得到 JSON),
|
|
369
|
+
因此统一先 str() 再解析,保证健壮性。
|
|
370
|
+
"""
|
|
371
|
+
collection = cls._get_collection(name_or_path)
|
|
372
|
+
raw = collection.stats
|
|
373
|
+
# 统一转字符串后解析 JSON(兼容 str 与字符串包装类两种情况)
|
|
374
|
+
text = raw if isinstance(raw, str) else str(raw)
|
|
375
|
+
try:
|
|
376
|
+
parsed = json.loads(text)
|
|
377
|
+
except (json.JSONDecodeError, TypeError):
|
|
378
|
+
return {"raw": text}
|
|
379
|
+
if not isinstance(parsed, dict):
|
|
380
|
+
return {"raw": text}
|
|
381
|
+
return parsed
|
|
382
|
+
|
|
383
|
+
@classmethod
|
|
384
|
+
def _get_collection(cls, name_or_path: str) -> Collection:
|
|
385
|
+
"""内部方法:获取已注册的 Collection(不存在则 open)"""
|
|
386
|
+
path = _resolve_path(name_or_path)
|
|
387
|
+
if path in _collections:
|
|
388
|
+
return _collections[path]
|
|
389
|
+
return cls.open_collection(name_or_path)
|
|
390
|
+
|
|
391
|
+
# ==================== 文档 CRUD ====================
|
|
392
|
+
|
|
393
|
+
@classmethod
|
|
394
|
+
def insert(cls, name_or_path: str, data: dict[str, Any]) -> str:
|
|
395
|
+
"""插入单条文档
|
|
396
|
+
|
|
397
|
+
Args:
|
|
398
|
+
name_or_path: collection 名称或路径
|
|
399
|
+
data: 文档字典,须含 'id',可含 'vectors'/'fields'
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
插入文档的 id
|
|
403
|
+
"""
|
|
404
|
+
collection = cls._get_collection(name_or_path)
|
|
405
|
+
try:
|
|
406
|
+
doc = _to_doc(data)
|
|
407
|
+
collection.insert(doc)
|
|
408
|
+
logger.info(f"✅ 插入文档: {doc.id}")
|
|
409
|
+
return doc.id
|
|
410
|
+
except Exception as e:
|
|
411
|
+
logger.error(f"❌ 插入文档失败: {e}")
|
|
412
|
+
raise ZvecError(f"插入文档失败: {e}") from e
|
|
413
|
+
|
|
414
|
+
@classmethod
|
|
415
|
+
def insert_multi(
|
|
416
|
+
cls, name_or_path: str, data_list: list[dict[str, Any]]
|
|
417
|
+
) -> list[str]:
|
|
418
|
+
"""批量插入文档
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
name_or_path: collection 名称或路径
|
|
422
|
+
data_list: 文档字典列表
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
插入成功的文档 id 列表
|
|
426
|
+
"""
|
|
427
|
+
collection = cls._get_collection(name_or_path)
|
|
428
|
+
try:
|
|
429
|
+
docs = [_to_doc(d) for d in data_list]
|
|
430
|
+
collection.insert(docs)
|
|
431
|
+
ids = [d.id for d in docs]
|
|
432
|
+
logger.info(f"✅ 批量插入 {len(ids)} 条文档")
|
|
433
|
+
return ids
|
|
434
|
+
except Exception as e:
|
|
435
|
+
logger.error(f"❌ 批量插入文档失败: {e}")
|
|
436
|
+
raise ZvecError(f"批量插入文档失败: {e}") from e
|
|
437
|
+
|
|
438
|
+
@classmethod
|
|
439
|
+
def upsert(cls, name_or_path: str, data: dict[str, Any]) -> str:
|
|
440
|
+
"""插入或更新单条文档(按 id 判断存在性)"""
|
|
441
|
+
collection = cls._get_collection(name_or_path)
|
|
442
|
+
try:
|
|
443
|
+
doc = _to_doc(data)
|
|
444
|
+
collection.upsert(doc)
|
|
445
|
+
logger.info(f"✅ upsert 文档: {doc.id}")
|
|
446
|
+
return doc.id
|
|
447
|
+
except Exception as e:
|
|
448
|
+
logger.error(f"❌ upsert 文档失败: {e}")
|
|
449
|
+
raise ZvecError(f"upsert 文档失败: {e}") from e
|
|
450
|
+
|
|
451
|
+
@classmethod
|
|
452
|
+
def update(cls, name_or_path: str, data: dict[str, Any]) -> str:
|
|
453
|
+
"""更新单条文档(仅更新指定字段,其余不变)
|
|
454
|
+
|
|
455
|
+
Args:
|
|
456
|
+
data: 须含 'id','vectors'/'fields' 为可选的待更新字段
|
|
457
|
+
"""
|
|
458
|
+
collection = cls._get_collection(name_or_path)
|
|
459
|
+
try:
|
|
460
|
+
doc = _to_doc(data)
|
|
461
|
+
collection.update(doc)
|
|
462
|
+
logger.info(f"✅ 更新文档: {doc.id}")
|
|
463
|
+
return doc.id
|
|
464
|
+
except Exception as e:
|
|
465
|
+
logger.error(f"❌ 更新文档失败: {e}")
|
|
466
|
+
raise ZvecError(f"更新文档失败: {e}") from e
|
|
467
|
+
|
|
468
|
+
@classmethod
|
|
469
|
+
def fetch(
|
|
470
|
+
cls,
|
|
471
|
+
name_or_path: str,
|
|
472
|
+
ids: str | list[str],
|
|
473
|
+
*,
|
|
474
|
+
include_vector: bool = True,
|
|
475
|
+
) -> dict[str, dict[str, Any]]:
|
|
476
|
+
"""按 id 获取文档
|
|
477
|
+
|
|
478
|
+
Args:
|
|
479
|
+
name_or_path: collection 名称或路径
|
|
480
|
+
ids: 单个 id 或 id 列表
|
|
481
|
+
include_vector: 是否包含向量数据
|
|
482
|
+
|
|
483
|
+
Returns:
|
|
484
|
+
id -> 文档字典 的映射(不存在的 id 会被省略)
|
|
485
|
+
"""
|
|
486
|
+
collection = cls._get_collection(name_or_path)
|
|
487
|
+
try:
|
|
488
|
+
result = collection.fetch(ids, include_vector=include_vector)
|
|
489
|
+
return {
|
|
490
|
+
doc_id: _doc_to_dict(doc, include_vector)
|
|
491
|
+
for doc_id, doc in result.items()
|
|
492
|
+
}
|
|
493
|
+
except Exception as e:
|
|
494
|
+
logger.error(f"❌ 获取文档失败: {e}")
|
|
495
|
+
raise ZvecError(f"获取文档失败: {e}") from e
|
|
496
|
+
|
|
497
|
+
@classmethod
|
|
498
|
+
def delete(cls, name_or_path: str, ids: str | list[str]) -> bool:
|
|
499
|
+
"""按 id 删除文档
|
|
500
|
+
|
|
501
|
+
Args:
|
|
502
|
+
ids: 单个 id 或 id 列表
|
|
503
|
+
|
|
504
|
+
Returns:
|
|
505
|
+
True 表示删除操作执行成功
|
|
506
|
+
"""
|
|
507
|
+
collection = cls._get_collection(name_or_path)
|
|
508
|
+
try:
|
|
509
|
+
collection.delete(ids)
|
|
510
|
+
logger.info(f"✅ 删除文档: {ids}")
|
|
511
|
+
return True
|
|
512
|
+
except Exception as e:
|
|
513
|
+
logger.error(f"❌ 删除文档失败: {e}")
|
|
514
|
+
raise ZvecError(f"删除文档失败: {e}") from e
|
|
515
|
+
|
|
516
|
+
@classmethod
|
|
517
|
+
def delete_by_filter(cls, name_or_path: str, filter_expr: str) -> bool:
|
|
518
|
+
"""按过滤表达式删除文档
|
|
519
|
+
|
|
520
|
+
Args:
|
|
521
|
+
filter_expr: 布尔表达式。zvec 过滤语法:相等用单等号 ``=````
|
|
522
|
+
(非 ``==``),数值比较用 ``year < 1950``,支持 AND/OR/IN 等。
|
|
523
|
+
|
|
524
|
+
Returns:
|
|
525
|
+
True 表示删除操作执行成功
|
|
526
|
+
"""
|
|
527
|
+
collection = cls._get_collection(name_or_path)
|
|
528
|
+
try:
|
|
529
|
+
collection.delete_by_filter(filter=filter_expr)
|
|
530
|
+
logger.info(f"✅ 按条件删除文档: {filter_expr}")
|
|
531
|
+
return True
|
|
532
|
+
except Exception as e:
|
|
533
|
+
logger.error(f"❌ 按条件删除文档失败: {e}")
|
|
534
|
+
raise ZvecError(f"按条件删除文档失败: {e}") from e
|
|
535
|
+
|
|
536
|
+
@classmethod
|
|
537
|
+
def count(cls, name_or_path: str) -> int:
|
|
538
|
+
"""获取 collection 中的文档数量
|
|
539
|
+
|
|
540
|
+
基于统计信息中的 doc_count 字段。
|
|
541
|
+
"""
|
|
542
|
+
stats = cls.get_stats(name_or_path)
|
|
543
|
+
return int(stats.get("doc_count", 0))
|
|
544
|
+
|
|
545
|
+
@classmethod
|
|
546
|
+
def flush(cls, name_or_path: str) -> None:
|
|
547
|
+
"""将待写入数据强制刷盘(保证持久化)"""
|
|
548
|
+
collection = cls._get_collection(name_or_path)
|
|
549
|
+
try:
|
|
550
|
+
collection.flush()
|
|
551
|
+
logger.info(f"✅ flush 完成: {name_or_path}")
|
|
552
|
+
except Exception as e:
|
|
553
|
+
logger.error(f"❌ flush 失败: {e}")
|
|
554
|
+
raise ZvecError(f"flush 失败: {e}") from e
|
|
555
|
+
|
|
556
|
+
# ==================== 向量检索(阶段2完整实现) ====================
|
|
557
|
+
|
|
558
|
+
@classmethod
|
|
559
|
+
def vector_query(
|
|
560
|
+
cls,
|
|
561
|
+
name_or_path: str,
|
|
562
|
+
*,
|
|
563
|
+
field: str,
|
|
564
|
+
vector: list[float],
|
|
565
|
+
topk: int = 10,
|
|
566
|
+
filter_expr: str | None = None,
|
|
567
|
+
include_vector: bool = False,
|
|
568
|
+
) -> list[dict[str, Any]]:
|
|
569
|
+
"""向量相似度检索
|
|
570
|
+
|
|
571
|
+
Args:
|
|
572
|
+
name_or_path: collection 名称或路径
|
|
573
|
+
field: 向量字段名
|
|
574
|
+
vector: 查询向量
|
|
575
|
+
topk: 返回近邻数量
|
|
576
|
+
filter_expr: 预过滤表达式。注意 zvec 过滤语法:相等用单等号
|
|
577
|
+
``category = 'life'``(非 ``==``),数值比较用 ``year > 2020``,
|
|
578
|
+
支持 AND / OR / NOT / IN / BETWEEN / LIKE。
|
|
579
|
+
include_vector: 结果是否包含向量数据
|
|
580
|
+
|
|
581
|
+
Returns:
|
|
582
|
+
文档字典列表(含 id/score/fields),按相似度降序
|
|
583
|
+
"""
|
|
584
|
+
collection = cls._get_collection(name_or_path)
|
|
585
|
+
try:
|
|
586
|
+
results = collection.query(
|
|
587
|
+
Query(field_name=field, vector=vector),
|
|
588
|
+
topk=topk,
|
|
589
|
+
filter=filter_expr,
|
|
590
|
+
include_vector=include_vector,
|
|
591
|
+
)
|
|
592
|
+
return [_doc_to_dict(doc, include_vector) for doc in results]
|
|
593
|
+
except Exception as e:
|
|
594
|
+
logger.error(f"❌ 向量检索失败: {e}")
|
|
595
|
+
raise ZvecError(f"向量检索失败: {e}") from e
|
|
596
|
+
|
|
597
|
+
@classmethod
|
|
598
|
+
def optimize(cls, name_or_path: str) -> None:
|
|
599
|
+
"""优化 collection(合并段、重建向量索引)
|
|
600
|
+
|
|
601
|
+
大批量写入后建议调用,以加速后续检索并提升召回率。
|
|
602
|
+
属于重操作,不应在每条 insert 后调用。
|
|
603
|
+
"""
|
|
604
|
+
collection = cls._get_collection(name_or_path)
|
|
605
|
+
try:
|
|
606
|
+
collection.optimize()
|
|
607
|
+
logger.info(f"✅ optimize 完成: {name_or_path}")
|
|
608
|
+
except Exception as e:
|
|
609
|
+
logger.error(f"❌ optimize 失败: {e}")
|
|
610
|
+
raise ZvecError(f"optimize 失败: {e}") from e
|
|
611
|
+
|
|
612
|
+
# ==================== Schema 工厂(降低使用门槛) ====================
|
|
613
|
+
|
|
614
|
+
@staticmethod
|
|
615
|
+
def make_text_embedding_schema(
|
|
616
|
+
name: str,
|
|
617
|
+
dim: int,
|
|
618
|
+
metric: str | None = None,
|
|
619
|
+
extra_fields: list[FieldSchema] | None = None,
|
|
620
|
+
) -> CollectionSchema:
|
|
621
|
+
"""构建常用的「文本 embedding」schema 工厂
|
|
622
|
+
|
|
623
|
+
默认包含:
|
|
624
|
+
- 向量字段 'embedding'(FP32, HNSW 索引)
|
|
625
|
+
- 标量字段 'publish_year'(INT32, 倒排索引,可选)
|
|
626
|
+
|
|
627
|
+
Args:
|
|
628
|
+
name: collection 名称
|
|
629
|
+
dim: 向量维度(如 768/1536)
|
|
630
|
+
metric: 距离度量(cosine/l2/ip),默认取配置 ZVEC_DEFAULT_METRIC
|
|
631
|
+
extra_fields: 额外的标量字段列表
|
|
632
|
+
"""
|
|
633
|
+
settings = get_settings()
|
|
634
|
+
metric_str = (metric or settings.ZVEC_DEFAULT_METRIC).lower()
|
|
635
|
+
metric_map = {
|
|
636
|
+
"cosine": MetricType.COSINE,
|
|
637
|
+
"l2": MetricType.L2,
|
|
638
|
+
"ip": MetricType.IP,
|
|
639
|
+
}
|
|
640
|
+
metric_type = metric_map.get(metric_str, MetricType.COSINE)
|
|
641
|
+
|
|
642
|
+
vectors = [
|
|
643
|
+
VectorSchema(
|
|
644
|
+
name="embedding",
|
|
645
|
+
data_type=DataType.VECTOR_FP32,
|
|
646
|
+
dimension=dim,
|
|
647
|
+
index_param=HnswIndexParam(metric_type=metric_type),
|
|
648
|
+
)
|
|
649
|
+
]
|
|
650
|
+
fields: list[FieldSchema] = [
|
|
651
|
+
FieldSchema(
|
|
652
|
+
name="publish_year",
|
|
653
|
+
data_type=DataType.INT32,
|
|
654
|
+
index_param=InvertIndexParam(enable_range_optimization=True),
|
|
655
|
+
)
|
|
656
|
+
]
|
|
657
|
+
if extra_fields:
|
|
658
|
+
fields.extend(extra_fields)
|
|
659
|
+
|
|
660
|
+
return CollectionSchema(name=name, fields=fields, vectors=vectors)
|
|
661
|
+
|
|
662
|
+
# ==================== 测试辅助 ====================
|
|
663
|
+
|
|
664
|
+
@classmethod
|
|
665
|
+
def _reset_for_test(cls) -> None:
|
|
666
|
+
"""重置内部状态(仅供单测隔离使用,业务代码勿调)
|
|
667
|
+
|
|
668
|
+
注意:zvec.init() 是进程级单例无法真正重置,此处仅清理 collection 注册表。
|
|
669
|
+
"""
|
|
670
|
+
with _collection_lock:
|
|
671
|
+
_collections.clear()
|