vectra-client 0.2.1 → 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 +52 -393
- data/docs/Gemfile +9 -0
- data/docs/_config.yml +37 -0
- data/docs/_layouts/default.html +14 -0
- data/docs/_layouts/home.html +187 -0
- data/docs/_layouts/page.html +82 -0
- data/docs/_site/api/overview/index.html +145 -0
- data/docs/_site/assets/main.css +649 -0
- data/docs/_site/assets/main.css.map +1 -0
- data/docs/_site/assets/minima-social-icons.svg +33 -0
- data/docs/_site/assets/style.css +295 -0
- data/docs/_site/community/contributing/index.html +110 -0
- data/docs/_site/examples/basic-usage/index.html +117 -0
- data/docs/_site/examples/index.html +58 -0
- data/docs/_site/feed.xml +1 -0
- data/docs/_site/guides/getting-started/index.html +106 -0
- data/docs/_site/guides/installation/index.html +82 -0
- data/docs/_site/index.html +92 -0
- data/docs/_site/providers/index.html +119 -0
- data/docs/_site/providers/pgvector/index.html +155 -0
- data/docs/_site/providers/pinecone/index.html +121 -0
- data/docs/_site/providers/qdrant/index.html +124 -0
- data/docs/_site/providers/weaviate/index.html +123 -0
- data/docs/_site/robots.txt +1 -0
- data/docs/_site/sitemap.xml +39 -0
- data/docs/api/overview.md +126 -0
- data/docs/assets/style.css +927 -0
- data/docs/community/contributing.md +89 -0
- data/docs/examples/basic-usage.md +102 -0
- data/docs/examples/index.md +54 -0
- data/docs/guides/getting-started.md +90 -0
- data/docs/guides/installation.md +67 -0
- data/docs/guides/performance.md +200 -0
- data/docs/index.md +37 -0
- data/docs/providers/index.md +81 -0
- data/docs/providers/pgvector.md +95 -0
- data/docs/providers/pinecone.md +72 -0
- data/docs/providers/qdrant.md +73 -0
- data/docs/providers/weaviate.md +72 -0
- 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
- data/netlify.toml +12 -0
- metadata +58 -5
- data/IMPLEMENTATION_GUIDE.md +0 -686
- data/NEW_FEATURES_v0.2.0.md +0 -459
- data/RELEASE_CHECKLIST_v0.2.0.md +0 -383
- data/USAGE_EXAMPLES.md +0 -787
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
---
|
|
2
|
+
layout: page
|
|
3
|
+
title: PostgreSQL with pgvector
|
|
4
|
+
permalink: /providers/pgvector/
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# PostgreSQL with pgvector Provider
|
|
8
|
+
|
|
9
|
+
[pgvector](https://github.com/pgvector/pgvector) is a PostgreSQL extension for vector data.
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
### Prerequisites
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# Install PostgreSQL with pgvector extension
|
|
17
|
+
# macOS with Homebrew
|
|
18
|
+
brew install postgresql
|
|
19
|
+
|
|
20
|
+
# Enable pgvector extension
|
|
21
|
+
psql -d your_database -c "CREATE EXTENSION IF NOT EXISTS vector;"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Connect with Vectra
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
client = Vectra::Client.new(
|
|
28
|
+
provider: :pgvector,
|
|
29
|
+
database: 'my_database',
|
|
30
|
+
host: 'localhost',
|
|
31
|
+
port: 5432,
|
|
32
|
+
user: 'postgres',
|
|
33
|
+
password: ENV['DB_PASSWORD']
|
|
34
|
+
)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Features
|
|
38
|
+
|
|
39
|
+
- ✅ Upsert vectors
|
|
40
|
+
- ✅ Query/search
|
|
41
|
+
- ✅ Delete vectors
|
|
42
|
+
- ✅ SQL integration
|
|
43
|
+
- ✅ ACID transactions
|
|
44
|
+
- ✅ Complex queries
|
|
45
|
+
- ✅ Rails ActiveRecord integration
|
|
46
|
+
|
|
47
|
+
## Example
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
# Initialize client
|
|
51
|
+
client = Vectra::Client.new(
|
|
52
|
+
provider: :pgvector,
|
|
53
|
+
database: 'vectors_db',
|
|
54
|
+
host: 'localhost'
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Upsert vectors
|
|
58
|
+
client.upsert(
|
|
59
|
+
vectors: [
|
|
60
|
+
{ id: 'doc-1', values: [0.1, 0.2, 0.3], metadata: { title: 'Doc 1' } }
|
|
61
|
+
]
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Search using cosine distance
|
|
65
|
+
results = client.query(vector: [0.1, 0.2, 0.3], top_k: 5)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## ActiveRecord Integration
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
class Document < ApplicationRecord
|
|
72
|
+
include Vectra::ActiveRecord
|
|
73
|
+
|
|
74
|
+
vector_search :embedding_vector
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Search
|
|
78
|
+
docs = Document.vector_search([0.1, 0.2, 0.3], limit: 10)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Configuration Options
|
|
82
|
+
|
|
83
|
+
| Option | Type | Required | Description |
|
|
84
|
+
|--------|------|----------|-------------|
|
|
85
|
+
| `database` | String | Yes | Database name |
|
|
86
|
+
| `host` | String | Yes | PostgreSQL host |
|
|
87
|
+
| `port` | Integer | No | PostgreSQL port (default: 5432) |
|
|
88
|
+
| `user` | String | No | Database user |
|
|
89
|
+
| `password` | String | No | Database password |
|
|
90
|
+
| `schema` | String | No | Database schema |
|
|
91
|
+
|
|
92
|
+
## Documentation
|
|
93
|
+
|
|
94
|
+
- [pgvector GitHub](https://github.com/pgvector/pgvector)
|
|
95
|
+
- [pgvector Docs](https://github.com/pgvector/pgvector#readme)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
---
|
|
2
|
+
layout: page
|
|
3
|
+
title: Pinecone
|
|
4
|
+
permalink: /providers/pinecone/
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Pinecone Provider
|
|
8
|
+
|
|
9
|
+
[Pinecone](https://www.pinecone.io/) is a managed vector database in the cloud.
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
1. Create a Pinecone account at https://www.pinecone.io/
|
|
14
|
+
2. Create an index and get your API key
|
|
15
|
+
3. Set up Vectra:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
client = Vectra::Client.new(
|
|
19
|
+
provider: :pinecone,
|
|
20
|
+
api_key: ENV['PINECONE_API_KEY'],
|
|
21
|
+
index_name: 'my-index',
|
|
22
|
+
environment: 'us-west-4'
|
|
23
|
+
)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Features
|
|
27
|
+
|
|
28
|
+
- ✅ Upsert vectors
|
|
29
|
+
- ✅ Query/search
|
|
30
|
+
- ✅ Delete vectors
|
|
31
|
+
- ✅ Fetch vectors by ID
|
|
32
|
+
- ✅ Index statistics
|
|
33
|
+
- ✅ Metadata filtering
|
|
34
|
+
- ✅ Namespace support
|
|
35
|
+
|
|
36
|
+
## Example
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
# Initialize client
|
|
40
|
+
client = Vectra::Client.new(
|
|
41
|
+
provider: :pinecone,
|
|
42
|
+
api_key: ENV['PINECONE_API_KEY'],
|
|
43
|
+
environment: 'us-west-4'
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Upsert vectors
|
|
47
|
+
client.upsert(
|
|
48
|
+
vectors: [
|
|
49
|
+
{ id: 'doc-1', values: [0.1, 0.2, 0.3], metadata: { title: 'Page 1' } },
|
|
50
|
+
{ id: 'doc-2', values: [0.2, 0.3, 0.4], metadata: { title: 'Page 2' } }
|
|
51
|
+
]
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Search
|
|
55
|
+
results = client.query(vector: [0.1, 0.2, 0.3], top_k: 5)
|
|
56
|
+
results.matches.each do |match|
|
|
57
|
+
puts "#{match['id']}: #{match['score']}"
|
|
58
|
+
end
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Configuration Options
|
|
62
|
+
|
|
63
|
+
| Option | Type | Required | Description |
|
|
64
|
+
|--------|------|----------|-------------|
|
|
65
|
+
| `api_key` | String | Yes | Your Pinecone API key |
|
|
66
|
+
| `environment` | String | Yes | Pinecone environment (e.g., 'us-west-4') |
|
|
67
|
+
| `index_name` | String | No | Index name (if not set globally) |
|
|
68
|
+
|
|
69
|
+
## Documentation
|
|
70
|
+
|
|
71
|
+
- [Pinecone Docs](https://docs.pinecone.io/)
|
|
72
|
+
- [Pinecone API Reference](https://docs.pinecone.io/reference/api/)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
---
|
|
2
|
+
layout: page
|
|
3
|
+
title: Qdrant
|
|
4
|
+
permalink: /providers/qdrant/
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Qdrant Provider
|
|
8
|
+
|
|
9
|
+
[Qdrant](https://qdrant.tech/) is an open-source vector search engine.
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
### Local Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
docker run -p 6333:6333 qdrant/qdrant
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### Connect with Vectra
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
client = Vectra::Client.new(
|
|
23
|
+
provider: :qdrant,
|
|
24
|
+
host: 'localhost',
|
|
25
|
+
port: 6333,
|
|
26
|
+
collection_name: 'my-collection'
|
|
27
|
+
)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
|
|
32
|
+
- ✅ Upsert vectors
|
|
33
|
+
- ✅ Query/search
|
|
34
|
+
- ✅ Delete vectors
|
|
35
|
+
- ✅ Fetch vectors by ID
|
|
36
|
+
- ✅ Collection management
|
|
37
|
+
- ✅ Metadata filtering
|
|
38
|
+
- ✅ Hybrid search
|
|
39
|
+
|
|
40
|
+
## Example
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
# Initialize client
|
|
44
|
+
client = Vectra::Client.new(
|
|
45
|
+
provider: :qdrant,
|
|
46
|
+
host: 'localhost',
|
|
47
|
+
port: 6333
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Upsert vectors
|
|
51
|
+
client.upsert(
|
|
52
|
+
vectors: [
|
|
53
|
+
{ id: 'doc-1', values: [0.1, 0.2, 0.3], metadata: { source: 'web' } }
|
|
54
|
+
]
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Search
|
|
58
|
+
results = client.query(vector: [0.1, 0.2, 0.3], top_k: 10)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Configuration Options
|
|
62
|
+
|
|
63
|
+
| Option | Type | Required | Description |
|
|
64
|
+
|--------|------|----------|-------------|
|
|
65
|
+
| `host` | String | Yes | Qdrant host address |
|
|
66
|
+
| `port` | Integer | Yes | Qdrant port (default: 6333) |
|
|
67
|
+
| `collection_name` | String | No | Collection name |
|
|
68
|
+
| `api_key` | String | No | API key if auth is enabled |
|
|
69
|
+
|
|
70
|
+
## Documentation
|
|
71
|
+
|
|
72
|
+
- [Qdrant Docs](https://qdrant.tech/documentation/)
|
|
73
|
+
- [Qdrant API Reference](https://api.qdrant.tech/)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
---
|
|
2
|
+
layout: page
|
|
3
|
+
title: Weaviate
|
|
4
|
+
permalink: /providers/weaviate/
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Weaviate Provider
|
|
8
|
+
|
|
9
|
+
[Weaviate](https://weaviate.io/) is an open-source vector search engine with semantic search capabilities.
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
### Local Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
docker run -p 8080:8080 semitechnologies/weaviate:latest
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### Connect with Vectra
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
client = Vectra::Client.new(
|
|
23
|
+
provider: :weaviate,
|
|
24
|
+
host: 'localhost',
|
|
25
|
+
port: 8080,
|
|
26
|
+
class_name: 'Document'
|
|
27
|
+
)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
|
|
32
|
+
- ✅ Upsert vectors
|
|
33
|
+
- ✅ Query/search
|
|
34
|
+
- ✅ Delete vectors
|
|
35
|
+
- ✅ Class management
|
|
36
|
+
- ✅ Metadata filtering
|
|
37
|
+
- ✅ Semantic search
|
|
38
|
+
|
|
39
|
+
## Example
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
# Initialize client
|
|
43
|
+
client = Vectra::Client.new(
|
|
44
|
+
provider: :weaviate,
|
|
45
|
+
host: 'localhost',
|
|
46
|
+
port: 8080
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Upsert vectors
|
|
50
|
+
client.upsert(
|
|
51
|
+
vectors: [
|
|
52
|
+
{ id: 'doc-1', values: [0.1, 0.2, 0.3], metadata: { category: 'news' } }
|
|
53
|
+
]
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Search
|
|
57
|
+
results = client.query(vector: [0.1, 0.2, 0.3], top_k: 5)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Configuration Options
|
|
61
|
+
|
|
62
|
+
| Option | Type | Required | Description |
|
|
63
|
+
|--------|------|----------|-------------|
|
|
64
|
+
| `host` | String | Yes | Weaviate host address |
|
|
65
|
+
| `port` | Integer | Yes | Weaviate port (default: 8080) |
|
|
66
|
+
| `class_name` | String | No | Class name for vectors |
|
|
67
|
+
| `api_key` | String | No | API key if auth is enabled |
|
|
68
|
+
|
|
69
|
+
## Documentation
|
|
70
|
+
|
|
71
|
+
- [Weaviate Docs](https://weaviate.io/developers)
|
|
72
|
+
- [Weaviate API Reference](https://weaviate.io/developers/weaviate/api/rest)
|
data/lib/vectra/batch.rb
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
|
|
5
|
+
module Vectra
|
|
6
|
+
# Batch operations with concurrent processing
|
|
7
|
+
#
|
|
8
|
+
# Provides async batch upsert capabilities with configurable concurrency
|
|
9
|
+
# and automatic chunking of large vector sets.
|
|
10
|
+
#
|
|
11
|
+
# @example Async batch upsert
|
|
12
|
+
# batch = Vectra::Batch.new(client, concurrency: 4)
|
|
13
|
+
# result = batch.upsert_async(
|
|
14
|
+
# index: 'my-index',
|
|
15
|
+
# vectors: large_vector_array,
|
|
16
|
+
# chunk_size: 100
|
|
17
|
+
# )
|
|
18
|
+
# puts "Upserted: #{result[:upserted_count]}"
|
|
19
|
+
#
|
|
20
|
+
class Batch
|
|
21
|
+
DEFAULT_CONCURRENCY = 4
|
|
22
|
+
DEFAULT_CHUNK_SIZE = 100
|
|
23
|
+
|
|
24
|
+
attr_reader :client, :concurrency
|
|
25
|
+
|
|
26
|
+
# Initialize a new Batch processor
|
|
27
|
+
#
|
|
28
|
+
# @param client [Client] the Vectra client
|
|
29
|
+
# @param concurrency [Integer] max concurrent requests (default: 4)
|
|
30
|
+
def initialize(client, concurrency: DEFAULT_CONCURRENCY)
|
|
31
|
+
@client = client
|
|
32
|
+
@concurrency = [concurrency, 1].max
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Perform async batch upsert with concurrent requests
|
|
36
|
+
#
|
|
37
|
+
# @param index [String] the index name
|
|
38
|
+
# @param vectors [Array<Hash>] vectors to upsert
|
|
39
|
+
# @param namespace [String, nil] optional namespace
|
|
40
|
+
# @param chunk_size [Integer] vectors per chunk (default: 100)
|
|
41
|
+
# @return [Hash] aggregated result with :upserted_count, :chunks, :errors
|
|
42
|
+
def upsert_async(index:, vectors:, namespace: nil, chunk_size: DEFAULT_CHUNK_SIZE)
|
|
43
|
+
chunks = vectors.each_slice(chunk_size).to_a
|
|
44
|
+
return { upserted_count: 0, chunks: 0, errors: [] } if chunks.empty?
|
|
45
|
+
|
|
46
|
+
results = process_chunks_concurrently(chunks) do |chunk|
|
|
47
|
+
client.upsert(index: index, vectors: chunk, namespace: namespace)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
aggregate_results(results, vectors.size)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Perform async batch delete with concurrent requests
|
|
54
|
+
#
|
|
55
|
+
# @param index [String] the index name
|
|
56
|
+
# @param ids [Array<String>] IDs to delete
|
|
57
|
+
# @param namespace [String, nil] optional namespace
|
|
58
|
+
# @param chunk_size [Integer] IDs per chunk (default: 100)
|
|
59
|
+
# @return [Hash] aggregated result
|
|
60
|
+
def delete_async(index:, ids:, namespace: nil, chunk_size: DEFAULT_CHUNK_SIZE)
|
|
61
|
+
chunks = ids.each_slice(chunk_size).to_a
|
|
62
|
+
return { deleted_count: 0, chunks: 0, errors: [] } if chunks.empty?
|
|
63
|
+
|
|
64
|
+
results = process_chunks_concurrently(chunks) do |chunk|
|
|
65
|
+
client.delete(index: index, ids: chunk, namespace: namespace)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
aggregate_delete_results(results, ids.size)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Perform async batch fetch with concurrent requests
|
|
72
|
+
#
|
|
73
|
+
# @param index [String] the index name
|
|
74
|
+
# @param ids [Array<String>] IDs to fetch
|
|
75
|
+
# @param namespace [String, nil] optional namespace
|
|
76
|
+
# @param chunk_size [Integer] IDs per chunk (default: 100)
|
|
77
|
+
# @return [Hash<String, Vector>] merged results
|
|
78
|
+
def fetch_async(index:, ids:, namespace: nil, chunk_size: DEFAULT_CHUNK_SIZE)
|
|
79
|
+
chunks = ids.each_slice(chunk_size).to_a
|
|
80
|
+
return {} if chunks.empty?
|
|
81
|
+
|
|
82
|
+
results = process_chunks_concurrently(chunks) do |chunk|
|
|
83
|
+
client.fetch(index: index, ids: chunk, namespace: namespace)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
merge_fetch_results(results)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def process_chunks_concurrently(chunks)
|
|
92
|
+
pool = Concurrent::FixedThreadPool.new(concurrency)
|
|
93
|
+
futures = []
|
|
94
|
+
|
|
95
|
+
chunks.each_with_index do |chunk, index|
|
|
96
|
+
futures << Concurrent::Future.execute(executor: pool) do
|
|
97
|
+
{ index: index, result: yield(chunk), error: nil }
|
|
98
|
+
rescue StandardError => e
|
|
99
|
+
{ index: index, result: nil, error: e }
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Wait for all futures and collect results
|
|
104
|
+
results = futures.map(&:value)
|
|
105
|
+
pool.shutdown
|
|
106
|
+
pool.wait_for_termination(30)
|
|
107
|
+
|
|
108
|
+
results.sort_by { |r| r[:index] }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def aggregate_results(results, total_vectors)
|
|
112
|
+
errors = results.select { |r| r[:error] }.map { |r| r[:error] }
|
|
113
|
+
successful = results.reject { |r| r[:error] }
|
|
114
|
+
upserted = successful.sum { |r| r.dig(:result, :upserted_count) || 0 }
|
|
115
|
+
|
|
116
|
+
{
|
|
117
|
+
upserted_count: upserted,
|
|
118
|
+
total_vectors: total_vectors,
|
|
119
|
+
chunks: results.size,
|
|
120
|
+
successful_chunks: successful.size,
|
|
121
|
+
errors: errors
|
|
122
|
+
}
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def aggregate_delete_results(results, total_ids)
|
|
126
|
+
errors = results.select { |r| r[:error] }.map { |r| r[:error] }
|
|
127
|
+
successful = results.reject { |r| r[:error] }
|
|
128
|
+
|
|
129
|
+
{
|
|
130
|
+
deleted_count: total_ids - (errors.size * (total_ids / results.size.to_f).ceil),
|
|
131
|
+
total_ids: total_ids,
|
|
132
|
+
chunks: results.size,
|
|
133
|
+
successful_chunks: successful.size,
|
|
134
|
+
errors: errors
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def merge_fetch_results(results)
|
|
139
|
+
merged = {}
|
|
140
|
+
results.each do |r|
|
|
141
|
+
next if r[:error] || r[:result].nil?
|
|
142
|
+
|
|
143
|
+
merged.merge!(r[:result])
|
|
144
|
+
end
|
|
145
|
+
merged
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
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
|