semantic-cache 0.1.0 → 0.2.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/CHANGELOG.md +16 -0
- data/lib/semantic_cache/cache.rb +15 -3
- data/lib/semantic_cache/configuration.rb +5 -1
- data/lib/semantic_cache/embedding.rb +49 -12
- data/lib/semantic_cache/stores/memory.rb +14 -2
- data/lib/semantic_cache/stores/redis.rb +24 -2
- data/lib/semantic_cache/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fe7443a701b34378500d134735a7d00eea7ab078cfaf036d7b11406495eed1b9
|
|
4
|
+
data.tar.gz: 4f079d89a8e0f977ac89af9ba09841e9064c7121b649ea91f9fb430475f0a628
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8b79b8b26a38343eed5db1e008be0ca4417277c33fb3ab0beda3dc8b6440d79a3d8d5e39301714ba12cf1a268be3dbe3307f639c8bc0dea83dd34e9186d4c566
|
|
7
|
+
data.tar.gz: bd19ca0b045887bffa9c2510977d6e005ea1fad50a4e44012e1e88a6a051100286fe09a847848ffa0ddd78b20212510b51ed3175fc4ed5d9a77edf2ea45a0929
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.2.0] - 2025-01-29
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Input validation** — `cache.fetch(nil)` and `cache.fetch("")` now raise `ArgumentError` early instead of failing at the API level
|
|
10
|
+
- **API timeout** — embedding generation is wrapped in `Timeout.timeout` (default: 30s, configurable via `config.embedding_timeout`)
|
|
11
|
+
- **Max cache size** — both Memory and Redis stores support `max_size` with LRU eviction (oldest entry by `created_at` is evicted when full)
|
|
12
|
+
- **Redis store tests** — full test coverage for write, entries, delete, tags, clear, size, max_size, and namespace
|
|
13
|
+
- **Rails integration tests** — tests for `with_cache`, `current`, `Cacheable` concern, and exception safety
|
|
14
|
+
- **Embedding batch validation** — `generate_batch` validates each element in the array
|
|
15
|
+
|
|
16
|
+
### Improved
|
|
17
|
+
|
|
18
|
+
- Test coverage increased from 85.6% to 97.67% (113 tests, 0 failures)
|
|
19
|
+
- Configuration now includes `embedding_timeout` and `max_cache_size` attributes
|
|
20
|
+
|
|
5
21
|
## [0.1.0] - 2025-01-29
|
|
6
22
|
|
|
7
23
|
### Added
|
data/lib/semantic_cache/cache.rb
CHANGED
|
@@ -13,7 +13,8 @@ module SemanticCache
|
|
|
13
13
|
store_options: {},
|
|
14
14
|
default_ttl: nil,
|
|
15
15
|
namespace: nil,
|
|
16
|
-
track_costs: nil
|
|
16
|
+
track_costs: nil,
|
|
17
|
+
max_size: nil
|
|
17
18
|
)
|
|
18
19
|
config = SemanticCache.configuration
|
|
19
20
|
|
|
@@ -21,6 +22,7 @@ module SemanticCache
|
|
|
21
22
|
@default_ttl = default_ttl || config.default_ttl
|
|
22
23
|
@track_costs = track_costs.nil? ? config.track_costs : track_costs
|
|
23
24
|
@namespace = namespace || config.namespace
|
|
25
|
+
@max_size = max_size || config.max_cache_size
|
|
24
26
|
|
|
25
27
|
@embedding = Embedding.new(
|
|
26
28
|
model: embedding_model || config.embedding_model,
|
|
@@ -43,6 +45,7 @@ module SemanticCache
|
|
|
43
45
|
# model: - Model name for cost tracking
|
|
44
46
|
def fetch(query, ttl: nil, tags: [], model: nil, metadata: {}, &block)
|
|
45
47
|
raise ArgumentError, "A block is required" unless block_given?
|
|
48
|
+
validate_query!(query)
|
|
46
49
|
|
|
47
50
|
start_time = Time.now
|
|
48
51
|
|
|
@@ -133,6 +136,13 @@ module SemanticCache
|
|
|
133
136
|
|
|
134
137
|
private
|
|
135
138
|
|
|
139
|
+
def validate_query!(query)
|
|
140
|
+
raise ArgumentError, "query cannot be nil" if query.nil?
|
|
141
|
+
|
|
142
|
+
query_str = query.to_s.strip
|
|
143
|
+
raise ArgumentError, "query cannot be blank" if query_str.empty?
|
|
144
|
+
end
|
|
145
|
+
|
|
136
146
|
def find_similar(query_embedding)
|
|
137
147
|
entries = @store.entries
|
|
138
148
|
return nil if entries.empty?
|
|
@@ -168,11 +178,13 @@ module SemanticCache
|
|
|
168
178
|
end
|
|
169
179
|
|
|
170
180
|
def build_store(type, options)
|
|
181
|
+
store_max_size = @max_size
|
|
182
|
+
|
|
171
183
|
case type
|
|
172
184
|
when :memory, "memory"
|
|
173
|
-
Stores::Memory.new(**options)
|
|
185
|
+
Stores::Memory.new(max_size: store_max_size, **options)
|
|
174
186
|
when :redis, "redis"
|
|
175
|
-
Stores::Redis.new(**options)
|
|
187
|
+
Stores::Redis.new(max_size: store_max_size, **options)
|
|
176
188
|
when Stores::Memory, Stores::Redis
|
|
177
189
|
type # Already instantiated
|
|
178
190
|
else
|
|
@@ -10,7 +10,9 @@ module SemanticCache
|
|
|
10
10
|
:store_options,
|
|
11
11
|
:track_costs,
|
|
12
12
|
:model_costs,
|
|
13
|
-
:namespace
|
|
13
|
+
:namespace,
|
|
14
|
+
:embedding_timeout,
|
|
15
|
+
:max_cache_size
|
|
14
16
|
|
|
15
17
|
# Cost per 1K tokens (USD)
|
|
16
18
|
DEFAULT_MODEL_COSTS = {
|
|
@@ -41,6 +43,8 @@ module SemanticCache
|
|
|
41
43
|
@track_costs = true
|
|
42
44
|
@model_costs = DEFAULT_MODEL_COSTS.dup
|
|
43
45
|
@namespace = "semantic_cache"
|
|
46
|
+
@embedding_timeout = 30 # seconds
|
|
47
|
+
@max_cache_size = nil # nil = unlimited
|
|
44
48
|
end
|
|
45
49
|
|
|
46
50
|
def cost_for(model)
|
|
@@ -1,24 +1,33 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "openai"
|
|
4
|
+
require "timeout"
|
|
4
5
|
|
|
5
6
|
module SemanticCache
|
|
6
7
|
class Embedding
|
|
7
8
|
def initialize(model: nil, api_key: nil)
|
|
8
9
|
config = SemanticCache.configuration
|
|
9
10
|
@model = model || config.embedding_model
|
|
11
|
+
@timeout = config.embedding_timeout
|
|
10
12
|
@client = OpenAI::Client.new(access_token: api_key || config.openai_api_key)
|
|
11
13
|
end
|
|
12
14
|
|
|
13
15
|
# Generate an embedding vector for the given text.
|
|
14
16
|
# Returns an Array of Floats.
|
|
17
|
+
#
|
|
18
|
+
# Raises ArgumentError if text is nil or empty.
|
|
19
|
+
# Raises SemanticCache::Error on API failure or timeout.
|
|
15
20
|
def generate(text)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
validate_input!(text)
|
|
22
|
+
|
|
23
|
+
response = with_timeout do
|
|
24
|
+
@client.embeddings(
|
|
25
|
+
parameters: {
|
|
26
|
+
model: @model,
|
|
27
|
+
input: text
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
end
|
|
22
31
|
|
|
23
32
|
data = response.dig("data", 0, "embedding")
|
|
24
33
|
raise Error, "Failed to generate embedding: #{response}" if data.nil?
|
|
@@ -28,18 +37,46 @@ module SemanticCache
|
|
|
28
37
|
|
|
29
38
|
# Generate embeddings for multiple texts in a single API call.
|
|
30
39
|
# Returns an Array of Arrays of Floats.
|
|
40
|
+
#
|
|
41
|
+
# Raises ArgumentError if texts is empty or contains nil/blank entries.
|
|
42
|
+
# Raises SemanticCache::Error on API failure or timeout.
|
|
31
43
|
def generate_batch(texts)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
44
|
+
raise ArgumentError, "texts must be a non-empty Array" if !texts.is_a?(Array) || texts.empty?
|
|
45
|
+
|
|
46
|
+
texts.each_with_index do |t, i|
|
|
47
|
+
validate_input!(t, label: "texts[#{i}]")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
response = with_timeout do
|
|
51
|
+
@client.embeddings(
|
|
52
|
+
parameters: {
|
|
53
|
+
model: @model,
|
|
54
|
+
input: texts
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
end
|
|
38
58
|
|
|
39
59
|
data = response["data"]
|
|
40
60
|
raise Error, "Failed to generate embeddings: #{response}" if data.nil?
|
|
41
61
|
|
|
42
62
|
data.sort_by { |d| d["index"] }.map { |d| d["embedding"] }
|
|
43
63
|
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def validate_input!(text, label: "query")
|
|
68
|
+
raise ArgumentError, "#{label} cannot be nil" if text.nil?
|
|
69
|
+
|
|
70
|
+
text_str = text.to_s.strip
|
|
71
|
+
raise ArgumentError, "#{label} cannot be blank" if text_str.empty?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def with_timeout(&block)
|
|
75
|
+
if @timeout && @timeout > 0
|
|
76
|
+
Timeout.timeout(@timeout, Error, "Embedding API request timed out after #{@timeout}s", &block)
|
|
77
|
+
else
|
|
78
|
+
block.call
|
|
79
|
+
end
|
|
80
|
+
end
|
|
44
81
|
end
|
|
45
82
|
end
|
|
@@ -6,18 +6,24 @@ module SemanticCache
|
|
|
6
6
|
module Stores
|
|
7
7
|
# Thread-safe in-memory cache store.
|
|
8
8
|
# Good for development, testing, and single-process apps.
|
|
9
|
+
#
|
|
10
|
+
# Options:
|
|
11
|
+
# max_size: Maximum number of entries to keep. When exceeded, the oldest
|
|
12
|
+
# entry (by created_at) is evicted. nil = unlimited.
|
|
9
13
|
class Memory
|
|
10
14
|
include MonitorMixin
|
|
11
15
|
|
|
12
|
-
def initialize(**_options)
|
|
16
|
+
def initialize(max_size: nil, **_options)
|
|
13
17
|
super()
|
|
14
18
|
@data = {}
|
|
15
19
|
@tags_index = Hash.new { |h, k| h[k] = Set.new }
|
|
20
|
+
@max_size = max_size
|
|
16
21
|
end
|
|
17
22
|
|
|
18
|
-
# Store a cache entry.
|
|
23
|
+
# Store a cache entry. Evicts the oldest entry if max_size is reached.
|
|
19
24
|
def write(key, entry)
|
|
20
25
|
synchronize do
|
|
26
|
+
evict_oldest! if @max_size && @data.size >= @max_size && !@data.key?(key)
|
|
21
27
|
@data[key] = entry
|
|
22
28
|
entry.tags.each { |tag| @tags_index[tag].add(key) }
|
|
23
29
|
end
|
|
@@ -73,6 +79,12 @@ module SemanticCache
|
|
|
73
79
|
expired_keys = @data.select { |_k, v| v.expired? }.keys
|
|
74
80
|
expired_keys.each { |key| delete(key) }
|
|
75
81
|
end
|
|
82
|
+
|
|
83
|
+
# Evict the oldest entry (by created_at) to make room for a new one.
|
|
84
|
+
def evict_oldest!
|
|
85
|
+
oldest_key = @data.min_by { |_k, v| v.created_at }&.first
|
|
86
|
+
delete(oldest_key) if oldest_key
|
|
87
|
+
end
|
|
76
88
|
end
|
|
77
89
|
end
|
|
78
90
|
end
|
|
@@ -8,14 +8,21 @@ module SemanticCache
|
|
|
8
8
|
# Suitable for production, multi-process, and distributed apps.
|
|
9
9
|
#
|
|
10
10
|
# Requires the `redis` gem: gem install redis
|
|
11
|
+
#
|
|
12
|
+
# Options:
|
|
13
|
+
# max_size: Maximum number of entries to keep. When exceeded, the oldest
|
|
14
|
+
# entry (by created_at) is evicted. nil = unlimited.
|
|
11
15
|
class Redis
|
|
12
|
-
def initialize(redis: nil, namespace: nil, **options)
|
|
16
|
+
def initialize(redis: nil, namespace: nil, max_size: nil, **options)
|
|
13
17
|
@namespace = namespace || SemanticCache.configuration.namespace
|
|
14
18
|
@redis = redis || connect(options)
|
|
19
|
+
@max_size = max_size
|
|
15
20
|
end
|
|
16
21
|
|
|
17
|
-
# Store a cache entry.
|
|
22
|
+
# Store a cache entry. Evicts the oldest entry if max_size is reached.
|
|
18
23
|
def write(key, entry)
|
|
24
|
+
evict_oldest! if @max_size && size >= @max_size
|
|
25
|
+
|
|
19
26
|
full_key = namespaced_key(key)
|
|
20
27
|
data = entry.to_json
|
|
21
28
|
|
|
@@ -122,6 +129,21 @@ module SemanticCache
|
|
|
122
129
|
@redis.del(full_key)
|
|
123
130
|
@redis.srem(keys_set_key, full_key)
|
|
124
131
|
end
|
|
132
|
+
|
|
133
|
+
# Evict the oldest entry (by created_at) to make room for a new one.
|
|
134
|
+
def evict_oldest!
|
|
135
|
+
all_entries = entries
|
|
136
|
+
return if all_entries.empty?
|
|
137
|
+
|
|
138
|
+
oldest = all_entries.min_by(&:created_at)
|
|
139
|
+
key = @redis.smembers(keys_set_key).find do |k|
|
|
140
|
+
data = @redis.get(k)
|
|
141
|
+
next false unless data
|
|
142
|
+
|
|
143
|
+
Entry.from_json(data).query == oldest.query
|
|
144
|
+
end
|
|
145
|
+
delete_raw(key) if key
|
|
146
|
+
end
|
|
125
147
|
end
|
|
126
148
|
end
|
|
127
149
|
end
|