zero-agent-core 0.2.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.
- agent_core/__init__.py +525 -0
- agent_core/access/__init__.py +6 -0
- agent_core/access/context.py +45 -0
- agent_core/access/guard.py +40 -0
- agent_core/acp/README.md +236 -0
- agent_core/acp/__init__.py +20 -0
- agent_core/acp/server.py +351 -0
- agent_core/acp/types.py +86 -0
- agent_core/callbacks/__init__.py +17 -0
- agent_core/callbacks/manager.py +105 -0
- agent_core/checkpoint/__init__.py +19 -0
- agent_core/checkpoint/naming.py +93 -0
- agent_core/checkpoint/store.py +186 -0
- agent_core/checkpoint/travel.py +448 -0
- agent_core/compaction/__init__.py +57 -0
- agent_core/compaction/middleware.py +428 -0
- agent_core/compaction/prompts.py +32 -0
- agent_core/compaction/pruner.py +121 -0
- agent_core/compaction/region.py +145 -0
- agent_core/compaction/spill.py +81 -0
- agent_core/compaction/spill_tool.py +42 -0
- agent_core/compaction/summarizer.py +95 -0
- agent_core/compaction/token_limit.py +74 -0
- agent_core/compaction/types.py +93 -0
- agent_core/docs.py +15 -0
- agent_core/documents/__init__.py +23 -0
- agent_core/documents/loaders.py +79 -0
- agent_core/documents/splitters.py +94 -0
- agent_core/documents/types.py +197 -0
- agent_core/errors.py +81 -0
- agent_core/guardrails/__init__.py +19 -0
- agent_core/guardrails/core.py +186 -0
- agent_core/hitl/__init__.py +15 -0
- agent_core/hitl/core.py +517 -0
- agent_core/mcp/__init__.py +14 -0
- agent_core/mcp/client.py +212 -0
- agent_core/mcp/errors.py +26 -0
- agent_core/middleware/__init__.py +26 -0
- agent_core/middleware/base.py +283 -0
- agent_core/model/__init__.py +73 -0
- agent_core/model/catalog.py +73 -0
- agent_core/model/core.py +179 -0
- agent_core/model/governance.py +317 -0
- agent_core/model/providers/__init__.py +43 -0
- agent_core/model/providers/anthropic.py +294 -0
- agent_core/model/providers/common.py +28 -0
- agent_core/model/providers/factory.py +107 -0
- agent_core/model/providers/gemini.py +273 -0
- agent_core/model/providers/ollama.py +80 -0
- agent_core/model/providers/openai_compatible.py +526 -0
- agent_core/model/structured.py +200 -0
- agent_core/observability/__init__.py +21 -0
- agent_core/observability/core.py +311 -0
- agent_core/ports/__init__.py +31 -0
- agent_core/ports/memory.py +130 -0
- agent_core/ports/model_selection.py +64 -0
- agent_core/ports/storage.py +114 -0
- agent_core/prompts/__init__.py +19 -0
- agent_core/prompts/registry.py +236 -0
- agent_core/prompts/templates.py +166 -0
- agent_core/protocol/__init__.py +31 -0
- agent_core/protocol/capabilities.py +49 -0
- agent_core/protocol/messages.py +100 -0
- agent_core/protocol/runtime.py +293 -0
- agent_core/py.typed +1 -0
- agent_core/retrieval/__init__.py +40 -0
- agent_core/retrieval/_common.py +121 -0
- agent_core/retrieval/chroma.py +269 -0
- agent_core/retrieval/core.py +63 -0
- agent_core/retrieval/factory.py +72 -0
- agent_core/retrieval/memory.py +232 -0
- agent_core/retrieval/pgvector.py +239 -0
- agent_core/retrieval/types.py +95 -0
- agent_core/runtime/__init__.py +28 -0
- agent_core/runtime/compat.py +98 -0
- agent_core/runtime/react.py +888 -0
- agent_core/runtime/service.py +557 -0
- agent_core/runtime/store.py +55 -0
- agent_core/serialization/__init__.py +21 -0
- agent_core/serialization/registry.py +318 -0
- agent_core/skills/__init__.py +24 -0
- agent_core/skills/errors.py +27 -0
- agent_core/skills/loader.py +258 -0
- agent_core/skills/registry.py +205 -0
- agent_core/skills/types.py +56 -0
- agent_core/storage/__init__.py +107 -0
- agent_core/storage/_sqlite_utils.py +92 -0
- agent_core/storage/context.py +382 -0
- agent_core/storage/lease.py +435 -0
- agent_core/storage/memory.py +604 -0
- agent_core/storage/model_selection.py +360 -0
- agent_core/storage/runtime.py +899 -0
- agent_core/storage/session.py +904 -0
- agent_core/storage/workflow.py +411 -0
- agent_core/tools/__init__.py +5 -0
- agent_core/tools/executor.py +306 -0
- agent_core/workflow/__init__.py +25 -0
- agent_core/workflow/durable.py +175 -0
- agent_core/workflow/state_machine.py +427 -0
- zero_agent_core-0.2.0.dist-info/METADATA +237 -0
- zero_agent_core-0.2.0.dist-info/RECORD +104 -0
- zero_agent_core-0.2.0.dist-info/WHEEL +4 -0
- zero_agent_core-0.2.0.dist-info/entry_points.txt +3 -0
- zero_agent_core-0.2.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Chroma 本地持久化向量存储适配器。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
from agent_core.access import AccessContext
|
|
11
|
+
from agent_core.retrieval._common import (
|
|
12
|
+
decode_record_payload,
|
|
13
|
+
encode_record_payload,
|
|
14
|
+
metadata_matches,
|
|
15
|
+
normalize_record,
|
|
16
|
+
record_accessible,
|
|
17
|
+
validate_vector,
|
|
18
|
+
)
|
|
19
|
+
from agent_core.retrieval.core import VectorStore
|
|
20
|
+
from agent_core.retrieval.types import VectorQuery, VectorRecord, VectorSearchResult
|
|
21
|
+
|
|
22
|
+
_NAMESPACE_KEY = "_agent_core_namespace"
|
|
23
|
+
_USER_KEY = "_agent_core_user_id"
|
|
24
|
+
_TENANT_KEY = "_agent_core_tenant_id"
|
|
25
|
+
_PAYLOAD_KEY = "_agent_core_payload"
|
|
26
|
+
_PUBLIC_OWNER = ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ChromaVectorStore(VectorStore):
|
|
30
|
+
"""适合本地开发的 Chroma 持久化实现。
|
|
31
|
+
|
|
32
|
+
Chroma 的 Python API 是同步接口,因此所有读写都通过 ``asyncio.to_thread``
|
|
33
|
+
执行,避免阻塞 Agent 所在的事件循环。
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
path: str | Path = "./chroma_db",
|
|
39
|
+
*,
|
|
40
|
+
collection_name: str = "agent_core",
|
|
41
|
+
require_access: bool = False,
|
|
42
|
+
client: Any | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
owns_client = client is None
|
|
45
|
+
if client is None:
|
|
46
|
+
try:
|
|
47
|
+
import chromadb
|
|
48
|
+
except ImportError as exc:
|
|
49
|
+
raise ImportError(
|
|
50
|
+
"ChromaVectorStore 需要 chromadb,请安装 zero-agent-core[chroma]"
|
|
51
|
+
) from exc
|
|
52
|
+
client = chromadb.PersistentClient(path=str(Path(path).resolve()))
|
|
53
|
+
# Chroma 各版本的 Client/Collection 泛型和重载差异较大,在 SDK 边界收口为 Any。
|
|
54
|
+
self._client: Any = client
|
|
55
|
+
self._owns_client = owns_client
|
|
56
|
+
self._collection: Any = client.get_or_create_collection(
|
|
57
|
+
name=collection_name,
|
|
58
|
+
metadata={"hnsw:space": "cosine"},
|
|
59
|
+
)
|
|
60
|
+
self._require_access = require_access
|
|
61
|
+
|
|
62
|
+
async def close(self) -> None:
|
|
63
|
+
"""关闭由当前 Store 创建的 PersistentClient,释放本地文件句柄。"""
|
|
64
|
+
if not self._owns_client:
|
|
65
|
+
return
|
|
66
|
+
close = getattr(self._client, "close", None)
|
|
67
|
+
if close is not None:
|
|
68
|
+
await asyncio.to_thread(close)
|
|
69
|
+
self._owns_client = False
|
|
70
|
+
|
|
71
|
+
async def upsert(
|
|
72
|
+
self,
|
|
73
|
+
records: Sequence[VectorRecord],
|
|
74
|
+
*,
|
|
75
|
+
access: AccessContext | None = None,
|
|
76
|
+
) -> list[str]:
|
|
77
|
+
values = [normalize_record(record, access, require_access=self._require_access) for record in records]
|
|
78
|
+
if not values:
|
|
79
|
+
return []
|
|
80
|
+
for record in values:
|
|
81
|
+
validate_vector(record.vector)
|
|
82
|
+
await self._check_existing_access([record.record_id for record in values], access)
|
|
83
|
+
metadatas = [self._metadata(record) for record in values]
|
|
84
|
+
await asyncio.to_thread(
|
|
85
|
+
self._collection.upsert,
|
|
86
|
+
ids=[record.record_id for record in values],
|
|
87
|
+
embeddings=[list(record.vector) for record in values],
|
|
88
|
+
documents=[record.document.content for record in values],
|
|
89
|
+
metadatas=metadatas,
|
|
90
|
+
)
|
|
91
|
+
return [record.record_id for record in values]
|
|
92
|
+
|
|
93
|
+
async def delete(
|
|
94
|
+
self,
|
|
95
|
+
record_ids: Sequence[str],
|
|
96
|
+
*,
|
|
97
|
+
access: AccessContext | None = None,
|
|
98
|
+
) -> int:
|
|
99
|
+
self._check_required_access(access)
|
|
100
|
+
if not record_ids:
|
|
101
|
+
return 0
|
|
102
|
+
result = await asyncio.to_thread(
|
|
103
|
+
self._collection.get,
|
|
104
|
+
ids=list(record_ids),
|
|
105
|
+
include=["metadatas"],
|
|
106
|
+
)
|
|
107
|
+
ids = self._flat_list(result, "ids")
|
|
108
|
+
metadatas = self._flat_list(result, "metadatas")
|
|
109
|
+
allowed: list[str] = []
|
|
110
|
+
for index, record_id in enumerate(ids):
|
|
111
|
+
metadata = self._mapping_at(metadatas, index)
|
|
112
|
+
self._check_metadata_access(str(record_id), metadata, access)
|
|
113
|
+
allowed.append(str(record_id))
|
|
114
|
+
if allowed:
|
|
115
|
+
await asyncio.to_thread(self._collection.delete, ids=allowed)
|
|
116
|
+
return len(allowed)
|
|
117
|
+
|
|
118
|
+
async def search(self, query: VectorQuery) -> list[VectorSearchResult]:
|
|
119
|
+
self._check_required_access(query.access)
|
|
120
|
+
validate_vector(query.vector)
|
|
121
|
+
count = int(await asyncio.to_thread(self._collection.count))
|
|
122
|
+
if count <= 0:
|
|
123
|
+
return []
|
|
124
|
+
# 没有业务 metadata 过滤时,namespace 和访问范围已下推给 Chroma,
|
|
125
|
+
# 只需取 limit 条;带任意 metadata 过滤时仍全量读取以保持现有精确语义。
|
|
126
|
+
n_results = count if query.metadata_filter else min(count, query.limit)
|
|
127
|
+
result = await asyncio.to_thread(
|
|
128
|
+
self._collection.query,
|
|
129
|
+
query_embeddings=[list(query.vector)],
|
|
130
|
+
n_results=n_results,
|
|
131
|
+
where=self._where(query.namespace, query.access),
|
|
132
|
+
include=["metadatas", "distances", "embeddings"],
|
|
133
|
+
)
|
|
134
|
+
ids = self._first_batch(result, "ids")
|
|
135
|
+
metadatas = self._first_batch(result, "metadatas")
|
|
136
|
+
distances = self._first_batch(result, "distances")
|
|
137
|
+
embeddings = self._first_batch(result, "embeddings")
|
|
138
|
+
matches: list[VectorSearchResult] = []
|
|
139
|
+
for index, record_id in enumerate(ids):
|
|
140
|
+
metadata = self._mapping_at(metadatas, index)
|
|
141
|
+
raw_vector = embeddings[index] if index < len(embeddings) else ()
|
|
142
|
+
vector = tuple(float(cast(Any, value)) for value in cast(Sequence[object], raw_vector))
|
|
143
|
+
record = self._record_from_metadata(str(record_id), metadata, vector)
|
|
144
|
+
combined_metadata = {**record.document.metadata, **record.metadata}
|
|
145
|
+
if not metadata_matches(combined_metadata, query.metadata_filter):
|
|
146
|
+
continue
|
|
147
|
+
if not record_accessible(record, query.access):
|
|
148
|
+
continue
|
|
149
|
+
distance = float(cast(Any, distances[index])) if index < len(distances) else 1.0
|
|
150
|
+
score = 1.0 - distance
|
|
151
|
+
if query.min_score is not None and score < query.min_score:
|
|
152
|
+
continue
|
|
153
|
+
matches.append(VectorSearchResult(record=record, score=score))
|
|
154
|
+
if len(matches) >= query.limit:
|
|
155
|
+
break
|
|
156
|
+
return matches
|
|
157
|
+
|
|
158
|
+
async def _check_existing_access(
|
|
159
|
+
self,
|
|
160
|
+
record_ids: Sequence[str],
|
|
161
|
+
access: AccessContext | None,
|
|
162
|
+
) -> None:
|
|
163
|
+
if access is None:
|
|
164
|
+
return
|
|
165
|
+
result = await asyncio.to_thread(
|
|
166
|
+
self._collection.get,
|
|
167
|
+
ids=list(record_ids),
|
|
168
|
+
include=["metadatas"],
|
|
169
|
+
)
|
|
170
|
+
ids = self._flat_list(result, "ids")
|
|
171
|
+
metadatas = self._flat_list(result, "metadatas")
|
|
172
|
+
for index, record_id in enumerate(ids):
|
|
173
|
+
self._check_metadata_access(
|
|
174
|
+
str(record_id),
|
|
175
|
+
self._mapping_at(metadatas, index),
|
|
176
|
+
access,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def _check_required_access(self, access: AccessContext | None) -> None:
|
|
180
|
+
if self._require_access and access is None:
|
|
181
|
+
raise PermissionError("该 VectorStore 要求提供 AccessContext")
|
|
182
|
+
|
|
183
|
+
@staticmethod
|
|
184
|
+
def _check_metadata_access(
|
|
185
|
+
record_id: str,
|
|
186
|
+
metadata: Mapping[str, object],
|
|
187
|
+
access: AccessContext | None,
|
|
188
|
+
) -> None:
|
|
189
|
+
if access is None:
|
|
190
|
+
return
|
|
191
|
+
user_id = str(metadata[_USER_KEY]) if metadata.get(_USER_KEY) else None
|
|
192
|
+
tenant_id = str(metadata[_TENANT_KEY]) if metadata.get(_TENANT_KEY) else None
|
|
193
|
+
if not access.can_access(user_id, tenant_id):
|
|
194
|
+
raise PermissionError(f"无权访问向量记录:{record_id}")
|
|
195
|
+
|
|
196
|
+
@staticmethod
|
|
197
|
+
def _metadata(record: VectorRecord) -> dict[str, str]:
|
|
198
|
+
return {
|
|
199
|
+
_NAMESPACE_KEY: record.namespace,
|
|
200
|
+
_USER_KEY: record.user_id or _PUBLIC_OWNER,
|
|
201
|
+
_TENANT_KEY: record.tenant_id or _PUBLIC_OWNER,
|
|
202
|
+
_PAYLOAD_KEY: encode_record_payload(record),
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
@staticmethod
|
|
206
|
+
def _where(namespace: str, access: AccessContext | None) -> dict[str, object]:
|
|
207
|
+
clauses: list[dict[str, object]] = [{_NAMESPACE_KEY: {"$eq": namespace}}]
|
|
208
|
+
if access is not None:
|
|
209
|
+
clauses.append(
|
|
210
|
+
{
|
|
211
|
+
"$or": [
|
|
212
|
+
{_TENANT_KEY: {"$eq": _PUBLIC_OWNER}},
|
|
213
|
+
{_TENANT_KEY: {"$eq": access.tenant_id}},
|
|
214
|
+
]
|
|
215
|
+
}
|
|
216
|
+
)
|
|
217
|
+
if not access.is_admin:
|
|
218
|
+
clauses.append(
|
|
219
|
+
{
|
|
220
|
+
"$or": [
|
|
221
|
+
{_USER_KEY: {"$eq": _PUBLIC_OWNER}},
|
|
222
|
+
{_USER_KEY: {"$eq": access.user_id}},
|
|
223
|
+
]
|
|
224
|
+
}
|
|
225
|
+
)
|
|
226
|
+
return clauses[0] if len(clauses) == 1 else {"$and": clauses}
|
|
227
|
+
|
|
228
|
+
@staticmethod
|
|
229
|
+
def _record_from_metadata(
|
|
230
|
+
record_id: str,
|
|
231
|
+
metadata: Mapping[str, object],
|
|
232
|
+
vector: tuple[float, ...],
|
|
233
|
+
) -> VectorRecord:
|
|
234
|
+
return decode_record_payload(
|
|
235
|
+
metadata.get(_PAYLOAD_KEY),
|
|
236
|
+
record_id=record_id,
|
|
237
|
+
vector=vector,
|
|
238
|
+
namespace=str(metadata.get(_NAMESPACE_KEY, "default")),
|
|
239
|
+
user_id=str(metadata[_USER_KEY]) if metadata.get(_USER_KEY) else None,
|
|
240
|
+
tenant_id=str(metadata[_TENANT_KEY]) if metadata.get(_TENANT_KEY) else None,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
@staticmethod
|
|
244
|
+
def _flat_list(result: object, key: str) -> list[object]:
|
|
245
|
+
if not isinstance(result, Mapping):
|
|
246
|
+
return []
|
|
247
|
+
return ChromaVectorStore._to_list(result.get(key))
|
|
248
|
+
|
|
249
|
+
@staticmethod
|
|
250
|
+
def _first_batch(result: object, key: str) -> list[object]:
|
|
251
|
+
values = ChromaVectorStore._flat_list(result, key)
|
|
252
|
+
if not values:
|
|
253
|
+
return []
|
|
254
|
+
return ChromaVectorStore._to_list(values[0])
|
|
255
|
+
|
|
256
|
+
@staticmethod
|
|
257
|
+
def _to_list(value: object) -> list[object]:
|
|
258
|
+
if isinstance(value, (str, bytes, Mapping)) or not isinstance(value, Iterable):
|
|
259
|
+
return []
|
|
260
|
+
return list(value)
|
|
261
|
+
|
|
262
|
+
@staticmethod
|
|
263
|
+
def _mapping_at(values: Sequence[object], index: int) -> Mapping[str, object]:
|
|
264
|
+
if index >= len(values) or not isinstance(values[index], Mapping):
|
|
265
|
+
raise ValueError("Chroma 返回的向量记录缺少 metadata")
|
|
266
|
+
return cast(Mapping[str, object], values[index])
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
__all__ = ["ChromaVectorStore"]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""检索依赖倒置协议。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import Protocol, runtime_checkable
|
|
7
|
+
|
|
8
|
+
from agent_core.access import AccessContext
|
|
9
|
+
from agent_core.retrieval.types import (
|
|
10
|
+
RetrievalQuery,
|
|
11
|
+
RetrievalResult,
|
|
12
|
+
VectorQuery,
|
|
13
|
+
VectorRecord,
|
|
14
|
+
VectorSearchResult,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@runtime_checkable
|
|
19
|
+
class Embeddings(Protocol):
|
|
20
|
+
"""文本向量化协议,厂商 SDK 由扩展适配器实现。"""
|
|
21
|
+
|
|
22
|
+
async def embed_documents(self, texts: Sequence[str]) -> list[list[float]]: ...
|
|
23
|
+
|
|
24
|
+
async def embed_query(self, text: str) -> list[float]: ...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@runtime_checkable
|
|
28
|
+
class Retriever(Protocol):
|
|
29
|
+
"""根据查询返回排好序的文档。"""
|
|
30
|
+
|
|
31
|
+
async def retrieve(self, query: RetrievalQuery) -> list[RetrievalResult]: ...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@runtime_checkable
|
|
35
|
+
class VectorStore(Protocol):
|
|
36
|
+
"""向量存储端口,不绑定 pgvector、Milvus、Qdrant 或其他实现。"""
|
|
37
|
+
|
|
38
|
+
async def upsert(
|
|
39
|
+
self,
|
|
40
|
+
records: Sequence[VectorRecord],
|
|
41
|
+
*,
|
|
42
|
+
access: AccessContext | None = None,
|
|
43
|
+
) -> list[str]: ...
|
|
44
|
+
|
|
45
|
+
async def delete(
|
|
46
|
+
self,
|
|
47
|
+
record_ids: Sequence[str],
|
|
48
|
+
*,
|
|
49
|
+
access: AccessContext | None = None,
|
|
50
|
+
) -> int: ...
|
|
51
|
+
|
|
52
|
+
async def search(self, query: VectorQuery) -> list[VectorSearchResult]: ...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@runtime_checkable
|
|
56
|
+
class Reranker(Protocol):
|
|
57
|
+
"""可选的二阶段重排端口。"""
|
|
58
|
+
|
|
59
|
+
async def rerank(
|
|
60
|
+
self,
|
|
61
|
+
query: RetrievalQuery,
|
|
62
|
+
results: Sequence[RetrievalResult],
|
|
63
|
+
) -> list[RetrievalResult]: ...
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""按环境和显式配置创建向量存储。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
from agent_core.retrieval.chroma import ChromaVectorStore
|
|
9
|
+
from agent_core.retrieval.core import VectorStore
|
|
10
|
+
from agent_core.retrieval.memory import InMemoryVectorStore
|
|
11
|
+
from agent_core.retrieval.pgvector import PgVectorStore
|
|
12
|
+
|
|
13
|
+
VectorStoreBackend = Literal["memory", "chroma", "pgvector"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def create_vector_store(
|
|
17
|
+
env: str = "development",
|
|
18
|
+
*,
|
|
19
|
+
backend: str | None = None,
|
|
20
|
+
chroma_path: str | Path = "./chroma_db",
|
|
21
|
+
chroma_client: Any | None = None,
|
|
22
|
+
postgres_url: str | None = None,
|
|
23
|
+
dimension: int | None = None,
|
|
24
|
+
collection_name: str = "agent_core",
|
|
25
|
+
table_name: str = "agent_vector_records",
|
|
26
|
+
require_access: bool = False,
|
|
27
|
+
pg_create_extension: bool = True,
|
|
28
|
+
pg_create_index: bool = True,
|
|
29
|
+
) -> VectorStore:
|
|
30
|
+
"""创建向量存储;显式 backend 优先于环境默认值。
|
|
31
|
+
|
|
32
|
+
默认映射:测试使用内存,开发使用 Chroma,生产使用 pgvector。
|
|
33
|
+
PostgreSQL 实现返回后仍需调用 ``initialize()``。
|
|
34
|
+
"""
|
|
35
|
+
normalized_env = env.strip().lower()
|
|
36
|
+
selected = (backend or _default_backend(normalized_env)).strip().lower()
|
|
37
|
+
selected = {"postgres": "pgvector", "postgresql": "pgvector"}.get(selected, selected)
|
|
38
|
+
if selected == "memory":
|
|
39
|
+
return InMemoryVectorStore(require_access=require_access)
|
|
40
|
+
if selected == "chroma":
|
|
41
|
+
return ChromaVectorStore(
|
|
42
|
+
chroma_path,
|
|
43
|
+
collection_name=collection_name,
|
|
44
|
+
require_access=require_access,
|
|
45
|
+
client=chroma_client,
|
|
46
|
+
)
|
|
47
|
+
if selected == "pgvector":
|
|
48
|
+
if not postgres_url:
|
|
49
|
+
raise ValueError("pgvector 后端需要配置 PostgreSQL 连接地址")
|
|
50
|
+
if dimension is None:
|
|
51
|
+
raise ValueError("pgvector 后端需要配置 embedding dimension")
|
|
52
|
+
return PgVectorStore(
|
|
53
|
+
postgres_url,
|
|
54
|
+
dimension=dimension,
|
|
55
|
+
collection_name=collection_name,
|
|
56
|
+
table_name=table_name,
|
|
57
|
+
require_access=require_access,
|
|
58
|
+
create_extension=pg_create_extension,
|
|
59
|
+
create_index=pg_create_index,
|
|
60
|
+
)
|
|
61
|
+
raise ValueError(f"不支持的向量存储后端:{selected}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _default_backend(env: str) -> VectorStoreBackend:
|
|
65
|
+
if env in {"test", "testing"}:
|
|
66
|
+
return "memory"
|
|
67
|
+
if env in {"production", "prod"}:
|
|
68
|
+
return "pgvector"
|
|
69
|
+
return "chroma"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
__all__ = ["VectorStoreBackend", "create_vector_store"]
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""用于开发、测试和小数据集的零依赖检索实现。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Iterable, Sequence
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from agent_core.access import AccessContext
|
|
11
|
+
from agent_core.documents import Document
|
|
12
|
+
from agent_core.retrieval._common import check_record_access, normalize_record, validate_vector
|
|
13
|
+
from agent_core.retrieval.core import Embeddings, VectorStore
|
|
14
|
+
from agent_core.retrieval.types import (
|
|
15
|
+
RetrievalQuery,
|
|
16
|
+
RetrievalResult,
|
|
17
|
+
SearchType,
|
|
18
|
+
VectorQuery,
|
|
19
|
+
VectorRecord,
|
|
20
|
+
VectorSearchResult,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _metadata_matches(metadata: dict[str, Any], expected: dict[str, Any]) -> bool:
|
|
25
|
+
return all(metadata.get(key) == value for key, value in expected.items())
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _cosine_similarity(left: tuple[float, ...], right: tuple[float, ...]) -> float:
|
|
29
|
+
if len(left) != len(right):
|
|
30
|
+
raise ValueError(f"向量维度不一致:{len(left)} != {len(right)}")
|
|
31
|
+
dot = sum(a * b for a, b in zip(left, right, strict=True))
|
|
32
|
+
left_norm = math.sqrt(sum(value * value for value in left))
|
|
33
|
+
right_norm = math.sqrt(sum(value * value for value in right))
|
|
34
|
+
if left_norm == 0 or right_norm == 0:
|
|
35
|
+
return 0.0
|
|
36
|
+
return dot / (left_norm * right_norm)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class InMemoryVectorStore(VectorStore):
|
|
40
|
+
"""带 namespace、metadata 和用户/租户过滤的内存向量存储。"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, *, require_access: bool = False) -> None:
|
|
43
|
+
self._records: dict[str, VectorRecord] = {}
|
|
44
|
+
self._dimension: int | None = None
|
|
45
|
+
self._require_access = require_access
|
|
46
|
+
|
|
47
|
+
async def upsert(
|
|
48
|
+
self,
|
|
49
|
+
records: Sequence[VectorRecord],
|
|
50
|
+
*,
|
|
51
|
+
access: AccessContext | None = None,
|
|
52
|
+
) -> list[str]:
|
|
53
|
+
self._check_required_access(access)
|
|
54
|
+
saved: list[str] = []
|
|
55
|
+
for record in records:
|
|
56
|
+
self._check_dimension(record.vector)
|
|
57
|
+
value = normalize_record(record, access, require_access=self._require_access)
|
|
58
|
+
existing = self._records.get(value.record_id)
|
|
59
|
+
if existing is not None:
|
|
60
|
+
check_record_access(existing, access)
|
|
61
|
+
self._records[value.record_id] = value
|
|
62
|
+
saved.append(value.record_id)
|
|
63
|
+
return saved
|
|
64
|
+
|
|
65
|
+
async def delete(
|
|
66
|
+
self,
|
|
67
|
+
record_ids: Sequence[str],
|
|
68
|
+
*,
|
|
69
|
+
access: AccessContext | None = None,
|
|
70
|
+
) -> int:
|
|
71
|
+
self._check_required_access(access)
|
|
72
|
+
existing = [self._records[record_id] for record_id in record_ids if record_id in self._records]
|
|
73
|
+
# 先完成整批权限校验,避免中途失败时前面的记录已经被删除。
|
|
74
|
+
for record in existing:
|
|
75
|
+
self._check_record_access(record, access)
|
|
76
|
+
deleted = 0
|
|
77
|
+
for record_id in record_ids:
|
|
78
|
+
target = self._records.get(record_id)
|
|
79
|
+
if target is None:
|
|
80
|
+
continue
|
|
81
|
+
del self._records[record_id]
|
|
82
|
+
deleted += 1
|
|
83
|
+
return deleted
|
|
84
|
+
|
|
85
|
+
async def search(self, query: VectorQuery) -> list[VectorSearchResult]:
|
|
86
|
+
self._check_required_access(query.access)
|
|
87
|
+
if self._dimension is not None and len(query.vector) != self._dimension:
|
|
88
|
+
raise ValueError(f"向量维度不一致:期望 {self._dimension},实际 {len(query.vector)}")
|
|
89
|
+
matches: list[VectorSearchResult] = []
|
|
90
|
+
for record in self._records.values():
|
|
91
|
+
if record.namespace != query.namespace:
|
|
92
|
+
continue
|
|
93
|
+
if not _metadata_matches({**record.document.metadata, **record.metadata}, query.metadata_filter):
|
|
94
|
+
continue
|
|
95
|
+
if not self._record_accessible(record, query.access):
|
|
96
|
+
continue
|
|
97
|
+
score = _cosine_similarity(query.vector, record.vector)
|
|
98
|
+
if query.min_score is not None and score < query.min_score:
|
|
99
|
+
continue
|
|
100
|
+
matches.append(VectorSearchResult(record=record, score=score))
|
|
101
|
+
matches.sort(key=lambda item: (-item.score, item.record.record_id))
|
|
102
|
+
return matches[: query.limit]
|
|
103
|
+
|
|
104
|
+
def _check_dimension(self, vector: tuple[float, ...]) -> None:
|
|
105
|
+
validate_vector(vector)
|
|
106
|
+
if self._dimension is None:
|
|
107
|
+
self._dimension = len(vector)
|
|
108
|
+
elif len(vector) != self._dimension:
|
|
109
|
+
raise ValueError(f"向量维度不一致:期望 {self._dimension},实际 {len(vector)}")
|
|
110
|
+
|
|
111
|
+
def _check_required_access(self, access: AccessContext | None) -> None:
|
|
112
|
+
if self._require_access and access is None:
|
|
113
|
+
raise PermissionError("该 VectorStore 要求提供 AccessContext")
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _record_accessible(record: VectorRecord, access: AccessContext | None) -> bool:
|
|
117
|
+
return access is None or access.can_access(record.user_id, record.tenant_id)
|
|
118
|
+
|
|
119
|
+
@classmethod
|
|
120
|
+
def _check_record_access(cls, record: VectorRecord, access: AccessContext | None) -> None:
|
|
121
|
+
if not cls._record_accessible(record, access):
|
|
122
|
+
raise PermissionError(f"无权访问向量记录:{record.record_id}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class EmbeddingRetriever:
|
|
126
|
+
"""组合 Embeddings 和 VectorStore 的通用语义 Retriever。"""
|
|
127
|
+
|
|
128
|
+
def __init__(self, embeddings: Embeddings, vector_store: VectorStore) -> None:
|
|
129
|
+
self._embeddings = embeddings
|
|
130
|
+
self._vector_store = vector_store
|
|
131
|
+
|
|
132
|
+
async def add_documents(
|
|
133
|
+
self,
|
|
134
|
+
documents: Sequence[Document],
|
|
135
|
+
*,
|
|
136
|
+
namespace: str = "default",
|
|
137
|
+
access: AccessContext | None = None,
|
|
138
|
+
metadata: dict[str, Any] | None = None,
|
|
139
|
+
) -> list[str]:
|
|
140
|
+
vectors = await self._embeddings.embed_documents([item.content for item in documents])
|
|
141
|
+
if len(vectors) != len(documents):
|
|
142
|
+
raise ValueError("Embedding 返回数量与文档数量不一致")
|
|
143
|
+
records = [
|
|
144
|
+
VectorRecord(
|
|
145
|
+
record_id=document.document_id,
|
|
146
|
+
vector=tuple(float(value) for value in vector),
|
|
147
|
+
document=document,
|
|
148
|
+
namespace=namespace,
|
|
149
|
+
user_id=access.user_id if access else None,
|
|
150
|
+
tenant_id=access.tenant_id if access else None,
|
|
151
|
+
metadata=dict(metadata or {}),
|
|
152
|
+
)
|
|
153
|
+
for document, vector in zip(documents, vectors, strict=True)
|
|
154
|
+
]
|
|
155
|
+
return await self._vector_store.upsert(records, access=access)
|
|
156
|
+
|
|
157
|
+
async def retrieve(self, query: RetrievalQuery) -> list[RetrievalResult]:
|
|
158
|
+
if query.search_type != SearchType.SIMILARITY:
|
|
159
|
+
raise ValueError("EmbeddingRetriever 当前只支持 similarity 检索")
|
|
160
|
+
vector = await self._embeddings.embed_query(query.text)
|
|
161
|
+
matches = await self._vector_store.search(
|
|
162
|
+
VectorQuery(
|
|
163
|
+
vector=tuple(float(value) for value in vector),
|
|
164
|
+
limit=query.limit,
|
|
165
|
+
namespace=query.namespace,
|
|
166
|
+
metadata_filter=query.metadata_filter,
|
|
167
|
+
min_score=query.min_score,
|
|
168
|
+
access=query.access,
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
return [
|
|
172
|
+
RetrievalResult(
|
|
173
|
+
document=item.record.document,
|
|
174
|
+
score=item.score,
|
|
175
|
+
rank=index,
|
|
176
|
+
metadata={"record_id": item.record.record_id, **item.record.metadata},
|
|
177
|
+
)
|
|
178
|
+
for index, item in enumerate(matches, start=1)
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class KeywordRetriever:
|
|
183
|
+
"""零依赖关键词 Retriever,适合测试和小型静态语料。"""
|
|
184
|
+
|
|
185
|
+
def __init__(
|
|
186
|
+
self,
|
|
187
|
+
documents: Iterable[Document] | None = None,
|
|
188
|
+
*,
|
|
189
|
+
require_access: bool = False,
|
|
190
|
+
) -> None:
|
|
191
|
+
self._documents = list(documents or ())
|
|
192
|
+
self._require_access = require_access
|
|
193
|
+
|
|
194
|
+
def add_documents(self, documents: Iterable[Document]) -> None:
|
|
195
|
+
self._documents.extend(documents)
|
|
196
|
+
|
|
197
|
+
async def retrieve(self, query: RetrievalQuery) -> list[RetrievalResult]:
|
|
198
|
+
if query.search_type != SearchType.KEYWORD:
|
|
199
|
+
raise ValueError("KeywordRetriever 只支持 keyword 检索")
|
|
200
|
+
if self._require_access and query.access is None:
|
|
201
|
+
raise PermissionError("该 Retriever 要求提供 AccessContext")
|
|
202
|
+
terms = [term.casefold() for term in query.text.split() if term.strip()]
|
|
203
|
+
if not terms and query.text.strip():
|
|
204
|
+
terms = [query.text.strip().casefold()]
|
|
205
|
+
results: list[RetrievalResult] = []
|
|
206
|
+
for document in self._documents:
|
|
207
|
+
metadata = document.metadata
|
|
208
|
+
if metadata.get("namespace", "default") != query.namespace:
|
|
209
|
+
continue
|
|
210
|
+
if not _metadata_matches(metadata, query.metadata_filter):
|
|
211
|
+
continue
|
|
212
|
+
if query.access is not None and not query.access.can_access(
|
|
213
|
+
metadata.get("user_id"), metadata.get("tenant_id")
|
|
214
|
+
):
|
|
215
|
+
continue
|
|
216
|
+
content = document.content.casefold()
|
|
217
|
+
matches = sum(content.count(term) for term in terms)
|
|
218
|
+
if matches == 0:
|
|
219
|
+
continue
|
|
220
|
+
score = matches / max(len(terms), 1)
|
|
221
|
+
if query.min_score is not None and score < query.min_score:
|
|
222
|
+
continue
|
|
223
|
+
results.append(
|
|
224
|
+
RetrievalResult(
|
|
225
|
+
document=document,
|
|
226
|
+
score=score,
|
|
227
|
+
rank=0,
|
|
228
|
+
metadata={"matches": matches},
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
results.sort(key=lambda item: (-item.score, item.document.document_id))
|
|
232
|
+
return [replace(item, rank=index) for index, item in enumerate(results[: query.limit], start=1)]
|