sf-vector-sdk 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.
@@ -0,0 +1,258 @@
1
+ """
2
+ Search namespace for vector similarity search operations.
3
+ """
4
+
5
+ import json
6
+ import uuid
7
+ from datetime import datetime
8
+ from typing import Optional
9
+
10
+ from vector_sdk.namespaces.base import BaseNamespace
11
+ from vector_sdk.types import (
12
+ EmbeddingConfigOverride,
13
+ QueryConfig,
14
+ QueryRequest,
15
+ QueryResult,
16
+ get_query_stream_for_priority,
17
+ validate_model,
18
+ )
19
+
20
+
21
+ class SearchNamespace(BaseNamespace):
22
+ """
23
+ Namespace for vector search operations.
24
+
25
+ Example:
26
+ ```python
27
+ client = VectorClient("redis://localhost:6379")
28
+
29
+ # Search for similar vectors
30
+ result = client.search.query_and_wait(
31
+ query_text="What is machine learning?",
32
+ database="turbopuffer",
33
+ namespace="topic_vectors",
34
+ top_k=10,
35
+ )
36
+
37
+ for match in result.matches:
38
+ print(f"{match.id}: {match.score}")
39
+ ```
40
+ """
41
+
42
+ def query(
43
+ self,
44
+ query_text: str,
45
+ database: str,
46
+ top_k: int = 10,
47
+ min_score: Optional[float] = None,
48
+ filters: Optional[dict[str, str]] = None,
49
+ namespace: Optional[str] = None,
50
+ collection: Optional[str] = None,
51
+ database_name: Optional[str] = None,
52
+ include_vectors: bool = False,
53
+ include_metadata: bool = True,
54
+ embedding_model: Optional[str] = None,
55
+ embedding_dimensions: Optional[int] = None,
56
+ priority: str = "normal",
57
+ metadata: Optional[dict[str, str]] = None,
58
+ request_id: Optional[str] = None,
59
+ ) -> str:
60
+ """
61
+ Submit a vector search query.
62
+
63
+ This method embeds the query text and searches for similar vectors in
64
+ the specified database. Returns a request ID - use `wait_for()` to get
65
+ the results, or use `query_and_wait()` for a combined operation.
66
+
67
+ Args:
68
+ query_text: The text to search for (will be embedded)
69
+ database: Which vector database to search ("mongodb", "turbopuffer", "pinecone")
70
+ top_k: Number of results to return (default: 10)
71
+ min_score: Minimum similarity score threshold (0.0 to 1.0)
72
+ filters: Metadata filters for filtering results
73
+ namespace: Namespace for Pinecone/TurboPuffer
74
+ collection: Collection name for MongoDB
75
+ database_name: Database name for MongoDB
76
+ include_vectors: Whether to include vector values in response
77
+ include_metadata: Whether to include metadata in response
78
+ embedding_model: Optional embedding model override
79
+ embedding_dimensions: Optional embedding dimensions override
80
+ priority: Queue priority (default: "normal")
81
+ metadata: Optional key-value pairs for tracking
82
+ request_id: Optional custom request ID
83
+
84
+ Returns:
85
+ The request ID for tracking the query
86
+
87
+ Raises:
88
+ ValueError: If query_text is empty
89
+ ModelValidationError: If embedding model is not supported
90
+ """
91
+ if not query_text or query_text.strip() == "":
92
+ raise ValueError("query_text cannot be empty")
93
+
94
+ # Validate embedding model if specified
95
+ if embedding_model:
96
+ validate_model(embedding_model, embedding_dimensions)
97
+
98
+ if request_id is None:
99
+ request_id = str(uuid.uuid4())
100
+
101
+ query_config = QueryConfig(
102
+ top_k=top_k,
103
+ min_score=min_score,
104
+ filters=filters,
105
+ namespace=namespace,
106
+ collection=collection,
107
+ database=database_name,
108
+ include_vectors=include_vectors,
109
+ include_metadata=include_metadata,
110
+ )
111
+
112
+ embedding_config = None
113
+ if embedding_model or embedding_dimensions:
114
+ embedding_config = EmbeddingConfigOverride(
115
+ model=embedding_model,
116
+ dimensions=embedding_dimensions,
117
+ )
118
+
119
+ request = QueryRequest(
120
+ request_id=request_id,
121
+ query_text=query_text,
122
+ database=database,
123
+ priority=priority,
124
+ query_config=query_config,
125
+ embedding_config=embedding_config,
126
+ metadata=metadata or {},
127
+ created_at=datetime.utcnow(),
128
+ )
129
+
130
+ stream = get_query_stream_for_priority(priority)
131
+ payload = json.dumps(request.to_dict())
132
+ self._redis.xadd(stream, {"data": payload})
133
+
134
+ return request_id
135
+
136
+ def wait_for(
137
+ self,
138
+ request_id: str,
139
+ timeout: int = 30,
140
+ ) -> QueryResult:
141
+ """
142
+ Wait for a search query to complete.
143
+
144
+ Args:
145
+ request_id: The request ID to wait for
146
+ timeout: Maximum time to wait in seconds (default: 30)
147
+
148
+ Returns:
149
+ The query result
150
+
151
+ Raises:
152
+ TimeoutError: If no result is received within the timeout
153
+ """
154
+ channel = f"query:result:{request_id}"
155
+ pubsub = self._redis.pubsub()
156
+ pubsub.subscribe(channel)
157
+
158
+ try:
159
+ start_time = datetime.utcnow()
160
+ while True:
161
+ message = pubsub.get_message(timeout=1.0)
162
+ if message and message["type"] == "message":
163
+ data = json.loads(message["data"])
164
+ return QueryResult.from_dict(data)
165
+
166
+ elapsed = (datetime.utcnow() - start_time).total_seconds()
167
+ if elapsed >= timeout:
168
+ raise TimeoutError(
169
+ f"No query result received for {request_id} within {timeout}s"
170
+ )
171
+ finally:
172
+ pubsub.unsubscribe(channel)
173
+ pubsub.close()
174
+
175
+ def query_and_wait(
176
+ self,
177
+ query_text: str,
178
+ database: str,
179
+ top_k: int = 10,
180
+ min_score: Optional[float] = None,
181
+ filters: Optional[dict[str, str]] = None,
182
+ namespace: Optional[str] = None,
183
+ collection: Optional[str] = None,
184
+ database_name: Optional[str] = None,
185
+ include_vectors: bool = False,
186
+ include_metadata: bool = True,
187
+ embedding_model: Optional[str] = None,
188
+ embedding_dimensions: Optional[int] = None,
189
+ priority: str = "normal",
190
+ metadata: Optional[dict[str, str]] = None,
191
+ timeout: int = 30,
192
+ ) -> QueryResult:
193
+ """
194
+ Submit a search query and wait for the result.
195
+
196
+ This method subscribes to the result channel BEFORE submitting the request,
197
+ ensuring no race condition where the result is published before we're listening.
198
+
199
+ Args:
200
+ query_text: The text to search for
201
+ database: Which vector database to search
202
+ top_k: Number of results to return
203
+ min_score: Minimum similarity score threshold
204
+ filters: Metadata filters
205
+ namespace: Namespace for Pinecone/TurboPuffer
206
+ collection: Collection name for MongoDB
207
+ database_name: Database name for MongoDB
208
+ include_vectors: Include vectors in response
209
+ include_metadata: Include metadata in response
210
+ embedding_model: Optional embedding model override
211
+ embedding_dimensions: Optional embedding dimensions override
212
+ priority: Queue priority
213
+ metadata: Optional metadata for tracking
214
+ timeout: Maximum time to wait in seconds
215
+
216
+ Returns:
217
+ The query result
218
+ """
219
+ request_id = str(uuid.uuid4())
220
+ channel = f"query:result:{request_id}"
221
+
222
+ pubsub = self._redis.pubsub()
223
+ pubsub.subscribe(channel)
224
+
225
+ try:
226
+ self.query(
227
+ query_text=query_text,
228
+ database=database,
229
+ top_k=top_k,
230
+ min_score=min_score,
231
+ filters=filters,
232
+ namespace=namespace,
233
+ collection=collection,
234
+ database_name=database_name,
235
+ include_vectors=include_vectors,
236
+ include_metadata=include_metadata,
237
+ embedding_model=embedding_model,
238
+ embedding_dimensions=embedding_dimensions,
239
+ priority=priority,
240
+ metadata=metadata,
241
+ request_id=request_id,
242
+ )
243
+
244
+ start_time = datetime.utcnow()
245
+ while True:
246
+ message = pubsub.get_message(timeout=1.0)
247
+ if message and message["type"] == "message":
248
+ data = json.loads(message["data"])
249
+ return QueryResult.from_dict(data)
250
+
251
+ elapsed = (datetime.utcnow() - start_time).total_seconds()
252
+ if elapsed >= timeout:
253
+ raise TimeoutError(
254
+ f"No query result received for {request_id} within {timeout}s"
255
+ )
256
+ finally:
257
+ pubsub.unsubscribe(channel)
258
+ pubsub.close()
@@ -0,0 +1,60 @@
1
+ """
2
+ Structured Embeddings Module.
3
+
4
+ Provides type-safe methods for embedding known tool types (FlashCard, TestQuestion, etc.)
5
+ with automatic text extraction, content hash computation, and database routing.
6
+ """
7
+
8
+ from .router import (
9
+ DatabaseRoutingError,
10
+ DatabaseRoutingMode,
11
+ build_storage_config,
12
+ get_content_type,
13
+ get_database_routing_mode,
14
+ validate_database_routing,
15
+ )
16
+ from .structured_embeddings import (
17
+ StructuredEmbeddingsNamespace,
18
+ TestQuestionInput,
19
+ ToolMetadata,
20
+ )
21
+ from .tool_config import (
22
+ TOOL_CONFIGS,
23
+ PineconeToolConfig,
24
+ QuestionType,
25
+ ToolConfig,
26
+ ToolDatabaseConfig,
27
+ TurboPufferToolConfig,
28
+ get_flashcard_namespace_suffix,
29
+ get_pinecone_namespace,
30
+ get_question_namespace_suffix,
31
+ get_tool_config,
32
+ get_turbopuffer_namespace,
33
+ )
34
+
35
+ __all__ = [
36
+ # Namespace class
37
+ "StructuredEmbeddingsNamespace",
38
+ # Types
39
+ "ToolMetadata",
40
+ "TestQuestionInput",
41
+ # Tool configuration
42
+ "ToolConfig",
43
+ "ToolDatabaseConfig",
44
+ "TurboPufferToolConfig",
45
+ "PineconeToolConfig",
46
+ "QuestionType",
47
+ "TOOL_CONFIGS",
48
+ "get_tool_config",
49
+ "get_flashcard_namespace_suffix",
50
+ "get_question_namespace_suffix",
51
+ "get_turbopuffer_namespace",
52
+ "get_pinecone_namespace",
53
+ # Database router
54
+ "DatabaseRoutingMode",
55
+ "DatabaseRoutingError",
56
+ "get_database_routing_mode",
57
+ "validate_database_routing",
58
+ "build_storage_config",
59
+ "get_content_type",
60
+ ]
@@ -0,0 +1,190 @@
1
+ """
2
+ Database Router for Structured Embeddings.
3
+
4
+ Routes embedding writes to the appropriate databases based on:
5
+ - STRUCTURED_EMBEDDING_DATABASE_ROUTER environment variable
6
+ - Per-tool enabled/disabled configuration
7
+ """
8
+
9
+ import os
10
+ from typing import Any, Literal, Optional
11
+
12
+ from ..hash import ToolCollection
13
+ from ..types import (
14
+ PineconeStorageConfig,
15
+ StorageConfig,
16
+ TurboPufferStorage,
17
+ )
18
+ from .tool_config import (
19
+ get_pinecone_namespace,
20
+ get_tool_config,
21
+ get_turbopuffer_namespace,
22
+ )
23
+
24
+ # ============================================================================
25
+ # Types
26
+ # ============================================================================
27
+
28
+ DatabaseRoutingMode = Literal["dual", "turbopuffer", "pinecone"]
29
+
30
+
31
+ class DatabaseRoutingError(Exception):
32
+ """Error thrown when database routing fails."""
33
+
34
+ pass
35
+
36
+
37
+ # ============================================================================
38
+ # Environment Variable
39
+ # ============================================================================
40
+
41
+ ENV_VAR_NAME = "STRUCTURED_EMBEDDING_DATABASE_ROUTER"
42
+
43
+
44
+ def get_database_routing_mode() -> DatabaseRoutingMode:
45
+ """
46
+ Get the database routing mode from environment variable.
47
+ Defaults to 'turbopuffer' if not set.
48
+ """
49
+ env_value = os.environ.get(ENV_VAR_NAME)
50
+
51
+ if not env_value:
52
+ return "turbopuffer"
53
+
54
+ mode = env_value.lower()
55
+
56
+ if mode not in ("dual", "turbopuffer", "pinecone"):
57
+ raise DatabaseRoutingError(
58
+ f"Invalid {ENV_VAR_NAME} value: '{env_value}'. "
59
+ "Must be 'dual', 'turbopuffer', or 'pinecone'."
60
+ )
61
+
62
+ return mode # type: ignore
63
+
64
+
65
+ # ============================================================================
66
+ # Validation
67
+ # ============================================================================
68
+
69
+
70
+ def validate_database_routing(
71
+ tool_collection: ToolCollection,
72
+ mode: DatabaseRoutingMode,
73
+ ) -> None:
74
+ """
75
+ Validate that the required databases are enabled for the routing mode.
76
+
77
+ Args:
78
+ tool_collection: The tool collection type
79
+ mode: The database routing mode
80
+
81
+ Raises:
82
+ DatabaseRoutingError: If validation fails
83
+ """
84
+ config = get_tool_config(tool_collection)
85
+
86
+ if mode == "turbopuffer":
87
+ if not config.turbopuffer.enabled:
88
+ raise DatabaseRoutingError(
89
+ f"Database routing mode 'turbopuffer' requested but "
90
+ f"turbopuffer.enabled is false for {tool_collection}"
91
+ )
92
+
93
+ elif mode == "pinecone":
94
+ if not config.pinecone.enabled:
95
+ raise DatabaseRoutingError(
96
+ f"Database routing mode 'pinecone' requested but "
97
+ f"pinecone.enabled is false for {tool_collection}"
98
+ )
99
+
100
+ elif mode == "dual":
101
+ # In dual mode, at least one database must be enabled
102
+ if not config.turbopuffer.enabled and not config.pinecone.enabled:
103
+ raise DatabaseRoutingError(
104
+ f"No databases enabled for {tool_collection}. "
105
+ "At least one of turbopuffer or pinecone must be enabled."
106
+ )
107
+
108
+
109
+ # ============================================================================
110
+ # Storage Config Builder
111
+ # ============================================================================
112
+
113
+
114
+ def build_storage_config(
115
+ tool_collection: ToolCollection,
116
+ sub_type: Optional[str],
117
+ content_hash: str,
118
+ document_fields: dict[str, Any],
119
+ ) -> StorageConfig:
120
+ """
121
+ Build the storage configuration for a structured embedding request.
122
+
123
+ This function:
124
+ 1. Gets the routing mode from environment
125
+ 2. Validates that required databases are enabled
126
+ 3. Builds the storage config with appropriate namespaces
127
+
128
+ Args:
129
+ tool_collection: Tool collection type
130
+ sub_type: Sub-type (FlashCardType or QuestionType)
131
+ content_hash: Content hash (used as vector ID)
132
+ document_fields: Additional document fields to store
133
+
134
+ Returns:
135
+ StorageConfig for the embedding request
136
+
137
+ Raises:
138
+ DatabaseRoutingError: If validation fails
139
+ """
140
+ mode = get_database_routing_mode()
141
+ validate_database_routing(tool_collection, mode)
142
+
143
+ config = get_tool_config(tool_collection)
144
+
145
+ # Build TurboPuffer config if enabled and requested
146
+ turbopuffer = None
147
+ include_turbopuffer = config.turbopuffer.enabled and mode in (
148
+ "turbopuffer",
149
+ "dual",
150
+ )
151
+
152
+ if include_turbopuffer:
153
+ namespace = get_turbopuffer_namespace(tool_collection, sub_type)
154
+ turbopuffer = TurboPufferStorage(
155
+ namespace=namespace,
156
+ id_field=config.turbopuffer.id_field,
157
+ metadata=list(config.turbopuffer.metadata_fields),
158
+ )
159
+
160
+ # Build Pinecone config if enabled and requested
161
+ pinecone = None
162
+ include_pinecone = config.pinecone.enabled and mode in ("pinecone", "dual")
163
+
164
+ if include_pinecone:
165
+ namespace = get_pinecone_namespace(tool_collection, sub_type)
166
+ pinecone = PineconeStorageConfig(
167
+ index_name=config.pinecone.index_name,
168
+ namespace=namespace,
169
+ id_field=config.pinecone.id_field,
170
+ metadata=list(config.pinecone.metadata_fields),
171
+ )
172
+
173
+ return StorageConfig(
174
+ turbopuffer=turbopuffer,
175
+ pinecone=pinecone,
176
+ )
177
+
178
+
179
+ def get_content_type(tool_collection: ToolCollection) -> str:
180
+ """
181
+ Get the content type string for the embedding request.
182
+ Maps tool collections to the contentType used in embedding requests.
183
+ """
184
+ mapping: dict[ToolCollection, str] = {
185
+ "FlashCard": "flashcard",
186
+ "TestQuestion": "testquestion",
187
+ "SpacedTestQuestion": "spacedtestquestion",
188
+ "AudioRecapV2Section": "audiorecap",
189
+ }
190
+ return mapping.get(tool_collection, "tool")