ragify 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0a8b50afa93cb29e25eda8a6e6c1bc902ed50b106bbb1b18db1c607672c92d86
4
- data.tar.gz: 02f99522340f1a8c2738c6fbc12c45e50fbfb5de4db9771580881a13385b6c11
3
+ metadata.gz: 1fadf58c11fd46229633ce0bdb6f9b06826bd9a8f03925c33e8139817ca424de
4
+ data.tar.gz: 5521897995ef1d73849a737b6dfed79277d34d41cf640c9243299d3c1d2bf775
5
5
  SHA512:
6
- metadata.gz: 15c94ec278c69c2615c32ae88bd84bece4fec6d1cde2d524cebcd5ddc512152a15c4e2c4b138036baf3ab978940ae3412e4323342e6e10f89afa101896b4f58b
7
- data.tar.gz: 280a443ad84ed106454fbad4ac76b6ea0604ce6c80f2e74af02d350eda1c5a9c875db547dd28ab5cdc5372136a62432cc6bc9d41ccd1b03940abae308a1e4a34
6
+ metadata.gz: 0f443e79c19bbba5fbb4a5698656ebc0cfca179907cc09661c3d24d73cf1005bc137dc5479eebcb45fe8bab12fb4fd12443579314ac08154f38b448084e32e0e
7
+ data.tar.gz: 366a12eb4674d988bc0dc7f26d33e8566d900778791921d38622f358d95ea027167d6098e4f0708ab15adc6a3be9c5661bb8e228a335c5ccefae7545aa5ac2ad
@@ -26,14 +26,14 @@ module Ragify
26
26
  say " 2. Add to your model:"
27
27
  say " class #{model_name} < ApplicationRecord"
28
28
  say " include Ragify::Embeddable"
29
- say ' ragify_content { |r| "#{r.question} #{r.answer}" }'
29
+ say ' ragify_content { |r| r.question + " " + r.answer }'
30
30
  say " end"
31
31
  say ""
32
32
  say " 3. Generate embeddings for existing records:"
33
33
  say " #{model_name}.embed_all!"
34
34
  say ""
35
35
  say " 4. Search:"
36
- say ' #{model_name}.semantic_search("your query")'
36
+ say " #{model_name}.semantic_search(\"your query\")"
37
37
  say ""
38
38
  end
39
39
 
@@ -3,6 +3,7 @@ class AddRagifyEmbeddingTo<%= table_name.camelize %> < ActiveRecord::Migration[<
3
3
  enable_extension "vector" unless extension_enabled?("vector")
4
4
 
5
5
  add_column :<%= table_name %>, :embedding, :vector, limit: <%= dimensions %>
6
+ add_column :<%= table_name %>, :embedding_digest, :string
6
7
  add_index :<%= table_name %>, :embedding, using: :ivfflat, opclass: :vector_cosine_ops
7
8
  end
8
9
  end
data/lib/ragify/chat.rb CHANGED
@@ -13,7 +13,7 @@ module Ragify
13
13
  OPENAI_CHAT_URL = "https://api.openai.com/v1/chat/completions"
14
14
 
15
15
  DEFAULT_SYSTEM_PROMPT = "You are a helpful assistant. Answer questions based on the provided context. " \
16
- "If the context doesn't contain relevant information, say you don't know."
16
+ "If the context doesn't contain relevant information, say you don't know."
17
17
 
18
18
  def initialize(context_model:, system_prompt: nil, search_limit: nil, history: nil)
19
19
  @context_model = context_model
@@ -59,10 +59,10 @@ module Ragify
59
59
  messages << { role: msg[:role].to_s, content: msg[:content].to_s }
60
60
  end
61
61
 
62
- user_content = if context.present?
63
- "Context:\n#{context}\n\n---\n\nQuestion: #{query}"
64
- else
62
+ user_content = if context.empty?
65
63
  query
64
+ else
65
+ "Context:\n#{context}\n\n---\n\nQuestion: #{query}"
66
66
  end
67
67
 
68
68
  messages << { role: "user", content: user_content }
@@ -71,11 +71,11 @@ module Ragify
71
71
 
72
72
  def chat_completion(messages)
73
73
  HttpClient.new.post_json(OPENAI_CHAT_URL, body: {
74
- model: config.chat_model,
75
- messages: messages,
76
- max_tokens: config.max_tokens,
77
- temperature: config.temperature
78
- }, timeout: 60)
74
+ model: config.chat_model,
75
+ messages: messages,
76
+ max_tokens: config.max_tokens,
77
+ temperature: config.temperature
78
+ }, timeout: 60)
79
79
  end
80
80
 
81
81
  def config
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "logger"
4
+ require "digest"
5
+
3
6
  module Ragify
4
7
  class Configuration
5
8
  attr_accessor :openai_api_key,
@@ -11,7 +14,8 @@ module Ragify
11
14
  :similarity_threshold,
12
15
  :search_limit,
13
16
  :ivfflat_probes,
14
- :logger
17
+ :logger,
18
+ :async
15
19
 
16
20
  def initialize
17
21
  @openai_api_key = ENV.fetch("OPENAI_API_KEY", nil)
@@ -23,12 +27,30 @@ module Ragify
23
27
  @similarity_threshold = 0.2
24
28
  @search_limit = 5
25
29
  @ivfflat_probes = 10
30
+ @async = false
26
31
  @logger = defined?(Rails) ? Rails.logger : Logger.new($stdout)
27
32
  end
28
33
 
29
34
  def openai_api_key!
30
- openai_api_key || raise(Error, "Ragify: openai_api_key is not configured. " \
31
- "Set it via Ragify.configure { |c| c.openai_api_key = '...' } or ENV['OPENAI_API_KEY']")
35
+ openai_api_key || raise(
36
+ Error,
37
+ "Ragify: openai_api_key is not configured. " \
38
+ "Set via Ragify.configure { |c| c.openai_api_key = '...' } or ENV['OPENAI_API_KEY']"
39
+ )
40
+ end
41
+
42
+ def validate!
43
+ unless embedding_dimensions.is_a?(Integer) && embedding_dimensions.positive?
44
+ raise Error,
45
+ "embedding_dimensions must be a positive integer"
46
+ end
47
+ raise Error, "similarity_threshold must be between 0 and 1" unless (0..1).cover?(similarity_threshold.to_f)
48
+ raise Error, "search_limit must be positive" unless search_limit.to_i.positive?
49
+ raise Error, "ivfflat_probes must be positive" unless ivfflat_probes.to_i.positive?
50
+ raise Error, "max_tokens must be positive" unless max_tokens.to_i.positive?
51
+ raise Error, "temperature must be between 0 and 2" unless (0..2).cover?(temperature.to_f)
52
+
53
+ true
32
54
  end
33
55
  end
34
56
  end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_support/concern"
4
+ require "neighbor"
5
+ require "digest"
4
6
 
5
7
  module Ragify
6
8
  # Include in ActiveRecord models to add vector embeddings and semantic search.
@@ -52,17 +54,22 @@ module Ragify
52
54
  Search.new(self).call(query, limit: limit, threshold: threshold)
53
55
  end
54
56
 
55
- # Generate embeddings for all records (or a subset).
57
+ # Generate embeddings for all records using batch API.
56
58
  #
57
59
  # FaqEntry.embed_all!
58
60
  # FaqEntry.where(embedding: nil).embed_all!
59
61
  #
60
62
  def embed_all!(batch_size: 50)
61
63
  client = EmbeddingClient.new
62
- find_each(batch_size: batch_size) do |record|
63
- text = ragify_content_proc.call(record)
64
- embedding = client.generate(text)
65
- record.update_column(:embedding, embedding) if embedding # rubocop:disable Rails/SkipsModelValidations
64
+ find_each(batch_size: batch_size).each_slice(batch_size) do |batch|
65
+ texts = batch.map { |record| ragify_content_proc.call(record) }
66
+ embeddings = client.generate_batch(texts)
67
+
68
+ batch.each_with_index do |record, i|
69
+ next unless embeddings[i]
70
+
71
+ record.update_columns(embedding: embeddings[i], embedding_digest: Digest::SHA256.hexdigest(texts[i].to_s))
72
+ end
66
73
  end
67
74
  end
68
75
  end
@@ -78,13 +85,44 @@ module Ragify
78
85
  text = ragify_text
79
86
  return if text.blank?
80
87
 
88
+ if Ragify.configuration.async
89
+ ragify_async_embed
90
+ else
91
+ ragify_sync_embed(text)
92
+ end
93
+ end
94
+
95
+ def ragify_sync_embed(text)
81
96
  embedding = Ragify.embed(text)
82
- update_column(:embedding, embedding) if embedding # rubocop:disable Rails/SkipsModelValidations
97
+ return unless embedding
98
+
99
+ update_columns(embedding: embedding, embedding_digest: Digest::SHA256.hexdigest(text.to_s))
100
+ end
101
+
102
+ # Override this method to use your own async job.
103
+ #
104
+ # def ragify_async_embed
105
+ # MyEmbeddingJob.perform_later(id)
106
+ # end
107
+ #
108
+ def ragify_async_embed
109
+ Ragify.configuration.logger.warn(
110
+ "[Ragify] async=true but ragify_async_embed not overridden in #{self.class.name}. " \
111
+ "Override it to enqueue a background job."
112
+ )
83
113
  end
84
114
 
85
115
  def ragify_content_changed?
86
- # Always regenerate — the content proc may depend on any attribute
87
- true
116
+ text = ragify_text
117
+ return false if text.blank?
118
+
119
+ current_digest = Digest::SHA256.hexdigest(text.to_s)
120
+
121
+ if respond_to?(:embedding_digest) && embedding_digest.present?
122
+ current_digest != embedding_digest
123
+ else
124
+ true
125
+ end
88
126
  end
89
127
  end
90
128
  end
@@ -38,7 +38,9 @@ module Ragify
38
38
  private
39
39
 
40
40
  def request(input)
41
- HttpClient.new.post_json(OPENAI_URL, body: { model: config.embedding_model, input: input })
41
+ body = { model: config.embedding_model, input: input }
42
+ body[:dimensions] = config.embedding_dimensions if config.embedding_dimensions
43
+ HttpClient.new.post_json(OPENAI_URL, body: body)
42
44
  end
43
45
 
44
46
  def truncate(text)
@@ -5,11 +5,34 @@ require "json"
5
5
  require "uri"
6
6
 
7
7
  module Ragify
8
- # Minimal HTTP client using Net::HTTP. No external dependencies.
8
+ # Minimal HTTP client with retry/backoff. No external dependencies.
9
9
  class HttpClient
10
10
  class ApiError < Error; end
11
+ class RateLimitError < ApiError; end
12
+ class AuthenticationError < ApiError; end
13
+ class TimeoutError < ApiError; end
14
+
15
+ MAX_RETRIES = 3
16
+ BASE_BACKOFF = 1 # seconds
11
17
 
12
18
  def post_json(url, body:, timeout: 30)
19
+ retries = 0
20
+
21
+ begin
22
+ raw_request(url, body: body, timeout: timeout)
23
+ rescue RateLimitError, TimeoutError, Net::OpenTimeout, Net::ReadTimeout => e
24
+ retries += 1
25
+ if retries <= MAX_RETRIES
26
+ sleep(BASE_BACKOFF * (2**(retries - 1))) # exponential: 1s, 2s, 4s
27
+ retry
28
+ end
29
+ raise ApiError, "#{e.message} (after #{MAX_RETRIES} retries)"
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def raw_request(url, body:, timeout:)
13
36
  uri = URI.parse(url)
14
37
 
15
38
  http = Net::HTTP.new(uri.host, uri.port)
@@ -24,11 +47,16 @@ module Ragify
24
47
 
25
48
  response = http.request(request)
26
49
 
27
- unless response.is_a?(Net::HTTPSuccess)
28
- raise ApiError, "OpenAI API error #{response.code}: #{response.body}"
50
+ case response
51
+ when Net::HTTPSuccess
52
+ JSON.parse(response.body)
53
+ when Net::HTTPTooManyRequests
54
+ raise RateLimitError, "Rate limited (429)"
55
+ when Net::HTTPUnauthorized
56
+ raise AuthenticationError, "Invalid API key (401)"
57
+ else
58
+ raise ApiError, "OpenAI API error #{response.code}: #{response.body.to_s[0, 200]}"
29
59
  end
30
-
31
- JSON.parse(response.body)
32
60
  end
33
61
  end
34
62
  end
data/lib/ragify/search.rb CHANGED
@@ -2,13 +2,14 @@
2
2
 
3
3
  module Ragify
4
4
  # Semantic vector search against an Embeddable model.
5
+ # Accepts any ActiveRecord scope, so you can pre-filter by tenant, status, etc.
5
6
  #
6
- # Search.new(FaqEntry).call("how does cashback work?")
7
- # # => [{ record: #<FaqEntry>, content: "...", similarity: 0.87 }, ...]
7
+ # Search.new(FaqEntry.published).call("how does cashback work?")
8
+ # Search.new(FaqEntry.where(tenant_id: 1)).call("refund policy")
8
9
  #
9
10
  class Search
10
- def initialize(model)
11
- @model = model
11
+ def initialize(scope)
12
+ @scope = scope
12
13
  end
13
14
 
14
15
  def call(query, limit: nil, threshold: nil)
@@ -20,11 +21,11 @@ module Ragify
20
21
 
21
22
  set_ivfflat_probes
22
23
 
23
- results = @model
24
- .with_embedding
25
- .nearest_neighbors(:embedding, embedding, distance: "cosine")
26
- .limit(limit * 2)
27
- .to_a
24
+ results = @scope
25
+ .with_embedding
26
+ .nearest_neighbors(:embedding, embedding, distance: "cosine")
27
+ .limit(limit * 2)
28
+ .to_a
28
29
 
29
30
  results
30
31
  .select { |record| (1 - record.neighbor_distance) >= threshold }
@@ -35,7 +36,7 @@ module Ragify
35
36
  private
36
37
 
37
38
  def set_ivfflat_probes
38
- @model.connection.execute("SET ivfflat.probes = #{config.ivfflat_probes}")
39
+ @scope.connection.execute("SET ivfflat.probes = #{config.ivfflat_probes}")
39
40
  end
40
41
 
41
42
  def format_result(record)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ragify
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/ragify.rb CHANGED
@@ -1,12 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "ragify/version"
4
- require_relative "ragify/configuration"
5
- require_relative "ragify/http_client"
6
- require_relative "ragify/embedding_client"
7
- require_relative "ragify/embeddable"
8
- require_relative "ragify/search"
9
- require_relative "ragify/chat"
10
4
 
11
5
  module Ragify
12
6
  class Error < StandardError; end
@@ -22,8 +16,8 @@ module Ragify
22
16
  yield(configuration)
23
17
  end
24
18
 
25
- def chat(query, context_model:, system_prompt: nil, **options)
26
- Chat.new(context_model: context_model, system_prompt: system_prompt, **options).call(query)
19
+ def chat(query, context_model:, system_prompt: nil, **)
20
+ Chat.new(context_model: context_model, system_prompt: system_prompt, **).call(query)
27
21
  end
28
22
 
29
23
  def embed(text)
@@ -35,3 +29,10 @@ module Ragify
35
29
  end
36
30
  end
37
31
  end
32
+
33
+ require_relative "ragify/configuration"
34
+ require_relative "ragify/http_client"
35
+ require_relative "ragify/embedding_client"
36
+ require_relative "ragify/embeddable"
37
+ require_relative "ragify/search"
38
+ require_relative "ragify/chat"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ragify
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Pavel Skripin
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-03-23 00:00:00.000000000 Z
11
+ date: 2026-03-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord