vectra-client 0.2.0 → 0.2.1
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +27 -14
- data/README.md +11 -5
- data/Rakefile +1 -1
- data/lib/vectra/configuration.rb +10 -1
- data/lib/vectra/providers/base.rb +10 -0
- data/lib/vectra/providers/qdrant.rb +399 -12
- data/lib/vectra/version.rb +1 -1
- data/lib/vectra.rb +9 -2
- metadata +5 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6c00aee137978894b5722853a5aa6419c76a4613fd07e7b772df83c0ffddcebd
|
|
4
|
+
data.tar.gz: 92f2e588d62520f695d59feaf74d6e1787efc8c1044cf92a8cf94251dcd9ce3a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: df1fa8e851978d7167e55d1df5d8718a01923b533ea10d314c24a570f84c1440dcb41bcad20ab9226206d742f03b8f3deb8aaf34d327f63fe5744345a9827127
|
|
7
|
+
data.tar.gz: 0f10f1ffa2f782f018d94d9c24a2311662ee4256f7c68495d233eb018220a023df5732e2fb541d302624ac543928d957f6ed6adb6bf34efe50e31a1f231c7d54
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.1] - 2025-01-08
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Qdrant Provider** - Full Qdrant vector database support:
|
|
15
|
+
- Vector upsert, query, fetch, update, delete operations
|
|
16
|
+
- Collection management (create, list, describe, delete)
|
|
17
|
+
- Multiple similarity metrics: cosine, euclidean, dot product
|
|
18
|
+
- Namespace support via payload filtering
|
|
19
|
+
- Advanced metadata filtering with Qdrant operators ($eq, $ne, $gt, $gte, $lt, $lte, $in, $nin)
|
|
20
|
+
- Automatic point ID hashing for string IDs
|
|
21
|
+
- Support for both local and cloud Qdrant instances
|
|
22
|
+
- Optional API key authentication for local deployments
|
|
23
|
+
|
|
24
|
+
### Improved
|
|
25
|
+
|
|
26
|
+
- Enhanced error handling with `Faraday::RetriableResponse` support
|
|
27
|
+
- Configuration now allows optional API key for Qdrant and pgvector (local instances)
|
|
28
|
+
- Better retry middleware integration across all providers
|
|
29
|
+
|
|
30
|
+
### Provider Support
|
|
31
|
+
|
|
32
|
+
- ✅ Pinecone - Fully implemented
|
|
33
|
+
- ✅ pgvector (PostgreSQL) - Fully implemented
|
|
34
|
+
- ✅ Qdrant - Fully implemented
|
|
35
|
+
- 🚧 Weaviate - Stub implementation (planned for v0.3.0)
|
|
36
|
+
|
|
10
37
|
## [0.2.0] - 2025-01-08
|
|
11
38
|
|
|
12
39
|
### Added
|
|
@@ -49,13 +76,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
49
76
|
- Updated gemspec description to include pgvector
|
|
50
77
|
- Added `pg` gem as development dependency
|
|
51
78
|
|
|
52
|
-
### Provider Support
|
|
53
|
-
|
|
54
|
-
- ✅ Pinecone - Fully implemented
|
|
55
|
-
- ✅ pgvector (PostgreSQL) - Fully implemented
|
|
56
|
-
- 🚧 Qdrant - Stub implementation (planned for v0.2.0)
|
|
57
|
-
- 🚧 Weaviate - Stub implementation (planned for v0.3.0)
|
|
58
|
-
|
|
59
79
|
## [0.1.0] - 2024-XX-XX
|
|
60
80
|
|
|
61
81
|
### Added
|
|
@@ -89,13 +109,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
89
109
|
|
|
90
110
|
## Planned
|
|
91
111
|
|
|
92
|
-
### [0.2.0]
|
|
93
|
-
|
|
94
|
-
- Qdrant provider implementation
|
|
95
|
-
- Enhanced error messages
|
|
96
|
-
- Connection pooling
|
|
97
|
-
- Improved retry strategies
|
|
98
|
-
|
|
99
112
|
### [0.3.0]
|
|
100
113
|
|
|
101
114
|
- Weaviate provider implementation
|
data/README.md
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
|----------|--------|---------|
|
|
25
25
|
| [Pinecone](https://pinecone.io) | ✅ Fully Supported | v0.1.0 |
|
|
26
26
|
| [PostgreSQL + pgvector](https://github.com/pgvector/pgvector) | ✅ Fully Supported | v0.1.1 |
|
|
27
|
-
| [Qdrant](https://qdrant.tech) |
|
|
27
|
+
| [Qdrant](https://qdrant.tech) | ✅ Fully Supported | v0.2.1 |
|
|
28
28
|
| [Weaviate](https://weaviate.io) | 🚧 Planned | v0.3.0 |
|
|
29
29
|
|
|
30
30
|
## Installation
|
|
@@ -87,6 +87,12 @@ client = Vectra.pgvector(
|
|
|
87
87
|
connection_url: 'postgres://user:password@localhost/mydb'
|
|
88
88
|
)
|
|
89
89
|
|
|
90
|
+
# Shortcut for Qdrant
|
|
91
|
+
client = Vectra.qdrant(
|
|
92
|
+
host: 'http://localhost:6333', # Local Qdrant
|
|
93
|
+
api_key: ENV['QDRANT_API_KEY'] # Optional for local instances
|
|
94
|
+
)
|
|
95
|
+
|
|
90
96
|
# Generic client with options
|
|
91
97
|
client = Vectra::Client.new(
|
|
92
98
|
provider: :pinecone,
|
|
@@ -421,10 +427,10 @@ bundle exec rake docs
|
|
|
421
427
|
- ✅ Namespace support for pgvector
|
|
422
428
|
- ✅ IVFFlat index creation
|
|
423
429
|
|
|
424
|
-
### v0.2.
|
|
425
|
-
-
|
|
426
|
-
-
|
|
427
|
-
-
|
|
430
|
+
### v0.2.1
|
|
431
|
+
- ✅ Qdrant provider (fully implemented)
|
|
432
|
+
- ✅ Enhanced error handling
|
|
433
|
+
- ✅ Improved retry middleware
|
|
428
434
|
|
|
429
435
|
### v0.3.0
|
|
430
436
|
- 🚧 Weaviate provider
|
data/Rakefile
CHANGED
data/lib/vectra/configuration.rb
CHANGED
|
@@ -56,7 +56,11 @@ module Vectra
|
|
|
56
56
|
# @raise [ConfigurationError] if configuration is invalid
|
|
57
57
|
def validate!
|
|
58
58
|
raise ConfigurationError, "Provider must be configured" if provider.nil?
|
|
59
|
-
|
|
59
|
+
|
|
60
|
+
# API key is optional for some providers (Qdrant local, pgvector)
|
|
61
|
+
if !api_key_optional_provider? && (api_key.nil? || api_key.empty?)
|
|
62
|
+
raise ConfigurationError, "API key must be configured"
|
|
63
|
+
end
|
|
60
64
|
|
|
61
65
|
validate_provider_specific!
|
|
62
66
|
end
|
|
@@ -106,6 +110,11 @@ module Vectra
|
|
|
106
110
|
|
|
107
111
|
private
|
|
108
112
|
|
|
113
|
+
# Providers that don't require API key (local instances)
|
|
114
|
+
def api_key_optional_provider?
|
|
115
|
+
%i[qdrant pgvector].include?(provider)
|
|
116
|
+
end
|
|
117
|
+
|
|
109
118
|
def validate_provider_specific!
|
|
110
119
|
case provider
|
|
111
120
|
when :pinecone
|
|
@@ -218,6 +218,16 @@ module Vectra
|
|
|
218
218
|
end
|
|
219
219
|
end
|
|
220
220
|
|
|
221
|
+
# Handle Faraday::RetriableResponse from retry middleware
|
|
222
|
+
# This is raised when all retries are exhausted
|
|
223
|
+
#
|
|
224
|
+
# @param exception [Faraday::RetriableResponse] the exception
|
|
225
|
+
# @raise [Error] appropriate error for the response
|
|
226
|
+
def handle_retriable_response(exception)
|
|
227
|
+
response = exception.response
|
|
228
|
+
handle_error(response)
|
|
229
|
+
end
|
|
230
|
+
|
|
221
231
|
# Extract error message from response body
|
|
222
232
|
#
|
|
223
233
|
# @param body [Hash, String, nil] response body
|
|
@@ -2,47 +2,434 @@
|
|
|
2
2
|
|
|
3
3
|
module Vectra
|
|
4
4
|
module Providers
|
|
5
|
-
# Qdrant vector database provider
|
|
5
|
+
# Qdrant vector database provider
|
|
6
6
|
#
|
|
7
|
-
#
|
|
7
|
+
# Qdrant is an open-source vector similarity search engine with extended filtering support.
|
|
8
8
|
#
|
|
9
|
+
# @example Basic usage
|
|
10
|
+
# Vectra.configure do |config|
|
|
11
|
+
# config.provider = :qdrant
|
|
12
|
+
# config.api_key = ENV['QDRANT_API_KEY']
|
|
13
|
+
# config.host = 'https://your-cluster.qdrant.io'
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# client = Vectra::Client.new
|
|
17
|
+
# client.upsert(index: 'my-collection', vectors: [...])
|
|
18
|
+
#
|
|
19
|
+
# rubocop:disable Metrics/ClassLength
|
|
9
20
|
class Qdrant < Base
|
|
21
|
+
# @see Base#provider_name
|
|
10
22
|
def provider_name
|
|
11
23
|
:qdrant
|
|
12
24
|
end
|
|
13
25
|
|
|
26
|
+
# @see Base#upsert
|
|
14
27
|
def upsert(index:, vectors:, namespace: nil)
|
|
15
|
-
|
|
28
|
+
normalized = normalize_vectors(vectors)
|
|
29
|
+
|
|
30
|
+
points = normalized.map do |vec|
|
|
31
|
+
point = {
|
|
32
|
+
id: generate_point_id(vec[:id]),
|
|
33
|
+
vector: vec[:values]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
payload = vec[:metadata] || {}
|
|
37
|
+
payload["_namespace"] = namespace if namespace
|
|
38
|
+
point[:payload] = payload unless payload.empty?
|
|
39
|
+
|
|
40
|
+
point
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
body = {
|
|
44
|
+
points: points
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
response = with_error_handling { connection.put("/collections/#{index}/points", body) }
|
|
48
|
+
|
|
49
|
+
if response.success?
|
|
50
|
+
log_debug("Upserted #{normalized.size} vectors to #{index}")
|
|
51
|
+
{ upserted_count: normalized.size }
|
|
52
|
+
else
|
|
53
|
+
handle_error(response)
|
|
54
|
+
end
|
|
16
55
|
end
|
|
17
56
|
|
|
57
|
+
# @see Base#query
|
|
18
58
|
def query(index:, vector:, top_k: 10, namespace: nil, filter: nil,
|
|
19
59
|
include_values: false, include_metadata: true)
|
|
20
|
-
|
|
60
|
+
body = {
|
|
61
|
+
vector: vector.map(&:to_f),
|
|
62
|
+
limit: top_k,
|
|
63
|
+
with_vector: include_values,
|
|
64
|
+
with_payload: include_metadata
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# Build filter with namespace if provided
|
|
68
|
+
qdrant_filter = build_filter(filter, namespace)
|
|
69
|
+
body[:filter] = qdrant_filter if qdrant_filter
|
|
70
|
+
|
|
71
|
+
response = with_error_handling { connection.post("/collections/#{index}/points/search", body) }
|
|
72
|
+
|
|
73
|
+
if response.success?
|
|
74
|
+
matches = transform_search_results(response.body["result"] || [])
|
|
75
|
+
log_debug("Query returned #{matches.size} results")
|
|
76
|
+
|
|
77
|
+
QueryResult.from_response(
|
|
78
|
+
matches: matches,
|
|
79
|
+
namespace: namespace
|
|
80
|
+
)
|
|
81
|
+
else
|
|
82
|
+
handle_error(response)
|
|
83
|
+
end
|
|
21
84
|
end
|
|
22
85
|
|
|
23
|
-
|
|
24
|
-
|
|
86
|
+
# @see Base#fetch
|
|
87
|
+
def fetch(index:, ids:, namespace: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
88
|
+
point_ids = ids.map { |id| generate_point_id(id) }
|
|
89
|
+
|
|
90
|
+
body = {
|
|
91
|
+
ids: point_ids,
|
|
92
|
+
with_vector: true,
|
|
93
|
+
with_payload: true
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
response = with_error_handling { connection.post("/collections/#{index}/points", body) }
|
|
97
|
+
|
|
98
|
+
if response.success?
|
|
99
|
+
vectors = {}
|
|
100
|
+
(response.body["result"] || []).each do |point|
|
|
101
|
+
original_id = extract_original_id(point["id"])
|
|
102
|
+
vectors[original_id] = Vector.new(
|
|
103
|
+
id: original_id,
|
|
104
|
+
values: point["vector"],
|
|
105
|
+
metadata: clean_payload(point["payload"])
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
vectors
|
|
109
|
+
else
|
|
110
|
+
handle_error(response)
|
|
111
|
+
end
|
|
25
112
|
end
|
|
26
113
|
|
|
27
|
-
|
|
28
|
-
|
|
114
|
+
# @see Base#update
|
|
115
|
+
def update(index:, id:, metadata: nil, values: nil, namespace: nil)
|
|
116
|
+
point_id = generate_point_id(id)
|
|
117
|
+
|
|
118
|
+
# Update payload (metadata) if provided
|
|
119
|
+
if metadata
|
|
120
|
+
payload = metadata.dup
|
|
121
|
+
payload["_namespace"] = namespace if namespace
|
|
122
|
+
|
|
123
|
+
payload_body = {
|
|
124
|
+
points: [point_id],
|
|
125
|
+
payload: payload
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
response = with_error_handling { connection.post("/collections/#{index}/points/payload", payload_body) }
|
|
129
|
+
handle_error(response) unless response.success?
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Update vector if provided
|
|
133
|
+
if values
|
|
134
|
+
vector_body = {
|
|
135
|
+
points: [
|
|
136
|
+
{
|
|
137
|
+
id: point_id,
|
|
138
|
+
vector: values.map(&:to_f)
|
|
139
|
+
}
|
|
140
|
+
]
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
response = with_error_handling { connection.put("/collections/#{index}/points", vector_body) }
|
|
144
|
+
handle_error(response) unless response.success?
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
log_debug("Updated vector #{id}")
|
|
148
|
+
{ updated: true }
|
|
29
149
|
end
|
|
30
150
|
|
|
151
|
+
# @see Base#delete
|
|
31
152
|
def delete(index:, ids: nil, namespace: nil, filter: nil, delete_all: false)
|
|
32
|
-
|
|
153
|
+
if delete_all
|
|
154
|
+
# Delete all points in collection
|
|
155
|
+
body = { filter: {} }
|
|
156
|
+
elsif ids
|
|
157
|
+
# Delete by IDs
|
|
158
|
+
point_ids = ids.map { |id| generate_point_id(id) }
|
|
159
|
+
body = { points: point_ids }
|
|
160
|
+
elsif filter || namespace
|
|
161
|
+
# Delete by filter
|
|
162
|
+
body = { filter: build_filter(filter, namespace) }
|
|
163
|
+
else
|
|
164
|
+
raise ValidationError, "Must specify ids, filter, or delete_all"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
response = with_error_handling { connection.post("/collections/#{index}/points/delete", body) }
|
|
168
|
+
|
|
169
|
+
if response.success?
|
|
170
|
+
log_debug("Deleted vectors from #{index}")
|
|
171
|
+
{ deleted: true }
|
|
172
|
+
else
|
|
173
|
+
handle_error(response)
|
|
174
|
+
end
|
|
33
175
|
end
|
|
34
176
|
|
|
177
|
+
# @see Base#list_indexes
|
|
35
178
|
def list_indexes
|
|
36
|
-
|
|
179
|
+
response = with_error_handling { connection.get("/collections") }
|
|
180
|
+
|
|
181
|
+
if response.success?
|
|
182
|
+
(response.body["result"]&.dig("collections") || []).map do |col|
|
|
183
|
+
# Get collection info for each
|
|
184
|
+
info = describe_index(index: col["name"])
|
|
185
|
+
info
|
|
186
|
+
rescue StandardError
|
|
187
|
+
{ name: col["name"], status: "unknown" }
|
|
188
|
+
end
|
|
189
|
+
else
|
|
190
|
+
handle_error(response)
|
|
191
|
+
end
|
|
37
192
|
end
|
|
38
193
|
|
|
194
|
+
# @see Base#describe_index
|
|
195
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
39
196
|
def describe_index(index:)
|
|
40
|
-
|
|
197
|
+
response = with_error_handling { connection.get("/collections/#{index}") }
|
|
198
|
+
|
|
199
|
+
if response.success?
|
|
200
|
+
result = response.body["result"]
|
|
201
|
+
config = result["config"]
|
|
202
|
+
params = config&.dig("params") || {}
|
|
203
|
+
vectors_config = params["vectors"] || {}
|
|
204
|
+
|
|
205
|
+
# Handle both named and unnamed vector configs
|
|
206
|
+
dimension = if vectors_config.is_a?(Hash) && vectors_config["size"]
|
|
207
|
+
vectors_config["size"]
|
|
208
|
+
elsif vectors_config.is_a?(Hash)
|
|
209
|
+
vectors_config.values.first&.dig("size")
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
distance = vectors_config["distance"] || vectors_config.values.first&.dig("distance")
|
|
213
|
+
|
|
214
|
+
{
|
|
215
|
+
name: index,
|
|
216
|
+
dimension: dimension,
|
|
217
|
+
metric: distance_to_metric(distance),
|
|
218
|
+
status: result["status"],
|
|
219
|
+
vectors_count: result["vectors_count"],
|
|
220
|
+
points_count: result["points_count"]
|
|
221
|
+
}
|
|
222
|
+
else
|
|
223
|
+
handle_error(response)
|
|
224
|
+
end
|
|
41
225
|
end
|
|
226
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
42
227
|
|
|
228
|
+
# @see Base#stats
|
|
43
229
|
def stats(index:, namespace: nil)
|
|
44
|
-
|
|
230
|
+
info = describe_index(index: index)
|
|
231
|
+
|
|
232
|
+
{
|
|
233
|
+
total_vector_count: info[:points_count] || info[:vectors_count] || 0,
|
|
234
|
+
dimension: info[:dimension],
|
|
235
|
+
status: info[:status],
|
|
236
|
+
namespaces: namespace ? { namespace => { vector_count: 0 } } : {}
|
|
237
|
+
}
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Create a new collection
|
|
241
|
+
#
|
|
242
|
+
# @param name [String] collection name
|
|
243
|
+
# @param dimension [Integer] vector dimension
|
|
244
|
+
# @param metric [String] similarity metric (cosine, euclidean, dot_product)
|
|
245
|
+
# @param on_disk [Boolean] store vectors on disk
|
|
246
|
+
# @return [Hash] created collection info
|
|
247
|
+
def create_index(name:, dimension:, metric: "cosine", on_disk: false)
|
|
248
|
+
body = {
|
|
249
|
+
vectors: {
|
|
250
|
+
size: dimension,
|
|
251
|
+
distance: metric_to_distance(metric),
|
|
252
|
+
on_disk: on_disk
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
response = with_error_handling { connection.put("/collections/#{name}", body) }
|
|
257
|
+
|
|
258
|
+
if response.success?
|
|
259
|
+
log_debug("Created collection #{name}")
|
|
260
|
+
describe_index(index: name)
|
|
261
|
+
else
|
|
262
|
+
handle_error(response)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Delete a collection
|
|
267
|
+
#
|
|
268
|
+
# @param name [String] collection name
|
|
269
|
+
# @return [Hash] deletion result
|
|
270
|
+
def delete_index(name:)
|
|
271
|
+
response = with_error_handling { connection.delete("/collections/#{name}") }
|
|
272
|
+
|
|
273
|
+
if response.success?
|
|
274
|
+
log_debug("Deleted collection #{name}")
|
|
275
|
+
{ deleted: true }
|
|
276
|
+
else
|
|
277
|
+
handle_error(response)
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
private
|
|
282
|
+
|
|
283
|
+
def validate_config!
|
|
284
|
+
super
|
|
285
|
+
raise ConfigurationError, "Host must be configured for Qdrant" if config.host.nil? || config.host.empty?
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def connection
|
|
289
|
+
@connection ||= build_connection(
|
|
290
|
+
config.host,
|
|
291
|
+
auth_headers
|
|
292
|
+
)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Wrap HTTP calls to handle Faraday::RetriableResponse
|
|
296
|
+
def with_error_handling
|
|
297
|
+
yield
|
|
298
|
+
rescue Faraday::RetriableResponse => e
|
|
299
|
+
handle_retriable_response(e)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def auth_headers
|
|
303
|
+
headers = {}
|
|
304
|
+
headers["api-key"] = config.api_key if config.api_key && !config.api_key.empty?
|
|
305
|
+
headers
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Generate a Qdrant point ID from string ID
|
|
309
|
+
# Qdrant supports both integer and UUID point IDs
|
|
310
|
+
# We use a hash to convert arbitrary strings to integers
|
|
311
|
+
def generate_point_id(id)
|
|
312
|
+
# If it's already a valid integer or UUID, use it
|
|
313
|
+
return id.to_i if id.to_s.match?(/^\d+$/)
|
|
314
|
+
return id if uuid?(id)
|
|
315
|
+
|
|
316
|
+
# Otherwise, store original ID in payload and use hash
|
|
317
|
+
id.to_s.hash.abs
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def uuid?(str)
|
|
321
|
+
str.to_s.match?(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def extract_original_id(point_id)
|
|
325
|
+
point_id.to_s
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Build Qdrant filter from Vectra filter and namespace
|
|
329
|
+
def build_filter(filter, namespace)
|
|
330
|
+
conditions = []
|
|
331
|
+
|
|
332
|
+
# Add namespace filter
|
|
333
|
+
if namespace
|
|
334
|
+
conditions << {
|
|
335
|
+
key: "_namespace",
|
|
336
|
+
match: { value: namespace }
|
|
337
|
+
}
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Add metadata filters
|
|
341
|
+
if filter.is_a?(Hash)
|
|
342
|
+
filter.each do |key, value|
|
|
343
|
+
conditions << build_condition(key.to_s, value)
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
return nil if conditions.empty?
|
|
348
|
+
|
|
349
|
+
{ must: conditions }
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def build_condition(key, value)
|
|
353
|
+
case value
|
|
354
|
+
when Hash
|
|
355
|
+
# Handle operators like { "$gt" => 5 }
|
|
356
|
+
build_operator_condition(key, value)
|
|
357
|
+
when Array
|
|
358
|
+
# IN operator
|
|
359
|
+
{ key: key, match: { any: value } }
|
|
360
|
+
else
|
|
361
|
+
# Exact match
|
|
362
|
+
{ key: key, match: { value: value } }
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def build_operator_condition(key, operator_hash)
|
|
367
|
+
operator, val = operator_hash.first
|
|
368
|
+
|
|
369
|
+
case operator.to_s
|
|
370
|
+
when "$ne"
|
|
371
|
+
{ key: key, match: { except: [val] } }
|
|
372
|
+
when "$gt"
|
|
373
|
+
{ key: key, range: { gt: val } }
|
|
374
|
+
when "$gte"
|
|
375
|
+
{ key: key, range: { gte: val } }
|
|
376
|
+
when "$lt"
|
|
377
|
+
{ key: key, range: { lt: val } }
|
|
378
|
+
when "$lte"
|
|
379
|
+
{ key: key, range: { lte: val } }
|
|
380
|
+
when "$in"
|
|
381
|
+
{ key: key, match: { any: val } }
|
|
382
|
+
when "$nin"
|
|
383
|
+
{ key: key, match: { except: val } }
|
|
384
|
+
else # $eq or unknown operator - exact match
|
|
385
|
+
{ key: key, match: { value: val } }
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def transform_search_results(results)
|
|
390
|
+
results.map do |result|
|
|
391
|
+
{
|
|
392
|
+
id: extract_original_id(result["id"]),
|
|
393
|
+
score: result["score"],
|
|
394
|
+
values: result["vector"],
|
|
395
|
+
metadata: clean_payload(result["payload"])
|
|
396
|
+
}
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Remove internal fields from payload
|
|
401
|
+
def clean_payload(payload)
|
|
402
|
+
return {} unless payload
|
|
403
|
+
|
|
404
|
+
payload.reject { |k, _| k.to_s.start_with?("_") }
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# Convert Vectra metric to Qdrant distance
|
|
408
|
+
def metric_to_distance(metric)
|
|
409
|
+
case metric.to_s.downcase
|
|
410
|
+
when "euclidean", "l2"
|
|
411
|
+
"Euclid"
|
|
412
|
+
when "dot_product", "dotproduct", "inner_product"
|
|
413
|
+
"Dot"
|
|
414
|
+
else # cosine or unknown - default to Cosine
|
|
415
|
+
"Cosine"
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Convert Qdrant distance to Vectra metric
|
|
420
|
+
def distance_to_metric(distance)
|
|
421
|
+
case distance.to_s
|
|
422
|
+
when "Cosine"
|
|
423
|
+
"cosine"
|
|
424
|
+
when "Euclid"
|
|
425
|
+
"euclidean"
|
|
426
|
+
when "Dot"
|
|
427
|
+
"dot_product"
|
|
428
|
+
else
|
|
429
|
+
distance.to_s.downcase
|
|
430
|
+
end
|
|
45
431
|
end
|
|
46
432
|
end
|
|
433
|
+
# rubocop:enable Metrics/ClassLength
|
|
47
434
|
end
|
|
48
435
|
end
|
data/lib/vectra/version.rb
CHANGED
data/lib/vectra.rb
CHANGED
|
@@ -90,11 +90,18 @@ module Vectra
|
|
|
90
90
|
|
|
91
91
|
# Shortcut to create a Qdrant client
|
|
92
92
|
#
|
|
93
|
-
# @param api_key [String] Qdrant API key
|
|
94
93
|
# @param host [String] Qdrant host URL
|
|
94
|
+
# @param api_key [String, nil] Qdrant API key (optional for local instances)
|
|
95
95
|
# @param options [Hash] additional options
|
|
96
96
|
# @return [Client]
|
|
97
|
-
|
|
97
|
+
#
|
|
98
|
+
# @example Local Qdrant (no API key)
|
|
99
|
+
# Vectra.qdrant(host: "http://localhost:6333")
|
|
100
|
+
#
|
|
101
|
+
# @example Qdrant Cloud
|
|
102
|
+
# Vectra.qdrant(host: "https://your-cluster.qdrant.io", api_key: ENV["QDRANT_API_KEY"])
|
|
103
|
+
#
|
|
104
|
+
def qdrant(host:, api_key: nil, **options)
|
|
98
105
|
Client.new(
|
|
99
106
|
provider: :qdrant,
|
|
100
107
|
api_key: api_key,
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: vectra-client
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mijo Kristo
|
|
@@ -55,16 +55,16 @@ dependencies:
|
|
|
55
55
|
name: sqlite3
|
|
56
56
|
requirement: !ruby/object:Gem::Requirement
|
|
57
57
|
requirements:
|
|
58
|
-
- - "
|
|
58
|
+
- - ">="
|
|
59
59
|
- !ruby/object:Gem::Version
|
|
60
|
-
version: '1
|
|
60
|
+
version: '2.1'
|
|
61
61
|
type: :development
|
|
62
62
|
prerelease: false
|
|
63
63
|
version_requirements: !ruby/object:Gem::Requirement
|
|
64
64
|
requirements:
|
|
65
|
-
- - "
|
|
65
|
+
- - ">="
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
|
-
version: '1
|
|
67
|
+
version: '2.1'
|
|
68
68
|
- !ruby/object:Gem::Dependency
|
|
69
69
|
name: pg
|
|
70
70
|
requirement: !ruby/object:Gem::Requirement
|