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 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RerankerRuby
4
+ VERSION = "0.1.0"
5
+ 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: []