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 +4 -4
- data/lib/generators/ragify/install_generator.rb +2 -2
- data/lib/generators/ragify/templates/add_embedding_migration.rb.erb +1 -0
- data/lib/ragify/chat.rb +9 -9
- data/lib/ragify/configuration.rb +25 -3
- data/lib/ragify/embeddable.rb +46 -8
- data/lib/ragify/embedding_client.rb +3 -1
- data/lib/ragify/http_client.rb +33 -5
- data/lib/ragify/search.rb +11 -10
- data/lib/ragify/version.rb +1 -1
- data/lib/ragify.rb +9 -8
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1fadf58c11fd46229633ce0bdb6f9b06826bd9a8f03925c33e8139817ca424de
|
|
4
|
+
data.tar.gz: 5521897995ef1d73849a737b6dfed79277d34d41cf640c9243299d3c1d2bf775
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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|
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
data/lib/ragify/configuration.rb
CHANGED
|
@@ -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(
|
|
31
|
-
|
|
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
|
data/lib/ragify/embeddable.rb
CHANGED
|
@@ -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
|
|
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 |
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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)
|
data/lib/ragify/http_client.rb
CHANGED
|
@@ -5,11 +5,34 @@ require "json"
|
|
|
5
5
|
require "uri"
|
|
6
6
|
|
|
7
7
|
module Ragify
|
|
8
|
-
# Minimal HTTP client
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
#
|
|
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(
|
|
11
|
-
@
|
|
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 = @
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
@
|
|
39
|
+
@scope.connection.execute("SET ivfflat.probes = #{config.ivfflat_probes}")
|
|
39
40
|
end
|
|
40
41
|
|
|
41
42
|
def format_result(record)
|
data/lib/ragify/version.rb
CHANGED
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, **
|
|
26
|
-
Chat.new(context_model: context_model, system_prompt: system_prompt, **
|
|
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.
|
|
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-
|
|
11
|
+
date: 2026-03-24 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activerecord
|