Skip to content

Commit 0c95d87

Browse files
authored
Merge pull request #4 from codenamev/claude/configurable-embedding-models-ys3Kc
Add configurable embedding models with ModelRegistry and provider inference
2 parents c909035 + f410263 commit 0c95d87

15 files changed

Lines changed: 1042 additions & 35 deletions

.claude/memory.sqlite3-shm

Lines changed: 0 additions & 3 deletions
This file was deleted.

.claude/memory.sqlite3-wal

Lines changed: 0 additions & 3 deletions
This file was deleted.

lib/claude_memory.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ class Error < StandardError; end
7070
require_relative "claude_memory/commands/git_lfs_command"
7171
require_relative "claude_memory/commands/install_skill_command"
7272
require_relative "claude_memory/commands/completion_command"
73+
require_relative "claude_memory/commands/embeddings_command"
7374
require_relative "claude_memory/commands/registry"
7475
require_relative "claude_memory/cli"
7576
require_relative "claude_memory/configuration"
@@ -80,6 +81,8 @@ class Error < StandardError; end
8081
require_relative "claude_memory/domain/entity"
8182
require_relative "claude_memory/domain/provenance"
8283
require_relative "claude_memory/domain/conflict"
84+
require_relative "claude_memory/embeddings/model_registry"
85+
require_relative "claude_memory/embeddings/inspector"
8386
require_relative "claude_memory/embeddings/generator"
8487
require_relative "claude_memory/embeddings/fastembed_adapter"
8588
require_relative "claude_memory/embeddings/api_adapter"
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
# frozen_string_literal: true
2+
3+
module ClaudeMemory
4+
module Commands
5+
# Shows embedding configuration, lists available models, and validates setup.
6+
#
7+
# Subcommands:
8+
# claude-memory embeddings # Show current config
9+
# claude-memory embeddings list # List available models
10+
# claude-memory embeddings check # Validate current setup
11+
#
12+
class EmbeddingsCommand < BaseCommand
13+
def call(args)
14+
opts = parse_options(args, {}) do |o|
15+
OptionParser.new do |parser|
16+
parser.banner = "Usage: claude-memory embeddings [list|check]"
17+
end
18+
end
19+
return 1 if opts.nil?
20+
21+
subcommand = args.first
22+
23+
case subcommand
24+
when "list" then list_models
25+
when "check" then check_setup
26+
when nil then show_config
27+
else
28+
failure("Unknown subcommand: #{subcommand}. Use: list, check")
29+
end
30+
end
31+
32+
private
33+
34+
def inspector
35+
@inspector ||= Embeddings::Inspector.new
36+
end
37+
38+
def show_config
39+
provider = ENV["CLAUDE_MEMORY_EMBEDDING_PROVIDER"] || "tfidf"
40+
model = ENV["CLAUDE_MEMORY_EMBEDDING_MODEL"]
41+
api_url = ENV["CLAUDE_MEMORY_EMBEDDING_API_URL"]
42+
43+
stdout.puts "Embedding Configuration"
44+
stdout.puts "======================"
45+
stdout.puts "Provider: #{provider}"
46+
stdout.puts "Model: #{model || "(default)"}"
47+
48+
if model
49+
info = Embeddings::ModelRegistry.find(model)
50+
if info
51+
stdout.puts "Dimensions: #{info.dimensions}"
52+
stdout.puts "Description: #{info.description}"
53+
else
54+
stdout.puts "Dimensions: (unknown - will be discovered at runtime)"
55+
end
56+
else
57+
info = Embeddings::ModelRegistry.default_for_provider(provider)
58+
if info
59+
stdout.puts "Default model: #{info.name}"
60+
stdout.puts "Dimensions: #{info.dimensions}"
61+
end
62+
end
63+
64+
stdout.puts "API URL: #{api_url}" if api_url && provider == "api"
65+
66+
inspector.database_states.each do |state|
67+
stdout.puts ""
68+
stdout.puts "#{state.label.capitalize} DB: provider=#{state.provider || "unknown"}, dimensions=#{state.dimensions || "unknown"}"
69+
end
70+
71+
stdout.puts ""
72+
stdout.puts "ENV variables:"
73+
stdout.puts " CLAUDE_MEMORY_EMBEDDING_PROVIDER Provider (tfidf, fastembed, api)"
74+
stdout.puts " CLAUDE_MEMORY_EMBEDDING_MODEL Model name"
75+
stdout.puts " CLAUDE_MEMORY_EMBEDDING_API_KEY API key (for api provider)"
76+
stdout.puts " CLAUDE_MEMORY_EMBEDDING_API_URL API endpoint (for api provider)"
77+
0
78+
end
79+
80+
def list_models
81+
Embeddings::ModelRegistry.providers.each do |provider|
82+
stdout.puts ""
83+
stdout.puts "#{provider_label(provider)}:"
84+
stdout.puts "-" * 40
85+
86+
Embeddings::ModelRegistry.models_for_provider(provider).each do |model|
87+
size = model.size_mb ? "#{model.size_mb}MB" : "cloud"
88+
tokens = model.max_tokens ? "#{model.max_tokens} tokens" : ""
89+
stdout.puts " #{model.name}"
90+
stdout.puts " #{model.dimensions}-dim | #{size} | #{tokens}"
91+
stdout.puts " #{model.description}"
92+
end
93+
end
94+
95+
stdout.puts ""
96+
stdout.puts "Custom models: Set CLAUDE_MEMORY_EMBEDDING_MODEL to any model"
97+
stdout.puts "supported by your provider. Dimensions are auto-detected."
98+
0
99+
end
100+
101+
def check_setup
102+
provider_name = ENV["CLAUDE_MEMORY_EMBEDDING_PROVIDER"] || "tfidf"
103+
model_name = ENV["CLAUDE_MEMORY_EMBEDDING_MODEL"]
104+
105+
stdout.puts "Checking embedding setup..."
106+
stdout.puts ""
107+
108+
ok = true
109+
ok &= check_provider(provider_name)
110+
ok &= check_model(provider_name, model_name) if model_name
111+
ok &= render_dimension_checks(provider_name, model_name)
112+
113+
stdout.puts ""
114+
stdout.puts ok ? "All checks passed." : "Some checks failed. See above."
115+
ok ? 0 : 1
116+
end
117+
118+
def check_provider(name)
119+
case name
120+
when "fastembed"
121+
check_fastembed
122+
when "api"
123+
check_api_config
124+
when "tfidf"
125+
stdout.puts " [OK] tfidf provider (built-in, always available)"
126+
true
127+
else
128+
stdout.puts " [FAIL] Unknown provider: #{name}"
129+
false
130+
end
131+
end
132+
133+
def check_model(provider_name, model_name)
134+
info = Embeddings::ModelRegistry.find(model_name)
135+
if info
136+
if info.provider != provider_name
137+
stdout.puts " [WARN] Model '#{model_name}' is for '#{info.provider}' provider, but '#{provider_name}' is selected"
138+
stdout.puts " Set CLAUDE_MEMORY_EMBEDDING_PROVIDER=#{info.provider}"
139+
else
140+
stdout.puts " [OK] Model '#{model_name}' (#{info.dimensions}-dim)"
141+
end
142+
else
143+
stdout.puts " [INFO] Model '#{model_name}' not in registry (dimensions will be auto-detected)"
144+
end
145+
true
146+
end
147+
148+
def render_dimension_checks(provider_name, model_name)
149+
ok = true
150+
151+
inspector.dimension_checks(provider_name, model_name).each do |check|
152+
case check.status
153+
when :mismatch
154+
stdout.puts " [WARN] #{check.label}: Dimension mismatch (stored: #{check.stored_dims}, current: #{check.current_dims})"
155+
stdout.puts " Re-index with: claude-memory index --force --scope #{check.label}"
156+
ok = false
157+
when :match
158+
stdout.puts " [OK] #{check.label}: #{check.stored_dims}-dim (provider: #{check.stored_provider || "unknown"})"
159+
when :fresh
160+
stdout.puts " [INFO] #{check.label}: No embeddings indexed yet"
161+
end
162+
end
163+
164+
ok
165+
end
166+
167+
def check_fastembed
168+
require "fastembed"
169+
stdout.puts " [OK] fastembed gem available"
170+
true
171+
rescue LoadError
172+
stdout.puts " [FAIL] fastembed gem not installed"
173+
stdout.puts " Add `gem 'fastembed'` to your Gemfile"
174+
false
175+
end
176+
177+
def check_api_config
178+
key = ENV["CLAUDE_MEMORY_EMBEDDING_API_KEY"] || ENV["OPENAI_API_KEY"]
179+
if key
180+
stdout.puts " [OK] API key configured"
181+
true
182+
else
183+
stdout.puts " [FAIL] No API key found"
184+
stdout.puts " Set CLAUDE_MEMORY_EMBEDDING_API_KEY or OPENAI_API_KEY"
185+
false
186+
end
187+
end
188+
189+
def provider_label(provider)
190+
case provider
191+
when "fastembed" then "fastembed (local ONNX, no API key)"
192+
when "api" then "api (OpenAI-compatible endpoints, requires API key)"
193+
when "tfidf" then "tfidf (built-in, no dependencies)"
194+
end
195+
end
196+
end
197+
end
198+
end

lib/claude_memory/commands/registry.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ class Registry
3232
"export" => "ExportCommand",
3333
"git-lfs" => "GitLfsCommand",
3434
"install-skill" => "InstallSkillCommand",
35-
"completion" => "CompletionCommand"
35+
"completion" => "CompletionCommand",
36+
"embeddings" => "EmbeddingsCommand"
3637
}.freeze
3738

3839
# Find a command class by name

lib/claude_memory/embeddings/api_adapter.rb

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,19 +22,20 @@ class ApiError < StandardError; end
2222
DEFAULT_API_URL = "https://api.openai.com/v1/embeddings"
2323
DEFAULT_MODEL = "text-embedding-3-small"
2424

25-
def initialize(env: ENV)
25+
def initialize(model: nil, env: ENV)
2626
@api_key = env["CLAUDE_MEMORY_EMBEDDING_API_KEY"] || env["OPENAI_API_KEY"]
2727
@api_url = env["CLAUDE_MEMORY_EMBEDDING_API_URL"] || DEFAULT_API_URL
28-
@model = env["CLAUDE_MEMORY_EMBEDDING_MODEL"] || DEFAULT_MODEL
28+
@model = model || env["CLAUDE_MEMORY_EMBEDDING_MODEL"] || DEFAULT_MODEL
29+
@known_dimensions = ModelRegistry.dimensions_for(@model)
2930

3031
raise ArgumentError, "Set CLAUDE_MEMORY_EMBEDDING_API_KEY or OPENAI_API_KEY" unless @api_key
3132
end
3233

3334
def name = "api"
3435

35-
# Dimensions are lazy — derived from the first API response and cached.
36+
# Dimensions resolved from registry if known, otherwise lazy from first API response.
3637
def dimensions
37-
@dimensions ||= fetch_dimensions
38+
@dimensions ||= @known_dimensions || fetch_dimensions
3839
end
3940

4041
# Generate embedding for a query text.

lib/claude_memory/embeddings/fastembed_adapter.rb

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,47 +2,59 @@
22

33
module ClaudeMemory
44
module Embeddings
5-
# Adapter wrapping fastembed-rb for high-quality local embeddings
6-
# Uses BAAI/bge-small-en-v1.5 by default (384-dim, ~67MB ONNX model)
5+
# Adapter wrapping fastembed-rb for high-quality local embeddings.
6+
# Supports any model available in fastembed-rb's SUPPORTED_MODELS.
77
#
8-
# Implements the same generate(text) interface as Generator for DI compatibility.
9-
# Supports asymmetric query/passage encoding for better retrieval accuracy.
8+
# Model selection (in priority order):
9+
# 1. Explicit model_name parameter
10+
# 2. CLAUDE_MEMORY_EMBEDDING_MODEL env var
11+
# 3. Default: BAAI/bge-small-en-v1.5 (384-dim, ~67MB ONNX)
12+
#
13+
# Dimensions are resolved from the ModelRegistry for known models,
14+
# or probed from fastembed's ModelInfo for unknown models.
1015
#
1116
# Usage:
1217
# adapter = FastembedAdapter.new
1318
# query_vec = adapter.generate("What database?") # query encoding
1419
# passage_vec = adapter.generate_passage("Uses PostgreSQL") # passage encoding
1520
#
21+
# # Use a larger model:
22+
# adapter = FastembedAdapter.new(model_name: "BAAI/bge-base-en-v1.5")
23+
# adapter.dimensions # => 768
24+
#
1625
class FastembedAdapter
17-
EMBEDDING_DIM = 384
1826
DEFAULT_MODEL = "BAAI/bge-small-en-v1.5"
1927

28+
attr_reader :model_name, :dimensions
29+
2030
def name = "fastembed"
2131

22-
def dimensions = EMBEDDING_DIM
32+
def initialize(model_name: nil, env: ENV)
33+
@model_name = model_name || env["CLAUDE_MEMORY_EMBEDDING_MODEL"] || DEFAULT_MODEL
34+
@dimensions = resolve_dimensions(@model_name)
2335

24-
def initialize(model_name: DEFAULT_MODEL)
2536
require "fastembed"
26-
@model = Fastembed::TextEmbedding.new(model_name: model_name)
37+
@model = Fastembed::TextEmbedding.new(model_name: @model_name)
38+
39+
# If dimensions weren't known from registry, probe from fastembed
40+
@dimensions ||= probe_dimensions_from_fastembed
2741
rescue LoadError
2842
raise LoadError,
2943
"fastembed gem is required for FastembedAdapter. Add `gem 'fastembed'` to your Gemfile."
3044
end
3145

3246
# Generate query embedding (optimized for search queries)
33-
# Compatible with Recall's embedding_generator interface
3447
# @param text [String] query text to embed
35-
# @return [Array<Float>] normalized 384-dimensional vector
48+
# @return [Array<Float>] normalized embedding vector
3649
def generate(text)
3750
return zero_vector if text.nil? || text.empty?
3851

3952
@model.query_embed(text).first.to_a
4053
end
4154

4255
# Generate passage embedding (optimized for document/fact indexing)
43-
# Use this when storing embeddings for facts
4456
# @param text [String] passage text to embed
45-
# @return [Array<Float>] normalized 384-dimensional vector
57+
# @return [Array<Float>] normalized embedding vector
4658
def generate_passage(text)
4759
return zero_vector if text.nil? || text.empty?
4860

@@ -51,8 +63,26 @@ def generate_passage(text)
5163

5264
private
5365

66+
# Resolve dimensions from the model registry (fast, no I/O).
67+
# Returns nil if the model isn't in the registry.
68+
def resolve_dimensions(model)
69+
ModelRegistry.dimensions_for(model)
70+
end
71+
72+
# Fallback: probe fastembed's SUPPORTED_MODELS for dimension info.
73+
# This handles models added to fastembed-rb but not yet in our registry.
74+
def probe_dimensions_from_fastembed
75+
if defined?(Fastembed::SUPPORTED_MODELS)
76+
info = Fastembed::SUPPORTED_MODELS[@model_name]
77+
return info.dim if info
78+
end
79+
80+
# Last resort: generate a test embedding and measure its size
81+
@model.query_embed("dimension probe").first.size
82+
end
83+
5484
def zero_vector
55-
Array.new(EMBEDDING_DIM, 0.0)
85+
Array.new(@dimensions, 0.0)
5686
end
5787
end
5888
end

0 commit comments

Comments
 (0)