vectra-client 0.2.0 → 0.2.2

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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -14
  3. data/README.md +140 -328
  4. data/Rakefile +1 -1
  5. data/docs/Gemfile +10 -0
  6. data/docs/_config.yml +20 -0
  7. data/docs/_layouts/default.html +14 -0
  8. data/docs/_layouts/home.html +33 -0
  9. data/docs/_layouts/page.html +20 -0
  10. data/docs/_site/api/overview/index.html +145 -0
  11. data/docs/_site/assets/main.css +649 -0
  12. data/docs/_site/assets/main.css.map +1 -0
  13. data/docs/_site/assets/minima-social-icons.svg +33 -0
  14. data/docs/_site/assets/style.css +295 -0
  15. data/docs/_site/community/contributing/index.html +110 -0
  16. data/docs/_site/examples/basic-usage/index.html +117 -0
  17. data/docs/_site/examples/index.html +58 -0
  18. data/docs/_site/feed.xml +1 -0
  19. data/docs/_site/guides/getting-started/index.html +106 -0
  20. data/docs/_site/guides/installation/index.html +82 -0
  21. data/docs/_site/index.html +92 -0
  22. data/docs/_site/providers/index.html +119 -0
  23. data/docs/_site/providers/pgvector/index.html +155 -0
  24. data/docs/_site/providers/pinecone/index.html +121 -0
  25. data/docs/_site/providers/qdrant/index.html +124 -0
  26. data/docs/_site/providers/weaviate/index.html +123 -0
  27. data/docs/_site/robots.txt +1 -0
  28. data/docs/_site/sitemap.xml +39 -0
  29. data/docs/api/overview.md +126 -0
  30. data/docs/assets/style.css +295 -0
  31. data/docs/community/contributing.md +89 -0
  32. data/docs/examples/basic-usage.md +102 -0
  33. data/docs/examples/index.md +32 -0
  34. data/docs/guides/getting-started.md +90 -0
  35. data/docs/guides/installation.md +67 -0
  36. data/docs/index.md +53 -0
  37. data/docs/providers/index.md +62 -0
  38. data/docs/providers/pgvector.md +95 -0
  39. data/docs/providers/pinecone.md +72 -0
  40. data/docs/providers/qdrant.md +73 -0
  41. data/docs/providers/weaviate.md +72 -0
  42. data/lib/vectra/configuration.rb +10 -1
  43. data/lib/vectra/providers/base.rb +10 -0
  44. data/lib/vectra/providers/qdrant.rb +399 -12
  45. data/lib/vectra/version.rb +1 -1
  46. data/lib/vectra.rb +9 -2
  47. data/netlify.toml +12 -0
  48. metadata +43 -9
  49. data/IMPLEMENTATION_GUIDE.md +0 -686
  50. data/NEW_FEATURES_v0.2.0.md +0 -459
  51. data/RELEASE_CHECKLIST_v0.2.0.md +0 -383
  52. data/USAGE_EXAMPLES.md +0 -787
@@ -2,47 +2,434 @@
2
2
 
3
3
  module Vectra
4
4
  module Providers
5
- # Qdrant vector database provider (planned for v0.2.0)
5
+ # Qdrant vector database provider
6
6
  #
7
- # @note This provider is not yet implemented
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
- raise NotImplementedError, "Qdrant provider is planned for v0.2.0"
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
- raise NotImplementedError, "Qdrant provider is planned for v0.2.0"
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
- def fetch(index:, ids:, namespace: nil)
24
- raise NotImplementedError, "Qdrant provider is planned for v0.2.0"
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
- def update(index:, id:, metadata:, namespace: nil)
28
- raise NotImplementedError, "Qdrant provider is planned for v0.2.0"
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
- raise NotImplementedError, "Qdrant provider is planned for v0.2.0"
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
- raise NotImplementedError, "Qdrant provider is planned for v0.2.0"
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
- raise NotImplementedError, "Qdrant provider is planned for v0.2.0"
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
- raise NotImplementedError, "Qdrant provider is planned for v0.2.0"
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Vectra
4
- VERSION = "0.2.0"
4
+ VERSION = "0.2.2"
5
5
  end
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
- def qdrant(api_key:, host:, **options)
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,
data/netlify.toml ADDED
@@ -0,0 +1,12 @@
1
+ [build]
2
+ command = "cd docs && bundle install && bundle exec jekyll build"
3
+ publish = "docs/_site"
4
+
5
+ [build.environment]
6
+ JEKYLL_ENV = "production"
7
+ RUBY_VERSION = "3.4.7"
8
+
9
+ [[redirects]]
10
+ from = "/*"
11
+ to = "/index.html"
12
+ status = 200
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.0
4
+ version: 0.2.2
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.4'
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.4'
67
+ version: '2.1'
68
68
  - !ruby/object:Gem::Dependency
69
69
  name: pg
70
70
  requirement: !ruby/object:Gem::Requirement
@@ -207,16 +207,49 @@ files:
207
207
  - CHANGELOG.md
208
208
  - CODE_OF_CONDUCT.md
209
209
  - CONTRIBUTING.md
210
- - IMPLEMENTATION_GUIDE.md
211
210
  - LICENSE
212
- - NEW_FEATURES_v0.2.0.md
213
211
  - README.md
214
- - RELEASE_CHECKLIST_v0.2.0.md
215
212
  - Rakefile
216
213
  - SECURITY.md
217
- - USAGE_EXAMPLES.md
218
214
  - benchmarks/batch_operations_benchmark.rb
219
215
  - benchmarks/connection_pooling_benchmark.rb
216
+ - docs/Gemfile
217
+ - docs/_config.yml
218
+ - docs/_layouts/default.html
219
+ - docs/_layouts/home.html
220
+ - docs/_layouts/page.html
221
+ - docs/_site/api/overview/index.html
222
+ - docs/_site/assets/main.css
223
+ - docs/_site/assets/main.css.map
224
+ - docs/_site/assets/minima-social-icons.svg
225
+ - docs/_site/assets/style.css
226
+ - docs/_site/community/contributing/index.html
227
+ - docs/_site/examples/basic-usage/index.html
228
+ - docs/_site/examples/index.html
229
+ - docs/_site/feed.xml
230
+ - docs/_site/guides/getting-started/index.html
231
+ - docs/_site/guides/installation/index.html
232
+ - docs/_site/index.html
233
+ - docs/_site/providers/index.html
234
+ - docs/_site/providers/pgvector/index.html
235
+ - docs/_site/providers/pinecone/index.html
236
+ - docs/_site/providers/qdrant/index.html
237
+ - docs/_site/providers/weaviate/index.html
238
+ - docs/_site/robots.txt
239
+ - docs/_site/sitemap.xml
240
+ - docs/api/overview.md
241
+ - docs/assets/style.css
242
+ - docs/community/contributing.md
243
+ - docs/examples/basic-usage.md
244
+ - docs/examples/index.md
245
+ - docs/guides/getting-started.md
246
+ - docs/guides/installation.md
247
+ - docs/index.md
248
+ - docs/providers/index.md
249
+ - docs/providers/pgvector.md
250
+ - docs/providers/pinecone.md
251
+ - docs/providers/qdrant.md
252
+ - docs/providers/weaviate.md
220
253
  - examples/active_record_demo.rb
221
254
  - examples/instrumentation_demo.rb
222
255
  - lib/generators/vectra/install_generator.rb
@@ -242,6 +275,7 @@ files:
242
275
  - lib/vectra/retry.rb
243
276
  - lib/vectra/vector.rb
244
277
  - lib/vectra/version.rb
278
+ - netlify.toml
245
279
  homepage: https://github.com/stokry/vectra
246
280
  licenses:
247
281
  - MIT