reranker-ruby 0.1.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 +7 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE +21 -0
- data/README.md +91 -0
- data/lib/reranker_ruby/base.rb +79 -0
- data/lib/reranker_ruby/batch.rb +44 -0
- data/lib/reranker_ruby/cache/memory.rb +36 -0
- data/lib/reranker_ruby/cache/redis.rb +29 -0
- data/lib/reranker_ruby/cohere.rb +42 -0
- data/lib/reranker_ruby/configuration.rb +92 -0
- data/lib/reranker_ruby/ensemble.rb +72 -0
- data/lib/reranker_ruby/generators/install_generator.rb +40 -0
- data/lib/reranker_ruby/jina.rb +41 -0
- data/lib/reranker_ruby/logging.rb +58 -0
- data/lib/reranker_ruby/middleware.rb +48 -0
- data/lib/reranker_ruby/model_downloader.rb +75 -0
- data/lib/reranker_ruby/onnx.rb +80 -0
- data/lib/reranker_ruby/railtie.rb +20 -0
- data/lib/reranker_ruby/rerank_job.rb +37 -0
- data/lib/reranker_ruby/result.rb +22 -0
- data/lib/reranker_ruby/rrf.rb +15 -0
- data/lib/reranker_ruby/score_normalizer.rb +50 -0
- data/lib/reranker_ruby/version.rb +5 -0
- data/lib/reranker_ruby.rb +23 -0
- metadata +121 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0b72a0d8a77aa8b43a90df33ef1aca6e74f886795cedb9decbc2f412f6d788da
|
|
4
|
+
data.tar.gz: 236483d06f4ca930556ef2bf90a757a7b171cb5175a479cf538086030dbd71a0
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 5bed85be6192b79df97d15732b17d688d596393099e0b8ef70d117f686151fef88a409254726fdd090af0f1f708e208b070ae55867fb2d8c7ca92f328a75b0be
|
|
7
|
+
data.tar.gz: a1ed43eba939509a20439604d0783ebd49942c6b01a4934ca576d91d29aab156a4703b7ebafd00812386f0f80cb4d967c7e8184f429220074d7a8f52241f6d01
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-03-08)
|
|
4
|
+
|
|
5
|
+
- Initial release with all four phases
|
|
6
|
+
- Cohere Rerank API v2 support
|
|
7
|
+
- Jina Reranker API support
|
|
8
|
+
- Local ONNX cross-encoder inference with auto-download from HuggingFace
|
|
9
|
+
- Tokenization via tokenizers gem
|
|
10
|
+
- Sigmoid score normalization for local models
|
|
11
|
+
- Support for ms-marco-MiniLM and BGE reranker models
|
|
12
|
+
- Ensemble reranker with weighted score aggregation
|
|
13
|
+
- Score calibration (min-max, softmax, sigmoid normalization)
|
|
14
|
+
- Concurrent batch reranking with configurable thread pool
|
|
15
|
+
- Logging and metrics with event callbacks
|
|
16
|
+
- Pipeline middleware for RAG integration
|
|
17
|
+
- Rails configuration via initializer (rails generate reranker_ruby:install)
|
|
18
|
+
- ActiveJob for async reranking (RerankerRuby::RerankJob)
|
|
19
|
+
- Global configuration and convenience API (RerankerRuby.rerank)
|
|
20
|
+
- Reciprocal Rank Fusion (RRF)
|
|
21
|
+
- In-memory and Redis caching with TTL
|
|
22
|
+
- String and hash document support with metadata preservation
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Johannes Dwi Cahyo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# reranker-ruby
|
|
2
|
+
|
|
3
|
+
Cross-encoder reranking for Ruby RAG pipelines. The single biggest quality improvement you can add to retrieval-augmented generation.
|
|
4
|
+
|
|
5
|
+
Vector search finds candidates fast (approximate). Reranking makes them accurate (precise).
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "reranker-ruby"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
### Cohere Rerank
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
require "reranker_ruby"
|
|
19
|
+
|
|
20
|
+
reranker = RerankerRuby::Cohere.new(api_key: ENV["COHERE_API_KEY"])
|
|
21
|
+
|
|
22
|
+
query = "What is the capital of France?"
|
|
23
|
+
documents = [
|
|
24
|
+
"Paris is the capital and largest city of France.",
|
|
25
|
+
"France is a country in Western Europe.",
|
|
26
|
+
"The Eiffel Tower is located in Paris.",
|
|
27
|
+
"Berlin is the capital of Germany.",
|
|
28
|
+
"Lyon is the second-largest city in France."
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
results = reranker.rerank(query, documents, top_k: 3)
|
|
32
|
+
|
|
33
|
+
results.each do |r|
|
|
34
|
+
puts "#{r.score.round(4)} | #{r.text[0..60]}"
|
|
35
|
+
end
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Jina Rerank
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
reranker = RerankerRuby::Jina.new(api_key: ENV["JINA_API_KEY"])
|
|
42
|
+
results = reranker.rerank(query, documents, top_k: 3)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Hash Documents with Metadata
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
documents = [
|
|
49
|
+
{ text: "Paris is the capital...", source: "wiki", id: "doc1" },
|
|
50
|
+
{ text: "France is a country...", source: "wiki", id: "doc2" },
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
results = reranker.rerank(query, documents, top_k: 3)
|
|
54
|
+
results.first.metadata # => { source: "wiki", id: "doc1" }
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Reciprocal Rank Fusion
|
|
58
|
+
|
|
59
|
+
Combine results from multiple retrieval strategies:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
vector_results = ["doc1", "doc3", "doc5"]
|
|
63
|
+
keyword_results = ["doc2", "doc1", "doc4"]
|
|
64
|
+
|
|
65
|
+
fused = RerankerRuby::RRF.fuse(vector_results, keyword_results, k: 60)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Caching
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
reranker = RerankerRuby::Cohere.new(
|
|
72
|
+
api_key: ENV["COHERE_API_KEY"],
|
|
73
|
+
cache: RerankerRuby::Cache::Memory.new(ttl: 3600)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
reranker.rerank(query, docs, top_k: 5) # API call
|
|
77
|
+
reranker.rerank(query, docs, top_k: 5) # cache hit
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Result Object
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
result.text # => "Paris is the capital..."
|
|
84
|
+
result.score # => 0.9987
|
|
85
|
+
result.index # => 0 (original position)
|
|
86
|
+
result.metadata # => {} (preserved from input)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
|
|
91
|
+
MIT
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module RerankerRuby
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
class APIError < Error; end
|
|
10
|
+
|
|
11
|
+
class Base
|
|
12
|
+
def initialize(api_key: nil, cache: nil)
|
|
13
|
+
@api_key = api_key
|
|
14
|
+
@cache = cache
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def rerank(query, documents, top_k: 10)
|
|
18
|
+
raise NotImplementedError, "#{self.class}#rerank must be implemented"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def instrument(query:, document_count:, top_k:, &block)
|
|
24
|
+
Logging.instrument(
|
|
25
|
+
reranker_class: self.class.name,
|
|
26
|
+
query: query,
|
|
27
|
+
document_count: document_count,
|
|
28
|
+
top_k: top_k,
|
|
29
|
+
&block
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def extract_texts(documents)
|
|
34
|
+
documents.map { |d| d.is_a?(Hash) ? d[:text] || d["text"] : d.to_s }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def extract_metadata(document)
|
|
38
|
+
return {} unless document.is_a?(Hash)
|
|
39
|
+
|
|
40
|
+
document.reject { |k, _| k == :text || k == "text" }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def cache_key(query, documents)
|
|
44
|
+
require "digest"
|
|
45
|
+
Digest::SHA256.hexdigest("#{query}:#{documents.map(&:to_s).join("|")}")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def with_cache(query, documents, &block)
|
|
49
|
+
return yield unless @cache
|
|
50
|
+
|
|
51
|
+
key = cache_key(query, documents)
|
|
52
|
+
cached = @cache.get(key)
|
|
53
|
+
return cached if cached
|
|
54
|
+
|
|
55
|
+
result = yield
|
|
56
|
+
@cache.set(key, result)
|
|
57
|
+
result
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def post(url, body, headers: {})
|
|
61
|
+
uri = URI.parse(url)
|
|
62
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
63
|
+
http.use_ssl = uri.scheme == "https"
|
|
64
|
+
|
|
65
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
66
|
+
request["Content-Type"] = "application/json"
|
|
67
|
+
headers.each { |k, v| request[k] = v }
|
|
68
|
+
request.body = JSON.generate(body)
|
|
69
|
+
|
|
70
|
+
response = http.request(request)
|
|
71
|
+
|
|
72
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
73
|
+
raise APIError, "HTTP #{response.code}: #{response.body}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
JSON.parse(response.body)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RerankerRuby
|
|
4
|
+
# Batch reranking — run multiple queries against the same document set concurrently.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# results = RerankerRuby::Batch.rerank(reranker, queries, documents, top_k: 5, threads: 4)
|
|
8
|
+
# results[0] # => results for queries[0]
|
|
9
|
+
# results[1] # => results for queries[1]
|
|
10
|
+
#
|
|
11
|
+
class Batch
|
|
12
|
+
# @param reranker [Base] any reranker instance
|
|
13
|
+
# @param queries [Array<String>] list of queries
|
|
14
|
+
# @param documents [Array] shared document set
|
|
15
|
+
# @param top_k [Integer] number of results per query
|
|
16
|
+
# @param threads [Integer] concurrency level
|
|
17
|
+
# @return [Array<Array<Result>>] results per query
|
|
18
|
+
def self.rerank(reranker, queries, documents, top_k: 10, threads: 4)
|
|
19
|
+
if threads <= 1
|
|
20
|
+
return queries.map { |q| reranker.rerank(q, documents, top_k: top_k) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
results = Array.new(queries.length)
|
|
24
|
+
mutex = Mutex.new
|
|
25
|
+
queue = Queue.new
|
|
26
|
+
|
|
27
|
+
queries.each_with_index { |q, i| queue << [q, i] }
|
|
28
|
+
threads.times { queue << nil } # sentinel values
|
|
29
|
+
|
|
30
|
+
workers = threads.times.map do
|
|
31
|
+
Thread.new do
|
|
32
|
+
while (item = queue.pop)
|
|
33
|
+
query, idx = item
|
|
34
|
+
result = reranker.rerank(query, documents, top_k: top_k)
|
|
35
|
+
mutex.synchronize { results[idx] = result }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
workers.each(&:join)
|
|
41
|
+
results
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RerankerRuby
|
|
4
|
+
module Cache
|
|
5
|
+
class Memory
|
|
6
|
+
def initialize(ttl: 3600)
|
|
7
|
+
@ttl = ttl
|
|
8
|
+
@store = {}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def get(key)
|
|
12
|
+
entry = @store[key]
|
|
13
|
+
return nil unless entry
|
|
14
|
+
|
|
15
|
+
if Time.now.to_f - entry[:time] > @ttl
|
|
16
|
+
@store.delete(key)
|
|
17
|
+
return nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
entry[:value]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def set(key, value)
|
|
24
|
+
@store[key] = { value: value, time: Time.now.to_f }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def clear
|
|
28
|
+
@store.clear
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def size
|
|
32
|
+
@store.size
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RerankerRuby
|
|
4
|
+
module Cache
|
|
5
|
+
class Redis
|
|
6
|
+
def initialize(redis:, ttl: 3600, prefix: "reranker:")
|
|
7
|
+
@redis = redis
|
|
8
|
+
@ttl = ttl
|
|
9
|
+
@prefix = prefix
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def get(key)
|
|
13
|
+
data = @redis.get("#{@prefix}#{key}")
|
|
14
|
+
return nil unless data
|
|
15
|
+
|
|
16
|
+
Marshal.load(data) # rubocop:disable Security/MarshalLoad
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def set(key, value)
|
|
20
|
+
@redis.setex("#{@prefix}#{key}", @ttl, Marshal.dump(value))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def clear
|
|
24
|
+
keys = @redis.keys("#{@prefix}*")
|
|
25
|
+
@redis.del(*keys) if keys.any?
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RerankerRuby
|
|
4
|
+
class Cohere < Base
|
|
5
|
+
API_URL = "https://api.cohere.com/v2/rerank"
|
|
6
|
+
DEFAULT_MODEL = "rerank-v3.5"
|
|
7
|
+
|
|
8
|
+
def initialize(api_key:, model: DEFAULT_MODEL, **options)
|
|
9
|
+
super(**options)
|
|
10
|
+
@api_key = api_key
|
|
11
|
+
@model = model
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def rerank(query, documents, top_k: 10, model: nil)
|
|
15
|
+
instrument(query: query, document_count: documents.length, top_k: top_k) do
|
|
16
|
+
with_cache(query, documents) do
|
|
17
|
+
texts = extract_texts(documents)
|
|
18
|
+
|
|
19
|
+
response = post(API_URL, {
|
|
20
|
+
model: model || @model,
|
|
21
|
+
query: query,
|
|
22
|
+
documents: texts,
|
|
23
|
+
top_n: top_k,
|
|
24
|
+
return_documents: false
|
|
25
|
+
}, headers: {
|
|
26
|
+
"Authorization" => "Bearer #{@api_key}"
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
response["results"].map do |r|
|
|
30
|
+
idx = r["index"]
|
|
31
|
+
Result.new(
|
|
32
|
+
text: texts[idx],
|
|
33
|
+
score: r["relevance_score"],
|
|
34
|
+
index: idx,
|
|
35
|
+
metadata: extract_metadata(documents[idx])
|
|
36
|
+
)
|
|
37
|
+
end.sort
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RerankerRuby
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :default_provider, :cohere_api_key, :jina_api_key,
|
|
6
|
+
:default_model, :default_top_k, :cache_store, :cache_ttl,
|
|
7
|
+
:logger, :onnx_model, :onnx_model_path, :onnx_cache_dir
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@default_provider = :cohere
|
|
11
|
+
@cohere_api_key = nil
|
|
12
|
+
@jina_api_key = nil
|
|
13
|
+
@default_model = nil
|
|
14
|
+
@default_top_k = 10
|
|
15
|
+
@cache_store = nil
|
|
16
|
+
@cache_ttl = 3600
|
|
17
|
+
@logger = nil
|
|
18
|
+
@onnx_model = nil
|
|
19
|
+
@onnx_model_path = nil
|
|
20
|
+
@onnx_cache_dir = nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Build a reranker instance from configuration
|
|
24
|
+
def build_reranker
|
|
25
|
+
cache = build_cache
|
|
26
|
+
|
|
27
|
+
case default_provider
|
|
28
|
+
when :cohere
|
|
29
|
+
raise Error, "cohere_api_key is required" unless cohere_api_key
|
|
30
|
+
opts = { api_key: cohere_api_key, cache: cache }
|
|
31
|
+
opts[:model] = default_model if default_model
|
|
32
|
+
Cohere.new(**opts)
|
|
33
|
+
when :jina
|
|
34
|
+
raise Error, "jina_api_key is required" unless jina_api_key
|
|
35
|
+
opts = { api_key: jina_api_key, cache: cache }
|
|
36
|
+
opts[:model] = default_model if default_model
|
|
37
|
+
Jina.new(**opts)
|
|
38
|
+
when :onnx
|
|
39
|
+
opts = { cache: cache }
|
|
40
|
+
opts[:model] = onnx_model if onnx_model
|
|
41
|
+
opts[:model_path] = onnx_model_path if onnx_model_path
|
|
42
|
+
opts[:cache_dir] = onnx_cache_dir if onnx_cache_dir
|
|
43
|
+
Onnx.new(**opts)
|
|
44
|
+
else
|
|
45
|
+
raise Error, "Unknown provider: #{default_provider}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def build_cache
|
|
52
|
+
case cache_store
|
|
53
|
+
when :memory
|
|
54
|
+
Cache::Memory.new(ttl: cache_ttl)
|
|
55
|
+
when :redis
|
|
56
|
+
require "redis"
|
|
57
|
+
Cache::Redis.new(redis: ::Redis.new, ttl: cache_ttl)
|
|
58
|
+
when nil
|
|
59
|
+
nil
|
|
60
|
+
else
|
|
61
|
+
# Allow passing a pre-built cache instance
|
|
62
|
+
cache_store
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
class << self
|
|
68
|
+
def configuration
|
|
69
|
+
@configuration ||= Configuration.new
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def configure
|
|
73
|
+
yield(configuration)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def reset_configuration!
|
|
77
|
+
@configuration = Configuration.new
|
|
78
|
+
@reranker = nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Global reranker instance built from configuration
|
|
82
|
+
def reranker
|
|
83
|
+
@reranker ||= configuration.build_reranker
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Convenience method for quick reranking
|
|
87
|
+
def rerank(query, documents, top_k: nil)
|
|
88
|
+
top_k ||= configuration.default_top_k
|
|
89
|
+
reranker.rerank(query, documents, top_k: top_k)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RerankerRuby
|
|
4
|
+
# Combines results from multiple rerankers using weighted score aggregation.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# ensemble = RerankerRuby::Ensemble.new(
|
|
8
|
+
# rerankers: [cohere_reranker, jina_reranker],
|
|
9
|
+
# weights: [0.6, 0.4],
|
|
10
|
+
# normalize: :min_max
|
|
11
|
+
# )
|
|
12
|
+
# results = ensemble.rerank(query, documents, top_k: 5)
|
|
13
|
+
#
|
|
14
|
+
class Ensemble < Base
|
|
15
|
+
# @param rerankers [Array<Base>] list of reranker instances
|
|
16
|
+
# @param weights [Array<Float>, nil] weights for each reranker (default: equal)
|
|
17
|
+
# @param normalize [Symbol] normalization strategy (:min_max, :softmax, :sigmoid, or :none)
|
|
18
|
+
def initialize(rerankers:, weights: nil, normalize: :min_max, **options)
|
|
19
|
+
super(**options)
|
|
20
|
+
@rerankers = rerankers
|
|
21
|
+
@weights = weights || Array.new(rerankers.length, 1.0 / rerankers.length)
|
|
22
|
+
@normalize = normalize
|
|
23
|
+
|
|
24
|
+
if @weights.length != @rerankers.length
|
|
25
|
+
raise ArgumentError, "weights length (#{@weights.length}) must match rerankers length (#{@rerankers.length})"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def rerank(query, documents, top_k: 10)
|
|
30
|
+
with_cache(query, documents) do
|
|
31
|
+
texts = extract_texts(documents)
|
|
32
|
+
|
|
33
|
+
# Collect and normalize results from each reranker
|
|
34
|
+
all_results = @rerankers.map do |reranker|
|
|
35
|
+
raw = reranker.rerank(query, documents, top_k: texts.length)
|
|
36
|
+
normalize_results(raw)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Aggregate scores by original document index
|
|
40
|
+
aggregated = Hash.new(0.0)
|
|
41
|
+
all_results.each_with_index do |results, reranker_idx|
|
|
42
|
+
weight = @weights[reranker_idx]
|
|
43
|
+
results.each do |result|
|
|
44
|
+
aggregated[result.index] += result.score * weight
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Build final results sorted by aggregated score
|
|
49
|
+
aggregated.map do |idx, score|
|
|
50
|
+
Result.new(
|
|
51
|
+
text: texts[idx],
|
|
52
|
+
score: score,
|
|
53
|
+
index: idx,
|
|
54
|
+
metadata: extract_metadata(documents[idx])
|
|
55
|
+
)
|
|
56
|
+
end.sort.first(top_k)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def normalize_results(results)
|
|
63
|
+
case @normalize
|
|
64
|
+
when :min_max then ScoreNormalizer.min_max(results)
|
|
65
|
+
when :softmax then ScoreNormalizer.softmax(results)
|
|
66
|
+
when :sigmoid then ScoreNormalizer.sigmoid(results)
|
|
67
|
+
when :none then results
|
|
68
|
+
else raise ArgumentError, "Unknown normalization: #{@normalize}"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module RerankerRuby
|
|
6
|
+
module Generators
|
|
7
|
+
class InstallGenerator < Rails::Generators::Base
|
|
8
|
+
desc "Creates a RerankerRuby initializer file"
|
|
9
|
+
|
|
10
|
+
def create_initializer_file
|
|
11
|
+
create_file "config/initializers/reranker_ruby.rb", <<~RUBY
|
|
12
|
+
# frozen_string_literal: true
|
|
13
|
+
|
|
14
|
+
RerankerRuby.configure do |config|
|
|
15
|
+
# Choose your reranking provider: :cohere, :jina, or :onnx
|
|
16
|
+
config.default_provider = :cohere
|
|
17
|
+
|
|
18
|
+
# API keys (use credentials or environment variables)
|
|
19
|
+
config.cohere_api_key = ENV["COHERE_API_KEY"]
|
|
20
|
+
# config.jina_api_key = ENV["JINA_API_KEY"]
|
|
21
|
+
|
|
22
|
+
# Default number of top results to return
|
|
23
|
+
config.default_top_k = 10
|
|
24
|
+
|
|
25
|
+
# Optional: specify a model
|
|
26
|
+
# config.default_model = "rerank-v3.5"
|
|
27
|
+
|
|
28
|
+
# Optional: enable caching (:memory or :redis)
|
|
29
|
+
# config.cache_store = :memory
|
|
30
|
+
# config.cache_ttl = 3600
|
|
31
|
+
|
|
32
|
+
# Optional: ONNX local model settings
|
|
33
|
+
# config.default_provider = :onnx
|
|
34
|
+
# config.onnx_model = "cross-encoder/ms-marco-MiniLM-L-6-v2"
|
|
35
|
+
end
|
|
36
|
+
RUBY
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RerankerRuby
|
|
4
|
+
class Jina < Base
|
|
5
|
+
API_URL = "https://api.jina.ai/v1/rerank"
|
|
6
|
+
DEFAULT_MODEL = "jina-reranker-v2-base-multilingual"
|
|
7
|
+
|
|
8
|
+
def initialize(api_key:, model: DEFAULT_MODEL, **options)
|
|
9
|
+
super(**options)
|
|
10
|
+
@api_key = api_key
|
|
11
|
+
@model = model
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def rerank(query, documents, top_k: 10, model: nil)
|
|
15
|
+
instrument(query: query, document_count: documents.length, top_k: top_k) do
|
|
16
|
+
with_cache(query, documents) do
|
|
17
|
+
texts = extract_texts(documents)
|
|
18
|
+
|
|
19
|
+
response = post(API_URL, {
|
|
20
|
+
model: model || @model,
|
|
21
|
+
query: query,
|
|
22
|
+
documents: texts.map { |t| { text: t } },
|
|
23
|
+
top_n: top_k
|
|
24
|
+
}, headers: {
|
|
25
|
+
"Authorization" => "Bearer #{@api_key}"
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
response["results"].map do |r|
|
|
29
|
+
idx = r["index"]
|
|
30
|
+
Result.new(
|
|
31
|
+
text: texts[idx],
|
|
32
|
+
score: r["relevance_score"],
|
|
33
|
+
index: idx,
|
|
34
|
+
metadata: extract_metadata(documents[idx])
|
|
35
|
+
)
|
|
36
|
+
end.sort
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module RerankerRuby
|
|
6
|
+
# Global logging and metrics for reranking operations.
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# RerankerRuby::Logging.logger = Logger.new($stdout)
|
|
10
|
+
# RerankerRuby::Logging.on_rerank do |event|
|
|
11
|
+
# puts "#{event[:reranker]} took #{event[:duration_ms]}ms for #{event[:document_count]} docs"
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
module Logging
|
|
15
|
+
class << self
|
|
16
|
+
attr_writer :logger
|
|
17
|
+
|
|
18
|
+
def logger
|
|
19
|
+
@logger ||= Logger.new($stdout, level: Logger::WARN)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Register a callback for rerank events
|
|
23
|
+
def on_rerank(&block)
|
|
24
|
+
callbacks << block
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def callbacks
|
|
28
|
+
@callbacks ||= []
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def clear_callbacks
|
|
32
|
+
@callbacks = []
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Wrap a rerank call with logging and metrics
|
|
36
|
+
def instrument(reranker_class:, query:, document_count:, top_k:)
|
|
37
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
38
|
+
result = yield
|
|
39
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
|
|
40
|
+
|
|
41
|
+
event = {
|
|
42
|
+
reranker: reranker_class,
|
|
43
|
+
query: query,
|
|
44
|
+
document_count: document_count,
|
|
45
|
+
top_k: top_k,
|
|
46
|
+
result_count: result.length,
|
|
47
|
+
duration_ms: duration_ms,
|
|
48
|
+
top_score: result.first.respond_to?(:score) ? result.first.score : nil
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
logger.info { "[RerankerRuby] #{reranker_class} reranked #{document_count} docs in #{duration_ms}ms" }
|
|
52
|
+
callbacks.each { |cb| cb.call(event) }
|
|
53
|
+
|
|
54
|
+
result
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RerankerRuby
|
|
4
|
+
# Pipeline middleware for integration with rag-ruby or any RAG pipeline.
|
|
5
|
+
# Accepts candidates from a retrieval step and reranks them.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# middleware = RerankerRuby::Middleware.new(
|
|
9
|
+
# reranker: RerankerRuby::Cohere.new(api_key: "..."),
|
|
10
|
+
# top_k: 5,
|
|
11
|
+
# text_key: :content # key to extract text from candidate hashes
|
|
12
|
+
# )
|
|
13
|
+
#
|
|
14
|
+
# reranked = middleware.call(query: "test", candidates: candidates)
|
|
15
|
+
#
|
|
16
|
+
class Middleware
|
|
17
|
+
def initialize(reranker: nil, top_k: nil, text_key: :text)
|
|
18
|
+
@reranker = reranker
|
|
19
|
+
@top_k = top_k
|
|
20
|
+
@text_key = text_key
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call(query:, candidates:, top_k: nil)
|
|
24
|
+
reranker = @reranker || RerankerRuby.reranker
|
|
25
|
+
top_k ||= @top_k || RerankerRuby.configuration.default_top_k
|
|
26
|
+
|
|
27
|
+
documents = candidates.map do |candidate|
|
|
28
|
+
case candidate
|
|
29
|
+
when Hash
|
|
30
|
+
text = candidate[@text_key] || candidate[@text_key.to_s]
|
|
31
|
+
metadata = candidate.reject { |k, _| k == @text_key || k == @text_key.to_s }
|
|
32
|
+
{ text: text }.merge(metadata)
|
|
33
|
+
when String
|
|
34
|
+
candidate
|
|
35
|
+
else
|
|
36
|
+
# Duck-type: try to call the text_key method
|
|
37
|
+
if candidate.respond_to?(@text_key)
|
|
38
|
+
{ text: candidate.send(@text_key) }
|
|
39
|
+
else
|
|
40
|
+
candidate.to_s
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
reranker.rerank(query, documents, top_k: top_k)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module RerankerRuby
|
|
8
|
+
class ModelDownloader
|
|
9
|
+
HF_BASE_URL = "https://huggingface.co"
|
|
10
|
+
DEFAULT_CACHE_DIR = File.join(Dir.home, ".cache", "reranker-ruby", "models")
|
|
11
|
+
|
|
12
|
+
# Known ONNX model paths for popular cross-encoder models
|
|
13
|
+
ONNX_PATHS = {
|
|
14
|
+
"cross-encoder/ms-marco-MiniLM-L-6-v2" => "onnx/model.onnx",
|
|
15
|
+
"cross-encoder/ms-marco-MiniLM-L-12-v2" => "onnx/model.onnx",
|
|
16
|
+
"BAAI/bge-reranker-base" => "onnx/model.onnx",
|
|
17
|
+
"BAAI/bge-reranker-large" => "onnx/model.onnx",
|
|
18
|
+
"BAAI/bge-reranker-v2-m3" => "onnx/model.onnx"
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
def initialize(cache_dir: DEFAULT_CACHE_DIR, token: nil)
|
|
22
|
+
@cache_dir = cache_dir
|
|
23
|
+
@token = token
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Downloads model and tokenizer files, returns paths
|
|
27
|
+
# @return [Hash] { model_path:, tokenizer_path: }
|
|
28
|
+
def download(repo_id)
|
|
29
|
+
model_dir = File.join(@cache_dir, repo_id.gsub("/", "--"))
|
|
30
|
+
FileUtils.mkdir_p(model_dir)
|
|
31
|
+
|
|
32
|
+
onnx_path = ONNX_PATHS.fetch(repo_id, "onnx/model.onnx")
|
|
33
|
+
|
|
34
|
+
model_path = File.join(model_dir, "model.onnx")
|
|
35
|
+
tokenizer_path = File.join(model_dir, "tokenizer.json")
|
|
36
|
+
|
|
37
|
+
download_file(repo_id, onnx_path, model_path) unless File.exist?(model_path)
|
|
38
|
+
download_file(repo_id, "tokenizer.json", tokenizer_path) unless File.exist?(tokenizer_path)
|
|
39
|
+
|
|
40
|
+
{ model_path: model_path, tokenizer_path: tokenizer_path }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def download_file(repo_id, remote_path, local_path)
|
|
46
|
+
url = URI("#{HF_BASE_URL}/#{repo_id}/resolve/main/#{remote_path}")
|
|
47
|
+
download_with_redirects(url, local_path)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def download_with_redirects(url, local_path, limit: 5)
|
|
51
|
+
raise Error, "Too many redirects downloading #{url}" if limit == 0
|
|
52
|
+
|
|
53
|
+
Net::HTTP.start(url.host, url.port, use_ssl: url.scheme == "https") do |http|
|
|
54
|
+
request = Net::HTTP::Get.new(url)
|
|
55
|
+
request["Authorization"] = "Bearer #{@token}" if @token
|
|
56
|
+
|
|
57
|
+
http.request(request) do |response|
|
|
58
|
+
case response
|
|
59
|
+
when Net::HTTPRedirection
|
|
60
|
+
redirect_url = URI(response["location"])
|
|
61
|
+
return download_with_redirects(redirect_url, local_path, limit: limit - 1)
|
|
62
|
+
when Net::HTTPSuccess
|
|
63
|
+
File.open(local_path, "wb") do |file|
|
|
64
|
+
response.read_body { |chunk| file.write(chunk) }
|
|
65
|
+
end
|
|
66
|
+
else
|
|
67
|
+
raise Error, "Failed to download #{url}: HTTP #{response.code}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
local_path
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RerankerRuby
|
|
4
|
+
class Onnx < Base
|
|
5
|
+
DEFAULT_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
|
|
6
|
+
MAX_LENGTH = 512
|
|
7
|
+
|
|
8
|
+
def initialize(model: nil, model_path: nil, tokenizer: nil, cache_dir: nil, **options)
|
|
9
|
+
super(**options)
|
|
10
|
+
require_dependencies!
|
|
11
|
+
|
|
12
|
+
if model_path
|
|
13
|
+
@model_path = model_path
|
|
14
|
+
tokenizer_id = tokenizer || DEFAULT_MODEL
|
|
15
|
+
@tokenizer = Tokenizers.from_pretrained(tokenizer_id)
|
|
16
|
+
else
|
|
17
|
+
repo_id = model || DEFAULT_MODEL
|
|
18
|
+
downloader = ModelDownloader.new(cache_dir: cache_dir || ModelDownloader::DEFAULT_CACHE_DIR)
|
|
19
|
+
paths = downloader.download(repo_id)
|
|
20
|
+
@model_path = paths[:model_path]
|
|
21
|
+
@tokenizer = Tokenizers.from_file(paths[:tokenizer_path])
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
@session = OnnxRuntime::InferenceSession.new(@model_path)
|
|
25
|
+
@tokenizer.enable_truncation(MAX_LENGTH)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def rerank(query, documents, top_k: 10)
|
|
29
|
+
with_cache(query, documents) do
|
|
30
|
+
texts = extract_texts(documents)
|
|
31
|
+
|
|
32
|
+
scores = texts.map { |text| score_pair(query, text) }
|
|
33
|
+
|
|
34
|
+
results = texts.each_with_index.map do |text, idx|
|
|
35
|
+
Result.new(
|
|
36
|
+
text: text,
|
|
37
|
+
score: scores[idx],
|
|
38
|
+
index: idx,
|
|
39
|
+
metadata: extract_metadata(documents[idx])
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
results.sort.first(top_k)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def require_dependencies!
|
|
50
|
+
require "onnxruntime" unless defined?(OnnxRuntime)
|
|
51
|
+
require "tokenizers" unless defined?(Tokenizers)
|
|
52
|
+
rescue LoadError => e
|
|
53
|
+
raise Error, "Missing dependency for local inference: #{e.message}. " \
|
|
54
|
+
"Install with: gem install onnxruntime tokenizers"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def score_pair(query, document)
|
|
58
|
+
encoding = @tokenizer.encode(query, document, add_special_tokens: true)
|
|
59
|
+
|
|
60
|
+
inputs = {
|
|
61
|
+
"input_ids" => [encoding.ids],
|
|
62
|
+
"attention_mask" => [encoding.attention_mask]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# Some models also need token_type_ids
|
|
66
|
+
if @session.inputs.any? { |i| i[:name] == "token_type_ids" }
|
|
67
|
+
inputs["token_type_ids"] = [encoding.type_ids]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
output = @session.run(nil, inputs)
|
|
71
|
+
logit = output[0][0][0]
|
|
72
|
+
|
|
73
|
+
sigmoid(logit)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def sigmoid(x)
|
|
77
|
+
1.0 / (1.0 + Math.exp(-x))
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RerankerRuby
|
|
4
|
+
class Railtie < ::Rails::Railtie
|
|
5
|
+
initializer "reranker_ruby.configure" do
|
|
6
|
+
# Apply Rails logger if none configured
|
|
7
|
+
config.after_initialize do
|
|
8
|
+
if RerankerRuby.configuration.logger.nil?
|
|
9
|
+
RerankerRuby::Logging.logger = Rails.logger
|
|
10
|
+
else
|
|
11
|
+
RerankerRuby::Logging.logger = RerankerRuby.configuration.logger
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
generators do
|
|
17
|
+
require_relative "generators/install_generator"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RerankerRuby
|
|
4
|
+
# ActiveJob for async reranking of large result sets.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# RerankerRuby::RerankJob.perform_later(
|
|
8
|
+
# query: "What is Ruby?",
|
|
9
|
+
# documents: ["doc1", "doc2", ...],
|
|
10
|
+
# top_k: 5,
|
|
11
|
+
# callback: "MyCallback"
|
|
12
|
+
# )
|
|
13
|
+
#
|
|
14
|
+
# The callback class must implement .on_rerank_complete(query, results):
|
|
15
|
+
#
|
|
16
|
+
# class MyCallback
|
|
17
|
+
# def self.on_rerank_complete(query, results)
|
|
18
|
+
# # results is an array of hashes: [{ text:, score:, index:, metadata: }, ...]
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
class RerankJob < ActiveJob::Base
|
|
23
|
+
queue_as :default
|
|
24
|
+
|
|
25
|
+
def perform(query:, documents:, top_k: nil, callback: nil)
|
|
26
|
+
top_k ||= RerankerRuby.configuration.default_top_k
|
|
27
|
+
results = RerankerRuby.rerank(query, documents, top_k: top_k)
|
|
28
|
+
|
|
29
|
+
if callback
|
|
30
|
+
callback_class = Object.const_get(callback)
|
|
31
|
+
callback_class.on_rerank_complete(query, results.map(&:to_h))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
results
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RerankerRuby
|
|
4
|
+
class Result
|
|
5
|
+
attr_reader :text, :score, :index, :metadata
|
|
6
|
+
|
|
7
|
+
def initialize(text:, score:, index:, metadata: {})
|
|
8
|
+
@text = text
|
|
9
|
+
@score = score.to_f
|
|
10
|
+
@index = index
|
|
11
|
+
@metadata = metadata
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def to_h
|
|
15
|
+
{ text: @text, score: @score, index: @index, metadata: @metadata }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def <=>(other)
|
|
19
|
+
other.score <=> @score
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RerankerRuby
|
|
4
|
+
class RRF
|
|
5
|
+
def self.fuse(*ranked_lists, k: 60)
|
|
6
|
+
scores = Hash.new(0.0)
|
|
7
|
+
ranked_lists.each do |list|
|
|
8
|
+
list.each_with_index do |id, rank|
|
|
9
|
+
scores[id] += 1.0 / (k + rank + 1)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
scores.sort_by { |_, score| -score }.map(&:first)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RerankerRuby
|
|
4
|
+
# Normalizes scores across different reranker models to a common [0, 1] scale.
|
|
5
|
+
# Different models produce scores on different scales — this makes them comparable.
|
|
6
|
+
module ScoreNormalizer
|
|
7
|
+
# Min-max normalization to [0, 1]
|
|
8
|
+
def self.min_max(results)
|
|
9
|
+
return results if results.empty?
|
|
10
|
+
|
|
11
|
+
scores = results.map(&:score)
|
|
12
|
+
min = scores.min
|
|
13
|
+
max = scores.max
|
|
14
|
+
range = max - min
|
|
15
|
+
|
|
16
|
+
return results.map { |r| with_score(r, 1.0) } if range.zero?
|
|
17
|
+
|
|
18
|
+
results.map { |r| with_score(r, (r.score - min) / range) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Softmax normalization — scores sum to 1.0, preserves relative ordering
|
|
22
|
+
def self.softmax(results)
|
|
23
|
+
return results if results.empty?
|
|
24
|
+
|
|
25
|
+
scores = results.map(&:score)
|
|
26
|
+
max_score = scores.max
|
|
27
|
+
exps = scores.map { |s| Math.exp(s - max_score) } # subtract max for numerical stability
|
|
28
|
+
sum = exps.sum
|
|
29
|
+
|
|
30
|
+
results.each_with_index.map do |r, i|
|
|
31
|
+
with_score(r, exps[i] / sum)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Sigmoid normalization — each score independently mapped to [0, 1]
|
|
36
|
+
def self.sigmoid(results)
|
|
37
|
+
results.map { |r| with_score(r, 1.0 / (1.0 + Math.exp(-r.score))) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.with_score(result, new_score)
|
|
41
|
+
Result.new(
|
|
42
|
+
text: result.text,
|
|
43
|
+
score: new_score,
|
|
44
|
+
index: result.index,
|
|
45
|
+
metadata: result.metadata
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
private_class_method :with_score
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "reranker_ruby/version"
|
|
4
|
+
require_relative "reranker_ruby/result"
|
|
5
|
+
require_relative "reranker_ruby/base"
|
|
6
|
+
require_relative "reranker_ruby/cohere"
|
|
7
|
+
require_relative "reranker_ruby/jina"
|
|
8
|
+
require_relative "reranker_ruby/rrf"
|
|
9
|
+
require_relative "reranker_ruby/model_downloader"
|
|
10
|
+
require_relative "reranker_ruby/score_normalizer"
|
|
11
|
+
require_relative "reranker_ruby/ensemble"
|
|
12
|
+
require_relative "reranker_ruby/batch"
|
|
13
|
+
require_relative "reranker_ruby/logging"
|
|
14
|
+
require_relative "reranker_ruby/cache/memory"
|
|
15
|
+
require_relative "reranker_ruby/configuration"
|
|
16
|
+
require_relative "reranker_ruby/middleware"
|
|
17
|
+
|
|
18
|
+
module RerankerRuby
|
|
19
|
+
autoload :Onnx, "reranker_ruby/onnx"
|
|
20
|
+
autoload :RerankJob, "reranker_ruby/rerank_job"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
require_relative "reranker_ruby/railtie" if defined?(Rails::Railtie)
|
metadata
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: reranker-ruby
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Johannes Dwi Cahyo
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: logger
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: minitest
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '5.0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '5.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: rake
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '13.0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '13.0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: webmock
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '3.0'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '3.0'
|
|
68
|
+
description: A cross-encoder reranking library for Ruby. Supports Cohere, Jina, and
|
|
69
|
+
local ONNX models. The single biggest quality improvement you can add to a RAG pipeline.
|
|
70
|
+
executables: []
|
|
71
|
+
extensions: []
|
|
72
|
+
extra_rdoc_files: []
|
|
73
|
+
files:
|
|
74
|
+
- CHANGELOG.md
|
|
75
|
+
- LICENSE
|
|
76
|
+
- README.md
|
|
77
|
+
- lib/reranker_ruby.rb
|
|
78
|
+
- lib/reranker_ruby/base.rb
|
|
79
|
+
- lib/reranker_ruby/batch.rb
|
|
80
|
+
- lib/reranker_ruby/cache/memory.rb
|
|
81
|
+
- lib/reranker_ruby/cache/redis.rb
|
|
82
|
+
- lib/reranker_ruby/cohere.rb
|
|
83
|
+
- lib/reranker_ruby/configuration.rb
|
|
84
|
+
- lib/reranker_ruby/ensemble.rb
|
|
85
|
+
- lib/reranker_ruby/generators/install_generator.rb
|
|
86
|
+
- lib/reranker_ruby/jina.rb
|
|
87
|
+
- lib/reranker_ruby/logging.rb
|
|
88
|
+
- lib/reranker_ruby/middleware.rb
|
|
89
|
+
- lib/reranker_ruby/model_downloader.rb
|
|
90
|
+
- lib/reranker_ruby/onnx.rb
|
|
91
|
+
- lib/reranker_ruby/railtie.rb
|
|
92
|
+
- lib/reranker_ruby/rerank_job.rb
|
|
93
|
+
- lib/reranker_ruby/result.rb
|
|
94
|
+
- lib/reranker_ruby/rrf.rb
|
|
95
|
+
- lib/reranker_ruby/score_normalizer.rb
|
|
96
|
+
- lib/reranker_ruby/version.rb
|
|
97
|
+
homepage: https://github.com/johannesdwicahyo/reranker-ruby
|
|
98
|
+
licenses:
|
|
99
|
+
- MIT
|
|
100
|
+
metadata:
|
|
101
|
+
homepage_uri: https://github.com/johannesdwicahyo/reranker-ruby
|
|
102
|
+
source_code_uri: https://github.com/johannesdwicahyo/reranker-ruby
|
|
103
|
+
changelog_uri: https://github.com/johannesdwicahyo/reranker-ruby/blob/main/CHANGELOG.md
|
|
104
|
+
rdoc_options: []
|
|
105
|
+
require_paths:
|
|
106
|
+
- lib
|
|
107
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
108
|
+
requirements:
|
|
109
|
+
- - ">="
|
|
110
|
+
- !ruby/object:Gem::Version
|
|
111
|
+
version: 3.1.0
|
|
112
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
113
|
+
requirements:
|
|
114
|
+
- - ">="
|
|
115
|
+
- !ruby/object:Gem::Version
|
|
116
|
+
version: '0'
|
|
117
|
+
requirements: []
|
|
118
|
+
rubygems_version: 3.6.9
|
|
119
|
+
specification_version: 4
|
|
120
|
+
summary: Cross-encoder reranking for Ruby RAG pipelines
|
|
121
|
+
test_files: []
|