swiftpy-framework 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.
- swiftpy/.gitignore +0 -0
- swiftpy/__init__.py +572 -0
- swiftpy/admin/__init__.py +50 -0
- swiftpy/admin/auth.py +522 -0
- swiftpy/admin/registry.py +567 -0
- swiftpy/admin/views.py +1583 -0
- swiftpy/ai/__init__.py +199 -0
- swiftpy/ai/_deterministic.py +40 -0
- swiftpy/ai/_fake.py +604 -0
- swiftpy/ai/agents/__init__.py +30 -0
- swiftpy/ai/agents/base_agent.py +506 -0
- swiftpy/ai/agents/memory.py +864 -0
- swiftpy/ai/client.py +1011 -0
- swiftpy/ai/conversation.py +171 -0
- swiftpy/ai/embeddings.py +321 -0
- swiftpy/ai/exceptions.py +75 -0
- swiftpy/ai/history_strategies.py +157 -0
- swiftpy/ai/middleware.py +918 -0
- swiftpy/ai/observability/__init__.py +28 -0
- swiftpy/ai/observability/drivers.py +763 -0
- swiftpy/ai/observability/observer.py +364 -0
- swiftpy/ai/pool.py +102 -0
- swiftpy/ai/pricing.py +101 -0
- swiftpy/ai/prompts/__init__.py +6 -0
- swiftpy/ai/prompts/loader.py +343 -0
- swiftpy/ai/providers/__init__.py +22 -0
- swiftpy/ai/providers/anthropic_provider.py +353 -0
- swiftpy/ai/providers/base.py +273 -0
- swiftpy/ai/providers/openai_provider.py +275 -0
- swiftpy/ai/rag/__init__.py +53 -0
- swiftpy/ai/rag/context_formatter.py +263 -0
- swiftpy/ai/rag/pipeline_builder.py +544 -0
- swiftpy/ai/rag/reranker.py +484 -0
- swiftpy/ai/rag/result.py +78 -0
- swiftpy/ai/rag/retriever.py +315 -0
- swiftpy/ai/redis_store.py +227 -0
- swiftpy/ai/streaming.py +115 -0
- swiftpy/ai/tools/__init__.py +22 -0
- swiftpy/ai/tools/base_tool.py +489 -0
- swiftpy/ai/tools/first_party.py +384 -0
- swiftpy/ai/tools/tool_loop.py +417 -0
- swiftpy/ai/vectorstore/__init__.py +46 -0
- swiftpy/ai/vectorstore/base.py +542 -0
- swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
- swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
- swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
- swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
- swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
- swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
- swiftpy/auth/__init__.py +50 -0
- swiftpy/auth/api_key_guard.py +544 -0
- swiftpy/auth/auth_manager.py +259 -0
- swiftpy/auth/exceptions.py +71 -0
- swiftpy/auth/jwt_guard.py +230 -0
- swiftpy/auth/jwt_issuer.py +658 -0
- swiftpy/auth/token_blacklist.py +300 -0
- swiftpy/cache/__init__.py +14 -0
- swiftpy/cache/cache.py +251 -0
- swiftpy/cache/driver.py +216 -0
- swiftpy/cache/drivers/memory.py +199 -0
- swiftpy/cache/drivers/redis.py +421 -0
- swiftpy/console/__init__.py +0 -0
- swiftpy/console/cli.py +68 -0
- swiftpy/console/commands/__init__.py +0 -0
- swiftpy/console/commands/cache.py +164 -0
- swiftpy/console/commands/make.py +935 -0
- swiftpy/console/commands/migrate.py +280 -0
- swiftpy/console/commands/new.py +1806 -0
- swiftpy/console/commands/queue.py +306 -0
- swiftpy/console/commands/seed.py +252 -0
- swiftpy/console/commands/start.py +171 -0
- swiftpy/console/commands/storage.py +104 -0
- swiftpy/console/loader.py +21 -0
- swiftpy/console/stubs/__init__.py +26 -0
- swiftpy/console/stubs/controller.stub +14 -0
- swiftpy/console/stubs/event.stub +14 -0
- swiftpy/console/stubs/job.stub +22 -0
- swiftpy/console/stubs/listener.stub +14 -0
- swiftpy/console/stubs/mail.stub +20 -0
- swiftpy/console/stubs/middleware.stub +16 -0
- swiftpy/console/stubs/migration.alter.stub +17 -0
- swiftpy/console/stubs/migration.api_keys.stub +22 -0
- swiftpy/console/stubs/migration.blank.stub +11 -0
- swiftpy/console/stubs/migration.create.stub +15 -0
- swiftpy/console/stubs/migration.stub +11 -0
- swiftpy/console/stubs/migration.update.stub +17 -0
- swiftpy/console/stubs/model.stub +17 -0
- swiftpy/console/stubs/provider.stub +18 -0
- swiftpy/console/stubs/repository.stub +10 -0
- swiftpy/console/stubs/request.stub +16 -0
- swiftpy/console/stubs/resource.stub +21 -0
- swiftpy/console/stubs/schema.stub +12 -0
- swiftpy/console/stubs/seeder.stub +14 -0
- swiftpy/console/stubs/service.stub +10 -0
- swiftpy/core/__init__.py +0 -0
- swiftpy/core/application.py +1306 -0
- swiftpy/core/bootstrap.py +30 -0
- swiftpy/core/config.py +432 -0
- swiftpy/core/container.py +596 -0
- swiftpy/core/container_exceptions.py +18 -0
- swiftpy/core/context.py +148 -0
- swiftpy/core/exceptions.py +352 -0
- swiftpy/core/providers/base.py +35 -0
- swiftpy/database/__init__.py +41 -0
- swiftpy/database/exceptions.py +42 -0
- swiftpy/database/identifiers.py +105 -0
- swiftpy/database/migrations/__init__.py +36 -0
- swiftpy/database/migrations/blueprint.py +431 -0
- swiftpy/database/migrations/lock.py +104 -0
- swiftpy/database/migrations/migration.py +22 -0
- swiftpy/database/migrations/migrator.py +507 -0
- swiftpy/database/migrations/schema.py +435 -0
- swiftpy/database/orm/__init__.py +33 -0
- swiftpy/database/orm/fields.py +92 -0
- swiftpy/database/orm/model.py +2034 -0
- swiftpy/database/orm/registry.py +33 -0
- swiftpy/database/orm/relationships.py +836 -0
- swiftpy/database/pagination/__init__.py +16 -0
- swiftpy/database/pagination/contracts.py +60 -0
- swiftpy/database/pagination/paginator.py +112 -0
- swiftpy/database/pagination/simple_paginator.py +97 -0
- swiftpy/database/pool.py +218 -0
- swiftpy/database/query_builder.py +1803 -0
- swiftpy/database/seeder.py +39 -0
- swiftpy/database/transaction.py +276 -0
- swiftpy/events/__init__.py +10 -0
- swiftpy/events/dispatcher.py +308 -0
- swiftpy/events/redis_bus.py +552 -0
- swiftpy/filesystem/__init__.py +227 -0
- swiftpy/filesystem/disk.py +378 -0
- swiftpy/filesystem/drivers/__init__.py +11 -0
- swiftpy/filesystem/drivers/base.py +156 -0
- swiftpy/filesystem/drivers/local.py +559 -0
- swiftpy/filesystem/drivers/s3.py +655 -0
- swiftpy/filesystem/events.py +37 -0
- swiftpy/filesystem/exceptions.py +127 -0
- swiftpy/filesystem/manager.py +138 -0
- swiftpy/filesystem/metadata.py +22 -0
- swiftpy/filesystem/paths.py +99 -0
- swiftpy/filesystem/service_provider.py +17 -0
- swiftpy/filesystem/uploads/__init__.py +13 -0
- swiftpy/filesystem/uploads/handlers.py +362 -0
- swiftpy/filesystem/uploads/uploaded_file.py +222 -0
- swiftpy/filesystem/uploads/validators.py +186 -0
- swiftpy/filesystem/visibility.py +22 -0
- swiftpy/helpers/__init__.py +42 -0
- swiftpy/helpers/helper.py +180 -0
- swiftpy/http/exceptions.py +41 -0
- swiftpy/http/middleware.py +307 -0
- swiftpy/http/request.py +375 -0
- swiftpy/http/response.py +442 -0
- swiftpy/http/router.py +1050 -0
- swiftpy/mail/__init__.py +58 -0
- swiftpy/mail/attachments.py +255 -0
- swiftpy/mail/config.py +71 -0
- swiftpy/mail/envelope.py +183 -0
- swiftpy/mail/exceptions.py +90 -0
- swiftpy/mail/facade.py +312 -0
- swiftpy/mail/mailable.py +53 -0
- swiftpy/mail/manager.py +240 -0
- swiftpy/mail/message.py +186 -0
- swiftpy/mail/models.py +19 -0
- swiftpy/mail/queue/__init__.py +5 -0
- swiftpy/mail/queue/job.py +279 -0
- swiftpy/mail/service_provider.py +19 -0
- swiftpy/mail/templates/__init__.py +5 -0
- swiftpy/mail/templates/renderer.py +173 -0
- swiftpy/mail/transports/__init__.py +21 -0
- swiftpy/mail/transports/api/__init__.py +13 -0
- swiftpy/mail/transports/api/base.py +194 -0
- swiftpy/mail/transports/api/mailgun.py +158 -0
- swiftpy/mail/transports/api/resend.py +156 -0
- swiftpy/mail/transports/api/sendgrid.py +172 -0
- swiftpy/mail/transports/base.py +24 -0
- swiftpy/mail/transports/console.py +91 -0
- swiftpy/mail/transports/sendmail.py +128 -0
- swiftpy/mail/transports/smtp.py +466 -0
- swiftpy/queue/__init__.py +13 -0
- swiftpy/queue/dispatcher.py +813 -0
- swiftpy/queue/job.py +541 -0
- swiftpy/queue/worker.py +556 -0
- swiftpy/support/__init__.py +12 -0
- swiftpy/support/keys.py +108 -0
- swiftpy/support/redis_config.py +223 -0
- swiftpy/support/strings.py +114 -0
- swiftpy/testing/__init__.py +20 -0
- swiftpy/testing/fakes/__init__.py +11 -0
- swiftpy/testing/fakes/ai_fake.py +21 -0
- swiftpy/testing/fakes/vectorstore_fake.py +194 -0
- swiftpy/testing/fixtures.py +124 -0
- swiftpy/testing/http_client.py +730 -0
- swiftpy/testing/test_case.py +126 -0
- swiftpy/validation/__init__.py +97 -0
- swiftpy/validation/exceptions.py +26 -0
- swiftpy/validation/form_request.py +302 -0
- swiftpy/validation/protocols.py +33 -0
- swiftpy/validation/rules/__init__.py +89 -0
- swiftpy/validation/rules/builtin.py +391 -0
- swiftpy/validation/rules/exists.py +86 -0
- swiftpy/validation/rules/registry.py +290 -0
- swiftpy/validation/rules/required.py +55 -0
- swiftpy/validation/rules/unique.py +138 -0
- swiftpy/validation/validator.py +612 -0
- swiftpy/websockets/__init__.py +19 -0
- swiftpy/websockets/connection.py +317 -0
- swiftpy/websockets/manager.py +271 -0
- swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
- swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
- swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
- swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
- swiftpy_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import AsyncIterator, Sequence
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import asyncpg
|
|
11
|
+
|
|
12
|
+
from swiftpy.ai.pool import AIHttpPool
|
|
13
|
+
from swiftpy.ai.vectorstore.base import (
|
|
14
|
+
SearchResult,
|
|
15
|
+
VectorRecord,
|
|
16
|
+
VectorStore,
|
|
17
|
+
VectorStoreCapabilities,
|
|
18
|
+
dedupe_preserve_order,
|
|
19
|
+
normalize_record,
|
|
20
|
+
validate_collection_name,
|
|
21
|
+
validate_record_id,
|
|
22
|
+
validate_vector,
|
|
23
|
+
)
|
|
24
|
+
from swiftpy.core.config import Config
|
|
25
|
+
from swiftpy.core.container import get_container
|
|
26
|
+
from swiftpy.database.identifiers import (
|
|
27
|
+
MAX_IDENTIFIER_LENGTH,
|
|
28
|
+
quote_identifier,
|
|
29
|
+
validate_identifier,
|
|
30
|
+
)
|
|
31
|
+
from swiftpy.database.pool import DatabasePool
|
|
32
|
+
from swiftpy.database.transaction import get_active_connection
|
|
33
|
+
|
|
34
|
+
METRIC_CONFIG: dict[str, dict[str, str]] = {
|
|
35
|
+
"cosine": {
|
|
36
|
+
"operator": "<=>",
|
|
37
|
+
"opclass": "vector_cosine_ops",
|
|
38
|
+
"score_sql": "(1.0 - (embedding <=> $1::vector))",
|
|
39
|
+
},
|
|
40
|
+
"l2": {
|
|
41
|
+
"operator": "<->",
|
|
42
|
+
"opclass": "vector_l2_ops",
|
|
43
|
+
"score_sql": "(-(embedding <-> $1::vector))",
|
|
44
|
+
},
|
|
45
|
+
"inner_product": {
|
|
46
|
+
"operator": "<#>",
|
|
47
|
+
"opclass": "vector_ip_ops",
|
|
48
|
+
"score_sql": "(-(embedding <#> $1::vector))",
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _format_vector(vector: Sequence[float]) -> str:
|
|
54
|
+
"""Format a numeric sequence into a pgvector string representation: '[0.1,0.2,...]'."""
|
|
55
|
+
return f"[{','.join(str(float(x)) for x in vector)}]"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _bounded_pg_identifier(full_name: str, compact_prefix: str, suffix: str, hash_source: str) -> str:
|
|
59
|
+
"""Keep PostgreSQL identifier within MAX_IDENTIFIER_LENGTH (63B) deterministically."""
|
|
60
|
+
if len(full_name.encode("utf-8")) <= MAX_IDENTIFIER_LENGTH:
|
|
61
|
+
validate_identifier(full_name)
|
|
62
|
+
return full_name
|
|
63
|
+
digest = hashlib.sha256(hash_source.encode("utf-8")).hexdigest()[:10]
|
|
64
|
+
safe_prefix = "".join(ch if (ch.isalnum() or ch == "_") else "_" for ch in compact_prefix)
|
|
65
|
+
head = "swiftpy_v_"
|
|
66
|
+
tail = f"_{digest}{suffix}"
|
|
67
|
+
max_body = MAX_IDENTIFIER_LENGTH - len(head) - len(tail)
|
|
68
|
+
candidate = f"{head}{safe_prefix[:max(0, max_body)]}{tail}"
|
|
69
|
+
validate_identifier(candidate)
|
|
70
|
+
return candidate
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class PgVectorAdapter(VectorStore):
|
|
74
|
+
"""
|
|
75
|
+
PostgreSQL pgvector adapter for SwiftPY.
|
|
76
|
+
|
|
77
|
+
Supports pgvector extension, HNSW indexing across declared distance metrics
|
|
78
|
+
(cosine, l2, inner_product), JSONB metadata containment queries, and seamless
|
|
79
|
+
participation in active database transactions.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
collection_name: str = "default",
|
|
85
|
+
dimensions: int = 1536,
|
|
86
|
+
metric: str = "cosine",
|
|
87
|
+
pool: DatabasePool | asyncpg.Pool | None = None,
|
|
88
|
+
config: Config | None = None,
|
|
89
|
+
http_pool: AIHttpPool | None = None,
|
|
90
|
+
) -> None:
|
|
91
|
+
validate_collection_name(collection_name)
|
|
92
|
+
if isinstance(dimensions, bool) or not isinstance(dimensions, int) or dimensions <= 0:
|
|
93
|
+
raise ValueError(f"Dimensions must be a positive integer, got {dimensions}")
|
|
94
|
+
norm_metric = metric.lower().strip()
|
|
95
|
+
if norm_metric not in METRIC_CONFIG:
|
|
96
|
+
raise ValueError(f"Unsupported metric '{metric}'. Expected one of {sorted(METRIC_CONFIG.keys())}")
|
|
97
|
+
|
|
98
|
+
self._collection_name = collection_name
|
|
99
|
+
self._dimensions = dimensions
|
|
100
|
+
self._metric = norm_metric
|
|
101
|
+
self._pool = pool
|
|
102
|
+
self._config = config
|
|
103
|
+
self._http_pool = http_pool
|
|
104
|
+
self._owned_db_pool: DatabasePool | None = None
|
|
105
|
+
self._initialized = False
|
|
106
|
+
self._lock = asyncio.Lock()
|
|
107
|
+
self._pool_lock = asyncio.Lock()
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def metric(self) -> str:
|
|
111
|
+
return self._metric
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def dimensions(self) -> int:
|
|
115
|
+
return self._dimensions
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def capabilities(self) -> VectorStoreCapabilities:
|
|
119
|
+
"""Return the declared capabilities supported by PgVectorAdapter."""
|
|
120
|
+
return VectorStoreCapabilities(
|
|
121
|
+
metadata_filtering=True,
|
|
122
|
+
nested_metadata_filtering=True,
|
|
123
|
+
namespaces=False,
|
|
124
|
+
pagination=False,
|
|
125
|
+
batch_upsert=True,
|
|
126
|
+
native_distance_metrics=("cosine", "l2", "inner_product"),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def _get_table_name(self, name: str | None = None) -> str:
|
|
130
|
+
coll = self._collection_name if name is None else validate_collection_name(name)
|
|
131
|
+
table = f"swiftpy_vectors_{coll}"
|
|
132
|
+
return _bounded_pg_identifier(table, coll, "", coll)
|
|
133
|
+
|
|
134
|
+
def _get_index_name(self, name: str | None = None, metric: str | None = None) -> str:
|
|
135
|
+
coll = self._collection_name if name is None else validate_collection_name(name)
|
|
136
|
+
active_metric = metric or self._metric
|
|
137
|
+
idx = f"swiftpy_vectors_{coll}_{active_metric}_hnsw_idx"
|
|
138
|
+
return _bounded_pg_identifier(idx, coll, f"_{active_metric}_idx", f"{coll}:{active_metric}")
|
|
139
|
+
|
|
140
|
+
async def aclose(self) -> None:
|
|
141
|
+
"""Close any internally created DatabasePool owned by this adapter instance."""
|
|
142
|
+
async with self._pool_lock:
|
|
143
|
+
if self._owned_db_pool is not None:
|
|
144
|
+
await self._owned_db_pool.close()
|
|
145
|
+
self._owned_db_pool = None
|
|
146
|
+
|
|
147
|
+
@asynccontextmanager
|
|
148
|
+
async def _connection(self) -> AsyncIterator[asyncpg.Connection]:
|
|
149
|
+
active = get_active_connection()
|
|
150
|
+
if active is not None:
|
|
151
|
+
yield active
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
pool: asyncpg.Pool
|
|
155
|
+
if self._pool is not None:
|
|
156
|
+
if isinstance(self._pool, DatabasePool):
|
|
157
|
+
try:
|
|
158
|
+
pool = self._pool.get_pool()
|
|
159
|
+
except RuntimeError:
|
|
160
|
+
pool = await self._pool.init()
|
|
161
|
+
else:
|
|
162
|
+
pool = self._pool
|
|
163
|
+
else:
|
|
164
|
+
container = get_container()
|
|
165
|
+
if container.has(DatabasePool):
|
|
166
|
+
db_pool = container.resolve(DatabasePool)
|
|
167
|
+
try:
|
|
168
|
+
pool = db_pool.get_pool()
|
|
169
|
+
except RuntimeError:
|
|
170
|
+
pool = await db_pool.init()
|
|
171
|
+
else:
|
|
172
|
+
async with self._pool_lock:
|
|
173
|
+
if self._owned_db_pool is None:
|
|
174
|
+
cfg = self._config
|
|
175
|
+
if cfg is None:
|
|
176
|
+
if container.has(Config):
|
|
177
|
+
cfg = container.resolve(Config)
|
|
178
|
+
else:
|
|
179
|
+
try:
|
|
180
|
+
cfg = Config.get_instance()
|
|
181
|
+
except RuntimeError:
|
|
182
|
+
cfg = Config()
|
|
183
|
+
self._owned_db_pool = DatabasePool(cfg)
|
|
184
|
+
pool = await self._owned_db_pool.init()
|
|
185
|
+
else:
|
|
186
|
+
try:
|
|
187
|
+
pool = self._owned_db_pool.get_pool()
|
|
188
|
+
except RuntimeError:
|
|
189
|
+
pool = await self._owned_db_pool.init()
|
|
190
|
+
|
|
191
|
+
conn = await pool.acquire()
|
|
192
|
+
try:
|
|
193
|
+
yield conn
|
|
194
|
+
finally:
|
|
195
|
+
await pool.release(conn)
|
|
196
|
+
|
|
197
|
+
async def create_collection(
|
|
198
|
+
self,
|
|
199
|
+
name: str,
|
|
200
|
+
dimensions: int,
|
|
201
|
+
metric: str = "cosine",
|
|
202
|
+
) -> None:
|
|
203
|
+
"""Create or initialize the collection table with pgvector extension and HNSW index."""
|
|
204
|
+
validate_collection_name(name)
|
|
205
|
+
if isinstance(dimensions, bool) or not isinstance(dimensions, int) or dimensions <= 0:
|
|
206
|
+
raise ValueError(f"Dimensions must be a positive integer, got {dimensions}")
|
|
207
|
+
norm_metric = metric.lower().strip()
|
|
208
|
+
if norm_metric not in METRIC_CONFIG:
|
|
209
|
+
raise ValueError(f"Unsupported metric '{metric}'. Expected one of {sorted(METRIC_CONFIG.keys())}")
|
|
210
|
+
|
|
211
|
+
table = self._get_table_name(name)
|
|
212
|
+
quoted_table = quote_identifier(table)
|
|
213
|
+
idx_name = self._get_index_name(name, metric=norm_metric)
|
|
214
|
+
quoted_idx = quote_identifier(idx_name)
|
|
215
|
+
opclass = METRIC_CONFIG[norm_metric]["opclass"]
|
|
216
|
+
|
|
217
|
+
async with self._connection() as conn:
|
|
218
|
+
# 1. Ensure pgvector extension exists
|
|
219
|
+
await conn.execute("CREATE EXTENSION IF NOT EXISTS vector;")
|
|
220
|
+
|
|
221
|
+
# 2. Check for dimension mismatch on pre-existing collection in active schema
|
|
222
|
+
dim_sql = """
|
|
223
|
+
SELECT pg_attribute.atttypmod
|
|
224
|
+
FROM pg_attribute
|
|
225
|
+
JOIN pg_class ON pg_class.oid = pg_attribute.attrelid
|
|
226
|
+
JOIN pg_namespace ON pg_namespace.oid = pg_class.relnamespace
|
|
227
|
+
WHERE pg_class.relname = $1
|
|
228
|
+
AND pg_namespace.nspname = current_schema()
|
|
229
|
+
AND pg_attribute.attname = 'embedding';
|
|
230
|
+
"""
|
|
231
|
+
existing_dim = await conn.fetchval(dim_sql, table)
|
|
232
|
+
if existing_dim is not None and existing_dim > 0 and existing_dim != dimensions:
|
|
233
|
+
raise ValueError(
|
|
234
|
+
f"Collection table '{table}' already exists with {existing_dim} dimensions, "
|
|
235
|
+
f"cannot reinitialize with {dimensions} dimensions."
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# 3. Create vector storage table
|
|
239
|
+
create_table_sql = f"""
|
|
240
|
+
CREATE TABLE IF NOT EXISTS {quoted_table} (
|
|
241
|
+
id VARCHAR(255) PRIMARY KEY,
|
|
242
|
+
embedding vector({dimensions}) NOT NULL,
|
|
243
|
+
content TEXT NOT NULL DEFAULT '',
|
|
244
|
+
metadata JSONB NOT NULL DEFAULT '{{}}'::jsonb,
|
|
245
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
246
|
+
);
|
|
247
|
+
"""
|
|
248
|
+
await conn.execute(create_table_sql)
|
|
249
|
+
|
|
250
|
+
# 4. Create HNSW index for the chosen distance metric
|
|
251
|
+
create_index_sql = f"""
|
|
252
|
+
CREATE INDEX IF NOT EXISTS {quoted_idx}
|
|
253
|
+
ON {quoted_table} USING hnsw (embedding {opclass});
|
|
254
|
+
"""
|
|
255
|
+
await conn.execute(create_index_sql)
|
|
256
|
+
|
|
257
|
+
self._collection_name = name
|
|
258
|
+
self._dimensions = dimensions
|
|
259
|
+
self._metric = norm_metric
|
|
260
|
+
self._initialized = True
|
|
261
|
+
|
|
262
|
+
async def _ensure_collection(self) -> None:
|
|
263
|
+
if self._initialized:
|
|
264
|
+
return
|
|
265
|
+
async with self._lock:
|
|
266
|
+
if self._initialized:
|
|
267
|
+
return
|
|
268
|
+
await self.create_collection(self._collection_name, self._dimensions, self._metric)
|
|
269
|
+
self._initialized = True
|
|
270
|
+
|
|
271
|
+
async def upsert(
|
|
272
|
+
self,
|
|
273
|
+
id: str,
|
|
274
|
+
vector: list[float],
|
|
275
|
+
metadata: dict[str, Any] | None = None,
|
|
276
|
+
content: str = "",
|
|
277
|
+
) -> None:
|
|
278
|
+
"""Insert or update a single vector record."""
|
|
279
|
+
rec_id = validate_record_id(id)
|
|
280
|
+
parsed_vec = validate_vector(vector, self._dimensions, self._metric)
|
|
281
|
+
await self._ensure_collection()
|
|
282
|
+
table = quote_identifier(self._get_table_name())
|
|
283
|
+
|
|
284
|
+
sql = f"""
|
|
285
|
+
INSERT INTO {table} (id, embedding, content, metadata)
|
|
286
|
+
VALUES ($1, $2::vector, $3, $4::jsonb)
|
|
287
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
288
|
+
embedding = EXCLUDED.embedding,
|
|
289
|
+
content = EXCLUDED.content,
|
|
290
|
+
metadata = EXCLUDED.metadata;
|
|
291
|
+
"""
|
|
292
|
+
|
|
293
|
+
meta_json = json.dumps(metadata or {})
|
|
294
|
+
vec_str = _format_vector(parsed_vec)
|
|
295
|
+
|
|
296
|
+
async with self._connection() as conn:
|
|
297
|
+
await conn.execute(sql, rec_id, vec_str, content, meta_json)
|
|
298
|
+
|
|
299
|
+
async def upsert_batch(
|
|
300
|
+
self,
|
|
301
|
+
records: Sequence[
|
|
302
|
+
VectorRecord
|
|
303
|
+
| dict[str, Any]
|
|
304
|
+
| tuple[str, list[float], dict[str, Any] | None]
|
|
305
|
+
| tuple[str, list[float], dict[str, Any] | None, str]
|
|
306
|
+
],
|
|
307
|
+
) -> None:
|
|
308
|
+
"""Insert or update multiple vector records atomically in batch."""
|
|
309
|
+
if not records:
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
formatted_rows: list[tuple[str, str, str, str]] = []
|
|
313
|
+
for rec in records:
|
|
314
|
+
norm_rec = normalize_record(rec, self._dimensions, self._metric)
|
|
315
|
+
formatted_rows.append(
|
|
316
|
+
(
|
|
317
|
+
norm_rec.id,
|
|
318
|
+
_format_vector(norm_rec.vector),
|
|
319
|
+
norm_rec.content,
|
|
320
|
+
json.dumps(norm_rec.metadata),
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
await self._ensure_collection()
|
|
325
|
+
table = quote_identifier(self._get_table_name())
|
|
326
|
+
|
|
327
|
+
sql = f"""
|
|
328
|
+
INSERT INTO {table} (id, embedding, content, metadata)
|
|
329
|
+
VALUES ($1, $2::vector, $3, $4::jsonb)
|
|
330
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
331
|
+
embedding = EXCLUDED.embedding,
|
|
332
|
+
content = EXCLUDED.content,
|
|
333
|
+
metadata = EXCLUDED.metadata;
|
|
334
|
+
"""
|
|
335
|
+
|
|
336
|
+
async with self._connection() as conn:
|
|
337
|
+
# Execute batch within an explicit transaction boundary for atomicity
|
|
338
|
+
if hasattr(conn, "transaction"):
|
|
339
|
+
async with conn.transaction():
|
|
340
|
+
await conn.executemany(sql, formatted_rows)
|
|
341
|
+
else:
|
|
342
|
+
await conn.executemany(sql, formatted_rows)
|
|
343
|
+
|
|
344
|
+
async def search(
|
|
345
|
+
self,
|
|
346
|
+
vector: list[float],
|
|
347
|
+
limit: int = 10,
|
|
348
|
+
filter: dict[str, Any] | None = None,
|
|
349
|
+
) -> list[SearchResult]:
|
|
350
|
+
"""Search nearest vectors using pgvector distance operator and HNSW indexing."""
|
|
351
|
+
if limit <= 0:
|
|
352
|
+
raise ValueError(f"Limit must be a positive integer, got {limit}")
|
|
353
|
+
|
|
354
|
+
parsed_vec = validate_vector(vector, self._dimensions, self._metric)
|
|
355
|
+
await self._ensure_collection()
|
|
356
|
+
|
|
357
|
+
table = quote_identifier(self._get_table_name())
|
|
358
|
+
vec_str = _format_vector(parsed_vec)
|
|
359
|
+
metric_cfg = METRIC_CONFIG[self._metric]
|
|
360
|
+
op = metric_cfg["operator"]
|
|
361
|
+
score_sql = metric_cfg["score_sql"]
|
|
362
|
+
|
|
363
|
+
if filter:
|
|
364
|
+
sql = f"""
|
|
365
|
+
SELECT id, content, metadata, {score_sql} AS score
|
|
366
|
+
FROM {table}
|
|
367
|
+
WHERE metadata @> $2::jsonb
|
|
368
|
+
ORDER BY embedding {op} $1::vector
|
|
369
|
+
LIMIT $3;
|
|
370
|
+
"""
|
|
371
|
+
args: tuple[Any, ...] = (vec_str, json.dumps(filter), limit)
|
|
372
|
+
else:
|
|
373
|
+
sql = f"""
|
|
374
|
+
SELECT id, content, metadata, {score_sql} AS score
|
|
375
|
+
FROM {table}
|
|
376
|
+
ORDER BY embedding {op} $1::vector
|
|
377
|
+
LIMIT $2;
|
|
378
|
+
"""
|
|
379
|
+
args = (vec_str, limit)
|
|
380
|
+
|
|
381
|
+
async with self._connection() as conn:
|
|
382
|
+
rows = await conn.fetch(sql, *args)
|
|
383
|
+
|
|
384
|
+
results: list[SearchResult] = []
|
|
385
|
+
for row in rows:
|
|
386
|
+
raw_meta = row["metadata"]
|
|
387
|
+
if isinstance(raw_meta, str):
|
|
388
|
+
try:
|
|
389
|
+
meta = json.loads(raw_meta)
|
|
390
|
+
except (ValueError, TypeError):
|
|
391
|
+
meta = {}
|
|
392
|
+
elif isinstance(raw_meta, dict):
|
|
393
|
+
meta = dict(raw_meta)
|
|
394
|
+
else:
|
|
395
|
+
meta = {}
|
|
396
|
+
|
|
397
|
+
results.append(
|
|
398
|
+
SearchResult(
|
|
399
|
+
id=row["id"],
|
|
400
|
+
score=float(row["score"]),
|
|
401
|
+
metadata=meta,
|
|
402
|
+
content=row["content"],
|
|
403
|
+
)
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
return results
|
|
407
|
+
|
|
408
|
+
async def delete(self, id: str) -> bool:
|
|
409
|
+
"""Delete a vector record by id."""
|
|
410
|
+
await self._ensure_collection()
|
|
411
|
+
table = quote_identifier(self._get_table_name())
|
|
412
|
+
sql = f"DELETE FROM {table} WHERE id = $1;"
|
|
413
|
+
|
|
414
|
+
async with self._connection() as conn:
|
|
415
|
+
result = await conn.execute(sql, id)
|
|
416
|
+
|
|
417
|
+
return bool(str(result).strip() != "DELETE 0")
|
|
418
|
+
|
|
419
|
+
async def delete_batch(self, ids: Sequence[str]) -> int:
|
|
420
|
+
"""Delete multiple deduplicated vector records by ids."""
|
|
421
|
+
unique_ids = dedupe_preserve_order(ids)
|
|
422
|
+
if not unique_ids:
|
|
423
|
+
return 0
|
|
424
|
+
|
|
425
|
+
await self._ensure_collection()
|
|
426
|
+
table = quote_identifier(self._get_table_name())
|
|
427
|
+
sql = f"DELETE FROM {table} WHERE id = ANY($1::varchar[]);"
|
|
428
|
+
|
|
429
|
+
async with self._connection() as conn:
|
|
430
|
+
result = await conn.execute(sql, unique_ids)
|
|
431
|
+
|
|
432
|
+
try:
|
|
433
|
+
return int(str(result).strip().split()[-1])
|
|
434
|
+
except (ValueError, IndexError):
|
|
435
|
+
return 0
|
|
436
|
+
|
|
437
|
+
async def get(self, id: str) -> SearchResult | None:
|
|
438
|
+
"""Retrieve a single vector record by id."""
|
|
439
|
+
await self._ensure_collection()
|
|
440
|
+
table = quote_identifier(self._get_table_name())
|
|
441
|
+
sql = f"SELECT id, content, metadata, embedding::text AS embedding FROM {table} WHERE id = $1;"
|
|
442
|
+
|
|
443
|
+
async with self._connection() as conn:
|
|
444
|
+
row = await conn.fetchrow(sql, id)
|
|
445
|
+
|
|
446
|
+
if row is None:
|
|
447
|
+
return None
|
|
448
|
+
|
|
449
|
+
raw_meta = row["metadata"]
|
|
450
|
+
if isinstance(raw_meta, str):
|
|
451
|
+
try:
|
|
452
|
+
meta = json.loads(raw_meta)
|
|
453
|
+
except (ValueError, TypeError):
|
|
454
|
+
meta = {}
|
|
455
|
+
elif isinstance(raw_meta, dict):
|
|
456
|
+
meta = dict(raw_meta)
|
|
457
|
+
else:
|
|
458
|
+
meta = {}
|
|
459
|
+
|
|
460
|
+
vec: list[float] | None = None
|
|
461
|
+
if row.get("embedding"):
|
|
462
|
+
try:
|
|
463
|
+
raw_s = str(row["embedding"]).strip("[] ")
|
|
464
|
+
vec = [float(x.strip()) for x in raw_s.split(",") if x.strip()]
|
|
465
|
+
except (ValueError, TypeError):
|
|
466
|
+
vec = None
|
|
467
|
+
|
|
468
|
+
return SearchResult(
|
|
469
|
+
id=row["id"],
|
|
470
|
+
score=1.0,
|
|
471
|
+
metadata=meta,
|
|
472
|
+
content=row["content"],
|
|
473
|
+
vector=vec,
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
async def count(self) -> int:
|
|
477
|
+
"""Return the count of vector records in the active collection table."""
|
|
478
|
+
await self._ensure_collection()
|
|
479
|
+
table = quote_identifier(self._get_table_name())
|
|
480
|
+
sql = f"SELECT COUNT(*) FROM {table};"
|
|
481
|
+
|
|
482
|
+
async with self._connection() as conn:
|
|
483
|
+
val = await conn.fetchval(sql)
|
|
484
|
+
|
|
485
|
+
return int(val) if val is not None else 0
|
|
486
|
+
|
|
487
|
+
async def drop_collection(self, name: str) -> None:
|
|
488
|
+
"""Drop a collection table and its associated index."""
|
|
489
|
+
table = quote_identifier(self._get_table_name(name))
|
|
490
|
+
sql = f"DROP TABLE IF EXISTS {table} CASCADE;"
|
|
491
|
+
|
|
492
|
+
async with self._connection() as conn:
|
|
493
|
+
await conn.execute(sql)
|
|
494
|
+
|
|
495
|
+
if name == self._collection_name:
|
|
496
|
+
self._initialized = False
|