vectra-client 0.2.2 → 0.3.0
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/.rubocop.yml +77 -37
- data/CHANGELOG.md +49 -6
- data/README.md +56 -203
- data/docs/Gemfile +0 -1
- data/docs/_config.yml +19 -2
- data/docs/_layouts/default.html +6 -6
- data/docs/_layouts/home.html +183 -29
- data/docs/_layouts/page.html +80 -18
- data/docs/assets/style.css +806 -174
- data/docs/examples/index.md +46 -24
- data/docs/guides/performance.md +200 -0
- data/docs/index.md +22 -38
- data/docs/providers/index.md +58 -39
- data/lib/vectra/batch.rb +148 -0
- data/lib/vectra/cache.rb +261 -0
- data/lib/vectra/configuration.rb +6 -1
- data/lib/vectra/pool.rb +256 -0
- data/lib/vectra/streaming.rb +153 -0
- data/lib/vectra/version.rb +1 -1
- data/lib/vectra.rb +4 -0
- metadata +20 -1
data/lib/vectra/cache.rb
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Vectra
|
|
6
|
+
# Optional caching layer for frequently queried vectors
|
|
7
|
+
#
|
|
8
|
+
# Provides in-memory caching with TTL support for query results
|
|
9
|
+
# and fetched vectors to reduce database load.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic usage
|
|
12
|
+
# cache = Vectra::Cache.new(ttl: 300, max_size: 1000)
|
|
13
|
+
# cached_client = Vectra::CachedClient.new(client, cache: cache)
|
|
14
|
+
#
|
|
15
|
+
# # First call hits the database
|
|
16
|
+
# result1 = cached_client.query(index: 'idx', vector: vec, top_k: 10)
|
|
17
|
+
#
|
|
18
|
+
# # Second call returns cached result
|
|
19
|
+
# result2 = cached_client.query(index: 'idx', vector: vec, top_k: 10)
|
|
20
|
+
#
|
|
21
|
+
class Cache
|
|
22
|
+
DEFAULT_TTL = 300 # 5 minutes
|
|
23
|
+
DEFAULT_MAX_SIZE = 1000
|
|
24
|
+
|
|
25
|
+
attr_reader :ttl, :max_size
|
|
26
|
+
|
|
27
|
+
# Initialize cache
|
|
28
|
+
#
|
|
29
|
+
# @param ttl [Integer] time-to-live in seconds (default: 300)
|
|
30
|
+
# @param max_size [Integer] maximum cache entries (default: 1000)
|
|
31
|
+
def initialize(ttl: DEFAULT_TTL, max_size: DEFAULT_MAX_SIZE)
|
|
32
|
+
@ttl = ttl
|
|
33
|
+
@max_size = max_size
|
|
34
|
+
@store = {}
|
|
35
|
+
@timestamps = {}
|
|
36
|
+
@mutex = Mutex.new
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Get value from cache
|
|
40
|
+
#
|
|
41
|
+
# @param key [String] cache key
|
|
42
|
+
# @return [Object, nil] cached value or nil if not found/expired
|
|
43
|
+
def get(key)
|
|
44
|
+
@mutex.synchronize do
|
|
45
|
+
return nil unless @store.key?(key)
|
|
46
|
+
|
|
47
|
+
if expired?(key)
|
|
48
|
+
delete_entry(key)
|
|
49
|
+
return nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
@store[key]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Set value in cache
|
|
57
|
+
#
|
|
58
|
+
# @param key [String] cache key
|
|
59
|
+
# @param value [Object] value to cache
|
|
60
|
+
# @return [Object] the cached value
|
|
61
|
+
def set(key, value)
|
|
62
|
+
@mutex.synchronize do
|
|
63
|
+
evict_if_needed
|
|
64
|
+
@store[key] = value
|
|
65
|
+
@timestamps[key] = Time.now
|
|
66
|
+
value
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Get or set value with block
|
|
71
|
+
#
|
|
72
|
+
# @param key [String] cache key
|
|
73
|
+
# @yield block to compute value if not cached
|
|
74
|
+
# @return [Object] cached or computed value
|
|
75
|
+
def fetch(key)
|
|
76
|
+
cached = get(key)
|
|
77
|
+
return cached unless cached.nil?
|
|
78
|
+
|
|
79
|
+
value = yield
|
|
80
|
+
set(key, value)
|
|
81
|
+
value
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Delete entry from cache
|
|
85
|
+
#
|
|
86
|
+
# @param key [String] cache key
|
|
87
|
+
# @return [Object, nil] deleted value
|
|
88
|
+
def delete(key)
|
|
89
|
+
@mutex.synchronize { delete_entry(key) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Clear all cache entries
|
|
93
|
+
#
|
|
94
|
+
# @return [void]
|
|
95
|
+
def clear
|
|
96
|
+
@mutex.synchronize do
|
|
97
|
+
@store.clear
|
|
98
|
+
@timestamps.clear
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Get cache statistics
|
|
103
|
+
#
|
|
104
|
+
# @return [Hash] cache stats
|
|
105
|
+
def stats
|
|
106
|
+
@mutex.synchronize do
|
|
107
|
+
{
|
|
108
|
+
size: @store.size,
|
|
109
|
+
max_size: max_size,
|
|
110
|
+
ttl: ttl,
|
|
111
|
+
keys: @store.keys
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Check if key exists and is not expired
|
|
117
|
+
#
|
|
118
|
+
# @param key [String] cache key
|
|
119
|
+
# @return [Boolean]
|
|
120
|
+
def exist?(key)
|
|
121
|
+
@mutex.synchronize do
|
|
122
|
+
return false unless @store.key?(key)
|
|
123
|
+
return false if expired?(key)
|
|
124
|
+
|
|
125
|
+
true
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def expired?(key)
|
|
132
|
+
return true unless @timestamps.key?(key)
|
|
133
|
+
|
|
134
|
+
Time.now - @timestamps[key] > ttl
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def delete_entry(key)
|
|
138
|
+
@timestamps.delete(key)
|
|
139
|
+
@store.delete(key)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def evict_if_needed
|
|
143
|
+
# Evict when at or above max_size to make room for new entry
|
|
144
|
+
return if @store.size < max_size
|
|
145
|
+
|
|
146
|
+
# Remove oldest entries (at least 10% of max_size to avoid frequent evictions)
|
|
147
|
+
entries_to_remove = [(max_size * 0.2).ceil, 1].max
|
|
148
|
+
oldest_keys = @timestamps.sort_by { |_, v| v }.first(entries_to_remove).map(&:first)
|
|
149
|
+
oldest_keys.each { |key| delete_entry(key) }
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Client wrapper with caching support
|
|
154
|
+
#
|
|
155
|
+
# Wraps a Vectra::Client to add transparent caching for query and fetch operations.
|
|
156
|
+
#
|
|
157
|
+
class CachedClient
|
|
158
|
+
attr_reader :client, :cache
|
|
159
|
+
|
|
160
|
+
# Initialize cached client
|
|
161
|
+
#
|
|
162
|
+
# @param client [Client] the underlying Vectra client
|
|
163
|
+
# @param cache [Cache] cache instance (creates default if nil)
|
|
164
|
+
# @param cache_queries [Boolean] whether to cache query results (default: true)
|
|
165
|
+
# @param cache_fetches [Boolean] whether to cache fetch results (default: true)
|
|
166
|
+
def initialize(client, cache: nil, cache_queries: true, cache_fetches: true)
|
|
167
|
+
@client = client
|
|
168
|
+
@cache = cache || Cache.new
|
|
169
|
+
@cache_queries = cache_queries
|
|
170
|
+
@cache_fetches = cache_fetches
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Query with caching
|
|
174
|
+
#
|
|
175
|
+
# @see Client#query
|
|
176
|
+
def query(index:, vector:, top_k: 10, namespace: nil, filter: nil, **options)
|
|
177
|
+
unless @cache_queries
|
|
178
|
+
return client.query(index: index, vector: vector, top_k: top_k,
|
|
179
|
+
namespace: namespace, filter: filter, **options)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
key = query_cache_key(index, vector, top_k, namespace, filter)
|
|
183
|
+
cache.fetch(key) do
|
|
184
|
+
client.query(index: index, vector: vector, top_k: top_k,
|
|
185
|
+
namespace: namespace, filter: filter, **options)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Fetch with caching
|
|
190
|
+
#
|
|
191
|
+
# @see Client#fetch
|
|
192
|
+
def fetch(index:, ids:, namespace: nil)
|
|
193
|
+
return client.fetch(index: index, ids: ids, namespace: namespace) unless @cache_fetches
|
|
194
|
+
|
|
195
|
+
# Check cache for each ID
|
|
196
|
+
results = {}
|
|
197
|
+
uncached_ids = []
|
|
198
|
+
|
|
199
|
+
ids.each do |id|
|
|
200
|
+
key = fetch_cache_key(index, id, namespace)
|
|
201
|
+
cached = cache.get(key)
|
|
202
|
+
if cached
|
|
203
|
+
results[id] = cached
|
|
204
|
+
else
|
|
205
|
+
uncached_ids << id
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Fetch uncached IDs
|
|
210
|
+
if uncached_ids.any?
|
|
211
|
+
fetched = client.fetch(index: index, ids: uncached_ids, namespace: namespace)
|
|
212
|
+
fetched.each do |id, vector|
|
|
213
|
+
key = fetch_cache_key(index, id, namespace)
|
|
214
|
+
cache.set(key, vector)
|
|
215
|
+
results[id] = vector
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
results
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Pass through other methods to underlying client
|
|
223
|
+
def method_missing(method, *, **, &)
|
|
224
|
+
if client.respond_to?(method)
|
|
225
|
+
client.public_send(method, *, **, &)
|
|
226
|
+
else
|
|
227
|
+
super
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def respond_to_missing?(method, include_private = false)
|
|
232
|
+
client.respond_to?(method, include_private) || super
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Invalidate cache entries for an index
|
|
236
|
+
#
|
|
237
|
+
# @param index [String] index name
|
|
238
|
+
def invalidate_index(index)
|
|
239
|
+
cache.stats[:keys].each do |key|
|
|
240
|
+
cache.delete(key) if key.start_with?("#{index}:")
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Clear entire cache
|
|
245
|
+
def clear_cache
|
|
246
|
+
cache.clear
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
private
|
|
250
|
+
|
|
251
|
+
def query_cache_key(index, vector, top_k, namespace, filter)
|
|
252
|
+
vector_hash = Digest::MD5.hexdigest(vector.to_s)[0, 16]
|
|
253
|
+
filter_hash = filter ? Digest::MD5.hexdigest(filter.to_s)[0, 8] : "nofilter"
|
|
254
|
+
"#{index}:q:#{vector_hash}:#{top_k}:#{namespace || 'default'}:#{filter_hash}"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def fetch_cache_key(index, id, namespace)
|
|
258
|
+
"#{index}:f:#{id}:#{namespace || 'default'}"
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
data/lib/vectra/configuration.rb
CHANGED
|
@@ -15,7 +15,8 @@ module Vectra
|
|
|
15
15
|
|
|
16
16
|
attr_accessor :api_key, :environment, :host, :timeout, :open_timeout,
|
|
17
17
|
:max_retries, :retry_delay, :logger, :pool_size, :pool_timeout,
|
|
18
|
-
:batch_size, :instrumentation
|
|
18
|
+
:batch_size, :instrumentation, :cache_enabled, :cache_ttl,
|
|
19
|
+
:cache_max_size, :async_concurrency
|
|
19
20
|
|
|
20
21
|
attr_reader :provider
|
|
21
22
|
|
|
@@ -33,6 +34,10 @@ module Vectra
|
|
|
33
34
|
@pool_timeout = 5
|
|
34
35
|
@batch_size = 100
|
|
35
36
|
@instrumentation = false
|
|
37
|
+
@cache_enabled = false
|
|
38
|
+
@cache_ttl = 300
|
|
39
|
+
@cache_max_size = 1000
|
|
40
|
+
@async_concurrency = 4
|
|
36
41
|
end
|
|
37
42
|
|
|
38
43
|
# Set the provider
|
data/lib/vectra/pool.rb
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
|
|
5
|
+
module Vectra
|
|
6
|
+
# Connection pool with warmup support
|
|
7
|
+
#
|
|
8
|
+
# Provides connection pooling for database providers with configurable
|
|
9
|
+
# pool size, timeout, and connection warmup.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic usage
|
|
12
|
+
# pool = Vectra::Pool.new(size: 5, timeout: 5) { create_connection }
|
|
13
|
+
# pool.warmup(3) # Pre-create 3 connections
|
|
14
|
+
#
|
|
15
|
+
# pool.with_connection do |conn|
|
|
16
|
+
# conn.execute("SELECT 1")
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
class Pool
|
|
20
|
+
class TimeoutError < Vectra::Error; end
|
|
21
|
+
class PoolExhaustedError < Vectra::Error; end
|
|
22
|
+
|
|
23
|
+
attr_reader :size, :timeout
|
|
24
|
+
|
|
25
|
+
# Initialize connection pool
|
|
26
|
+
#
|
|
27
|
+
# @param size [Integer] maximum pool size
|
|
28
|
+
# @param timeout [Integer] checkout timeout in seconds
|
|
29
|
+
# @yield connection factory block
|
|
30
|
+
def initialize(size:, timeout: 5, &block)
|
|
31
|
+
raise ArgumentError, "Connection factory block required" unless block_given?
|
|
32
|
+
|
|
33
|
+
@size = size
|
|
34
|
+
@timeout = timeout
|
|
35
|
+
@factory = block
|
|
36
|
+
@pool = Concurrent::Array.new
|
|
37
|
+
@checked_out = Concurrent::AtomicFixnum.new(0)
|
|
38
|
+
@mutex = Mutex.new
|
|
39
|
+
@condition = ConditionVariable.new
|
|
40
|
+
@shutdown = false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Warmup the pool by pre-creating connections
|
|
44
|
+
#
|
|
45
|
+
# @param count [Integer] number of connections to create (default: pool size)
|
|
46
|
+
# @return [Integer] number of connections created
|
|
47
|
+
def warmup(count = nil)
|
|
48
|
+
count ||= size
|
|
49
|
+
count = [count, size].min
|
|
50
|
+
created = 0
|
|
51
|
+
|
|
52
|
+
count.times do
|
|
53
|
+
break if @pool.size >= size
|
|
54
|
+
|
|
55
|
+
conn = create_connection
|
|
56
|
+
if conn
|
|
57
|
+
@pool << conn
|
|
58
|
+
created += 1
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
created
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Execute block with a connection from the pool
|
|
66
|
+
#
|
|
67
|
+
# @yield [connection] the checked out connection
|
|
68
|
+
# @return [Object] result of the block
|
|
69
|
+
def with_connection
|
|
70
|
+
conn = checkout
|
|
71
|
+
begin
|
|
72
|
+
yield conn
|
|
73
|
+
ensure
|
|
74
|
+
checkin(conn)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Checkout a connection from the pool
|
|
79
|
+
#
|
|
80
|
+
# @return [Object] a connection
|
|
81
|
+
# @raise [TimeoutError] if checkout times out
|
|
82
|
+
# @raise [PoolExhaustedError] if pool is exhausted
|
|
83
|
+
def checkout
|
|
84
|
+
raise PoolExhaustedError, "Pool has been shutdown" if @shutdown
|
|
85
|
+
|
|
86
|
+
deadline = Time.now + timeout
|
|
87
|
+
|
|
88
|
+
@mutex.synchronize do
|
|
89
|
+
loop do
|
|
90
|
+
# Try to get an existing connection
|
|
91
|
+
conn = @pool.pop
|
|
92
|
+
if conn
|
|
93
|
+
@checked_out.increment
|
|
94
|
+
return conn if healthy?(conn)
|
|
95
|
+
|
|
96
|
+
# Connection is unhealthy, discard and try again
|
|
97
|
+
close_connection(conn)
|
|
98
|
+
next
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Try to create a new connection if under limit
|
|
102
|
+
if @checked_out.value + @pool.size < size
|
|
103
|
+
conn = create_connection
|
|
104
|
+
if conn
|
|
105
|
+
@checked_out.increment
|
|
106
|
+
return conn
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Wait for a connection to be returned
|
|
111
|
+
remaining = deadline - Time.now
|
|
112
|
+
raise TimeoutError, "Connection checkout timed out after #{timeout}s" if remaining <= 0
|
|
113
|
+
|
|
114
|
+
@condition.wait(@mutex, remaining)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Return a connection to the pool
|
|
120
|
+
#
|
|
121
|
+
# @param connection [Object] connection to return
|
|
122
|
+
def checkin(connection)
|
|
123
|
+
return if @shutdown
|
|
124
|
+
|
|
125
|
+
@mutex.synchronize do
|
|
126
|
+
@checked_out.decrement
|
|
127
|
+
if healthy?(connection) && @pool.size < size
|
|
128
|
+
@pool << connection
|
|
129
|
+
else
|
|
130
|
+
close_connection(connection)
|
|
131
|
+
end
|
|
132
|
+
@condition.signal
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Shutdown the pool, closing all connections
|
|
137
|
+
#
|
|
138
|
+
# @return [void]
|
|
139
|
+
def shutdown
|
|
140
|
+
@shutdown = true
|
|
141
|
+
@mutex.synchronize do
|
|
142
|
+
while (conn = @pool.pop)
|
|
143
|
+
close_connection(conn)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Get pool statistics
|
|
149
|
+
#
|
|
150
|
+
# @return [Hash] pool stats
|
|
151
|
+
def stats
|
|
152
|
+
{
|
|
153
|
+
size: size,
|
|
154
|
+
available: @pool.size,
|
|
155
|
+
checked_out: @checked_out.value,
|
|
156
|
+
total_created: @pool.size + @checked_out.value,
|
|
157
|
+
shutdown: @shutdown
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Check if pool is healthy (public method)
|
|
162
|
+
#
|
|
163
|
+
# @return [Boolean]
|
|
164
|
+
def pool_healthy?
|
|
165
|
+
!@shutdown && (@pool.size + @checked_out.value).positive?
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
# Internal health check for individual connections
|
|
171
|
+
def healthy?(conn)
|
|
172
|
+
return false if conn.nil?
|
|
173
|
+
return true unless conn.respond_to?(:status)
|
|
174
|
+
|
|
175
|
+
# For PG connections, check status. Otherwise assume healthy.
|
|
176
|
+
if defined?(PG::CONNECTION_OK)
|
|
177
|
+
conn.status == PG::CONNECTION_OK
|
|
178
|
+
else
|
|
179
|
+
# If PG not loaded, assume connection is healthy if it exists
|
|
180
|
+
true
|
|
181
|
+
end
|
|
182
|
+
rescue StandardError
|
|
183
|
+
false
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def create_connection
|
|
187
|
+
@factory.call
|
|
188
|
+
rescue StandardError => e
|
|
189
|
+
Vectra.configuration.logger&.error("Pool: Failed to create connection: #{e.message}")
|
|
190
|
+
nil
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def close_connection(conn)
|
|
194
|
+
conn.close if conn.respond_to?(:close)
|
|
195
|
+
rescue StandardError => e
|
|
196
|
+
Vectra.configuration.logger&.warn("Pool: Error closing connection: #{e.message}")
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Pooled connection module for pgvector
|
|
201
|
+
module PooledConnection
|
|
202
|
+
# Get a pooled connection
|
|
203
|
+
#
|
|
204
|
+
# @return [Pool] connection pool
|
|
205
|
+
def connection_pool
|
|
206
|
+
@connection_pool ||= create_pool
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Warmup the connection pool
|
|
210
|
+
#
|
|
211
|
+
# @param count [Integer] number of connections to pre-create
|
|
212
|
+
# @return [Integer] connections created
|
|
213
|
+
def warmup_pool(count = nil)
|
|
214
|
+
connection_pool.warmup(count)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Execute with pooled connection
|
|
218
|
+
#
|
|
219
|
+
# @yield [connection] database connection
|
|
220
|
+
def with_pooled_connection(&)
|
|
221
|
+
connection_pool.with_connection(&)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Shutdown the connection pool
|
|
225
|
+
def shutdown_pool
|
|
226
|
+
@connection_pool&.shutdown
|
|
227
|
+
@connection_pool = nil
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Get pool statistics
|
|
231
|
+
#
|
|
232
|
+
# @return [Hash]
|
|
233
|
+
def pool_stats
|
|
234
|
+
return { status: "not_initialized" } unless @connection_pool
|
|
235
|
+
|
|
236
|
+
connection_pool.stats
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
private
|
|
240
|
+
|
|
241
|
+
def create_pool
|
|
242
|
+
pool_size = config.pool_size || 5
|
|
243
|
+
pool_timeout = config.pool_timeout || 5
|
|
244
|
+
|
|
245
|
+
Pool.new(size: pool_size, timeout: pool_timeout) do
|
|
246
|
+
create_raw_connection
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def create_raw_connection
|
|
251
|
+
require "pg"
|
|
252
|
+
conn_params = parse_connection_params
|
|
253
|
+
PG.connect(conn_params)
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vectra
|
|
4
|
+
# Streaming query results for large datasets
|
|
5
|
+
#
|
|
6
|
+
# Provides lazy enumeration over query results with automatic pagination,
|
|
7
|
+
# reducing memory usage for large result sets.
|
|
8
|
+
#
|
|
9
|
+
# @example Stream through results
|
|
10
|
+
# stream = Vectra::Streaming.new(client)
|
|
11
|
+
# stream.query_each(index: 'my-index', vector: query_vec, total: 1000) do |match|
|
|
12
|
+
# process(match)
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# @example As lazy enumerator
|
|
16
|
+
# results = stream.query_stream(index: 'my-index', vector: query_vec, total: 1000)
|
|
17
|
+
# results.take(50).each { |m| puts m.id }
|
|
18
|
+
#
|
|
19
|
+
class Streaming
|
|
20
|
+
DEFAULT_PAGE_SIZE = 100
|
|
21
|
+
|
|
22
|
+
attr_reader :client, :page_size
|
|
23
|
+
|
|
24
|
+
# Initialize streaming query handler
|
|
25
|
+
#
|
|
26
|
+
# @param client [Client] the Vectra client
|
|
27
|
+
# @param page_size [Integer] results per page (default: 100)
|
|
28
|
+
def initialize(client, page_size: DEFAULT_PAGE_SIZE)
|
|
29
|
+
@client = client
|
|
30
|
+
@page_size = [page_size, 1].max
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Stream query results with a block
|
|
34
|
+
#
|
|
35
|
+
# @param index [String] the index name
|
|
36
|
+
# @param vector [Array<Float>] query vector
|
|
37
|
+
# @param total [Integer] total results to fetch
|
|
38
|
+
# @param namespace [String, nil] optional namespace
|
|
39
|
+
# @param filter [Hash, nil] metadata filter
|
|
40
|
+
# @yield [Match] each match result
|
|
41
|
+
# @return [Integer] total results yielded
|
|
42
|
+
def query_each(index:, vector:, total:, namespace: nil, filter: nil, &block)
|
|
43
|
+
return 0 unless block_given?
|
|
44
|
+
|
|
45
|
+
count = 0
|
|
46
|
+
query_stream(index: index, vector: vector, total: total, namespace: namespace, filter: filter).each do |match|
|
|
47
|
+
block.call(match)
|
|
48
|
+
count += 1
|
|
49
|
+
end
|
|
50
|
+
count
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Create a lazy enumerator for streaming results
|
|
54
|
+
#
|
|
55
|
+
# @param index [String] the index name
|
|
56
|
+
# @param vector [Array<Float>] query vector
|
|
57
|
+
# @param total [Integer] total results to fetch
|
|
58
|
+
# @param namespace [String, nil] optional namespace
|
|
59
|
+
# @param filter [Hash, nil] metadata filter
|
|
60
|
+
# @return [Enumerator::Lazy] lazy enumerator of results
|
|
61
|
+
def query_stream(index:, vector:, total:, namespace: nil, filter: nil)
|
|
62
|
+
Enumerator.new do |yielder|
|
|
63
|
+
fetched = 0
|
|
64
|
+
seen_ids = Set.new
|
|
65
|
+
|
|
66
|
+
while fetched < total
|
|
67
|
+
batch_size = [page_size, total - fetched].min
|
|
68
|
+
|
|
69
|
+
result = client.query(
|
|
70
|
+
index: index,
|
|
71
|
+
vector: vector,
|
|
72
|
+
top_k: batch_size,
|
|
73
|
+
namespace: namespace,
|
|
74
|
+
filter: filter,
|
|
75
|
+
include_metadata: true
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
break if result.empty?
|
|
79
|
+
|
|
80
|
+
result.each do |match|
|
|
81
|
+
# Skip duplicates (some providers may return overlapping results)
|
|
82
|
+
next if seen_ids.include?(match.id)
|
|
83
|
+
|
|
84
|
+
seen_ids.add(match.id)
|
|
85
|
+
yielder << match
|
|
86
|
+
fetched += 1
|
|
87
|
+
|
|
88
|
+
break if fetched >= total
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# If we got fewer results than requested, we've exhausted the index
|
|
92
|
+
break if result.size < batch_size
|
|
93
|
+
end
|
|
94
|
+
end.lazy
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Scan all vectors in an index (provider-dependent)
|
|
98
|
+
#
|
|
99
|
+
# @param index [String] the index name
|
|
100
|
+
# @param namespace [String, nil] optional namespace
|
|
101
|
+
# @param batch_size [Integer] IDs per batch
|
|
102
|
+
# @yield [Vector] each vector
|
|
103
|
+
# @return [Integer] total vectors scanned
|
|
104
|
+
# @note Not all providers support efficient scanning
|
|
105
|
+
def scan_all(index:, namespace: nil, batch_size: 1000)
|
|
106
|
+
return 0 unless block_given?
|
|
107
|
+
|
|
108
|
+
count = 0
|
|
109
|
+
offset = 0
|
|
110
|
+
|
|
111
|
+
loop do
|
|
112
|
+
# This is a simplified scan - actual implementation depends on provider
|
|
113
|
+
stats = client.stats(index: index, namespace: namespace)
|
|
114
|
+
total = stats[:total_vector_count] || 0
|
|
115
|
+
|
|
116
|
+
break if offset >= total
|
|
117
|
+
|
|
118
|
+
# Fetch IDs in batches (this is provider-specific)
|
|
119
|
+
# For now, we return what we can
|
|
120
|
+
break if offset.positive? # Only one iteration for basic implementation
|
|
121
|
+
|
|
122
|
+
offset += batch_size
|
|
123
|
+
count = total
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
count
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Streaming result wrapper with additional metadata
|
|
131
|
+
class StreamingResult
|
|
132
|
+
include Enumerable
|
|
133
|
+
|
|
134
|
+
attr_reader :enumerator, :metadata
|
|
135
|
+
|
|
136
|
+
def initialize(enumerator, metadata = {})
|
|
137
|
+
@enumerator = enumerator
|
|
138
|
+
@metadata = metadata
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def each(&)
|
|
142
|
+
enumerator.each(&)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def take(n)
|
|
146
|
+
enumerator.take(n)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def to_a
|
|
150
|
+
enumerator.to_a
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
data/lib/vectra/version.rb
CHANGED
data/lib/vectra.rb
CHANGED
|
@@ -7,6 +7,10 @@ require_relative "vectra/vector"
|
|
|
7
7
|
require_relative "vectra/query_result"
|
|
8
8
|
require_relative "vectra/instrumentation"
|
|
9
9
|
require_relative "vectra/retry"
|
|
10
|
+
require_relative "vectra/batch"
|
|
11
|
+
require_relative "vectra/streaming"
|
|
12
|
+
require_relative "vectra/cache"
|
|
13
|
+
require_relative "vectra/pool"
|
|
10
14
|
require_relative "vectra/active_record"
|
|
11
15
|
require_relative "vectra/providers/base"
|
|
12
16
|
require_relative "vectra/providers/pinecone"
|