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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 23e0684b20e30c7083b21676f50611b82c2926baec2cd2596a02aa9d0f1524b2
4
- data.tar.gz: 892a0094e23003e7c4647a931dca2b785c243439bad35d3511391df0b3275587
3
+ metadata.gz: 6c00aee137978894b5722853a5aa6419c76a4613fd07e7b772df83c0ffddcebd
4
+ data.tar.gz: 92f2e588d62520f695d59feaf74d6e1787efc8c1044cf92a8cf94251dcd9ce3a
5
5
  SHA512:
6
- metadata.gz: 356585c134240dc3b36734f85f3a68965ee18f16dfe1437737628d1c8b9c0c9fcbaca11e7092e1328501fd861f1e1c297a8bad38197a1b0033a9af5159ddb075
7
- data.tar.gz: 0abf8ee4bf16c01065db7dad529ff3da125a0f4bb45a91be19a94842405fdfc8e8c86a9aed7ae153b68323e528111fbea13ffbb2888b1216b1b045cbf1937888
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) | 🚧 Planned | v0.2.0 |
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.0
425
- - 🚧 Qdrant provider
426
- - 🚧 Enhanced error handling
427
- - 🚧 Connection pooling
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
@@ -34,7 +34,7 @@ task :changelog do
34
34
  end
35
35
 
36
36
  desc "Bump version to VERSION"
37
- task :bump_version, [:version] do |t, args|
37
+ task :bump_version, [:version] do |_t, args|
38
38
  version = args[:version]
39
39
  raise "Version required: rake bump_version[1.2.3]" unless version
40
40
 
@@ -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
- raise ConfigurationError, "API key must be configured" if api_key.nil? || api_key.empty?
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 (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.1"
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,
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.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.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