ask-rag 0.2.2 → 0.4.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.
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module RAG
5
+ # Embedding for the vector stores: resolving a model name to a provider,
6
+ # calling it, and validating what comes back.
7
+ #
8
+ # Every store needs this and none of it is store-specific — the same
9
+ # request goes to the same provider whether the vectors land in a Hash or
10
+ # a Postgres table. It lives here so a store adds storage, not an
11
+ # embeddings client.
12
+ #
13
+ # Including classes provide `@embedding_model` (the model the corpus was
14
+ # embedded with, reused for queries) and may provide
15
+ # `@embedding_dimensions` to help split a flat response into vectors.
16
+ module Embeddings
17
+ # Embed a batch of documents.
18
+ #
19
+ # @param texts [Array<String>] texts to embed
20
+ # @param model [String, nil] embedding model name (default: configured
21
+ # embedding_model)
22
+ # @return [Array<Array<Float>>] one vector per text
23
+ def embed_texts(texts, model: nil)
24
+ model ||= default_embedding_model
25
+ raw = embedding_provider(model).embed(texts, model: model)
26
+ normalize_vectors(unwrap_result(raw, "#{texts.length} document(s)", model), texts, model)
27
+ rescue EmbeddingError
28
+ raise
29
+ rescue StandardError => e
30
+ raise EmbeddingError, "Failed to embed #{texts.length} document(s) with model #{model}: #{e.message}"
31
+ end
32
+
33
+ # Embed one query with the model the corpus was embedded with.
34
+ #
35
+ # The model matters: a query embedded by a different model lands in a
36
+ # different vector space, where the similarity scores are meaningless
37
+ # rather than merely worse.
38
+ #
39
+ # @param text [String] the query
40
+ # @param model [String, nil] override the corpus model
41
+ # @return [Array<Float>]
42
+ def embed_query(text, model: nil)
43
+ model ||= @embedding_model || default_embedding_model
44
+ raw = embedding_provider(model).embed(text.to_s, model: model)
45
+
46
+ Array(unwrap_result(raw, "query", model)).flatten.map(&:to_f)
47
+ rescue EmbeddingError
48
+ raise
49
+ rescue StandardError => e
50
+ raise EmbeddingError, "Failed to embed query with model #{model}: #{e.message}"
51
+ end
52
+
53
+ DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small"
54
+
55
+ private
56
+
57
+ # The configured embedding model, or the historical default when the
58
+ # gem is used without configuration. This indirection keeps
59
+ # `model:` optional on every store method while preserving the
60
+ # previous default for plain Ruby users.
61
+ def default_embedding_model
62
+ Ask::RAG.configuration.embedding_model || DEFAULT_EMBEDDING_MODEL
63
+ end
64
+
65
+ # Providers return either a bare payload or an Ask::Result wrapping one.
66
+ # A failed Result must raise — coercing it to floats would silently make
67
+ # every score meaningless.
68
+ def unwrap_result(raw, subject, model)
69
+ return raw unless raw.is_a?(Ask::Result)
70
+
71
+ raise EmbeddingError, "Failed to embed #{subject} with model #{model}: #{raw.error}" unless raw.ok?
72
+
73
+ raw.output
74
+ end
75
+
76
+ # Providers vary in what they return for a batch: an array of vectors,
77
+ # or one flat array of floats that has to be sliced. The count is
78
+ # checked either way, because a mismatch would attach the wrong vector
79
+ # to a document and no later stage could detect it.
80
+ def normalize_vectors(result, texts, model)
81
+ vectors = split_vectors(Array(result), texts.length)
82
+ verify_vector_count(vectors, texts.length, model)
83
+
84
+ @embedding_dimensions ||= vectors.first&.length
85
+ vectors
86
+ end
87
+
88
+ def split_vectors(result, text_count)
89
+ return result.map { |vector| Array(vector).map(&:to_f) } if result.first.is_a?(Array)
90
+
91
+ flat = Array(result).flatten.map(&:to_f)
92
+ # A single text embeds to one flat vector. Otherwise the response is
93
+ # a batch flattened into one array and has to be sliced back into
94
+ # vectors, using the dimension seen on an earlier call when there is
95
+ # one — the provider does not label the boundaries.
96
+ return [flat] if text_count <= 1
97
+
98
+ dimensions = @embedding_dimensions || (flat.length / text_count)
99
+ return [flat] if dimensions <= 0
100
+
101
+ flat.each_slice(dimensions).to_a
102
+ end
103
+
104
+ def verify_vector_count(vectors, text_count, model)
105
+ return if vectors.length == text_count
106
+
107
+ raise EmbeddingError,
108
+ "model #{model} returned #{vectors.length} vector(s) for #{text_count} text(s)"
109
+ end
110
+
111
+ def embedding_provider(model)
112
+ info = Ask::ModelCatalog.find(model)
113
+ klass = Ask::Provider.resolve(info.provider)
114
+ klass.new(provider_config(klass))
115
+ rescue Ask::ModelNotFound, Ask::UnknownProvider
116
+ klass = Ask::Provider.resolve(:openai)
117
+ klass.new(provider_config(klass))
118
+ end
119
+
120
+ # Providers declare what they need as configuration; each
121
+ # requirement resolves from Rails credentials first
122
+ # (`ask.openai_api_key`), then the environment variable named after
123
+ # it, namespaced by provider first (`OPENAI_API_KEY`) then bare
124
+ # (`API_KEY`). Non-Rails apps skip the credentials lookup entirely.
125
+ def provider_config(klass)
126
+ config = Object.new
127
+ slug = klass.slug
128
+
129
+ (klass.configuration_requirements + klass.configuration_options).each do |option|
130
+ value = Credentials.fetch(slug, option)
131
+ config.define_singleton_method(option) { value } if value
132
+ end
133
+
134
+ config
135
+ end
136
+ end
137
+ end
138
+ end
data/lib/ask/rag/query.rb CHANGED
@@ -8,13 +8,22 @@ module Ask
8
8
  # answer a question based on those documents.
9
9
  #
10
10
  # @example
11
- # answer = Ask::RAG.query(
11
+ # answer = Ask::RAG::Query.query(
12
12
  # store: my_vector_store,
13
13
  # question: "What is the default timeout?",
14
14
  # model: "gpt-4o"
15
15
  # )
16
16
  # puts answer.content
17
17
  #
18
+ # @example Hybrid retrieval with a relevance floor
19
+ # answer = Ask::RAG::Query.query(
20
+ # store: store,
21
+ # question: "What is the default timeout?",
22
+ # model: "gpt-4o",
23
+ # hybrid: true,
24
+ # min_score: 0.35
25
+ # )
26
+ #
18
27
  module Query
19
28
  module_function
20
29
 
@@ -23,34 +32,58 @@ module Ask
23
32
  #
24
33
  # @param store [Ask::RAG::VectorStore] the vector store to search
25
34
  # @param question [String] the user's question
26
- # @param model [String] LLM model to use for answering
35
+ # @param model [String, nil] LLM model to use for answering (default:
36
+ # configured chat_model)
27
37
  # @param limit [Integer] number of documents to retrieve (default: 5)
38
+ # @param min_score [Float, nil] drop retrieved documents scoring below
39
+ # this — the main defense against answering from irrelevant context
40
+ # @param hybrid [Boolean] retrieve with hybrid search (dense + lexical)
41
+ # instead of dense alone; recommended for anything a person reads
28
42
  # @param system_prompt [String, nil] custom system prompt override
29
43
  # @param provider [Symbol, nil] optional provider override
30
44
  # @return [Ask::Document, nil] the answer with metadata including sources
31
- def query(store:, question:, model: nil, limit: 5, system_prompt: nil, provider: nil)
32
- documents = store.similarity_search(question, limit: limit)
45
+ def query(store:, question:, model: nil, limit: 5, min_score: nil, hybrid: false,
46
+ system_prompt: nil, provider: nil)
47
+ documents = retrieve(store, question, limit: limit, min_score: min_score, hybrid: hybrid)
33
48
 
49
+ # No context is a legitimate answer to return rather than a nil the
50
+ # caller has to distinguish from "the model failed": the question may
51
+ # simply be outside this corpus, and answering it from nothing is how
52
+ # a RAG system invents an answer.
34
53
  return nil if documents.empty?
35
54
 
36
- context = documents.map.with_index(1) do |doc, i|
37
- "[#{i}] #{doc.content}\n"
38
- end.join("\n")
39
-
40
- prompt = system_prompt || build_default_prompt
41
- full_prompt = "#{prompt}\n\nContext:\n#{context}\n\nQuestion: #{question}"
42
-
43
- answer = ask_llm(full_prompt, model: model, provider: provider)
55
+ model ||= Ask::RAG.configuration.chat_model
56
+ answer = ask_llm(build_prompt(documents, question, system_prompt), model: model, provider: provider)
44
57
 
45
58
  Ask::Document.new(
46
59
  content: answer,
47
60
  metadata: {
48
- sources: documents.map { |d| d.metadata[:source] }.compact.uniq,
61
+ sources: documents.map { |document| document.metadata[:source] }.compact.uniq,
49
62
  model: model
50
63
  }
51
64
  )
52
65
  end
53
66
 
67
+ # Retrieve the context documents a question will be answered from.
68
+ # Exposed so an agent can inspect retrieval without calling an LLM.
69
+ #
70
+ # @return [Array<Ask::Document>]
71
+ def retrieve(store, question, limit: 5, min_score: nil, hybrid: false)
72
+ if hybrid
73
+ store.hybrid_search(question, limit: limit, min_score: min_score)
74
+ else
75
+ store.similarity_search(question, limit: limit, min_score: min_score)
76
+ end
77
+ end
78
+
79
+ def build_prompt(documents, question, system_prompt = nil)
80
+ context = documents.map.with_index(1) do |document, index|
81
+ "[#{index}] #{document.content}\n"
82
+ end.join("\n")
83
+
84
+ "#{system_prompt || build_default_prompt}\n\nContext:\n#{context}\n\nQuestion: #{question}"
85
+ end
86
+
54
87
  # Generate an answer using the LLM.
55
88
  def ask_llm(prompt, model: nil, provider: nil) # rubocop:disable Metrics/MethodLength
56
89
  require "ask/agent" unless defined?(Ask::Agent)
@@ -73,13 +106,13 @@ module Ask
73
106
  end
74
107
 
75
108
  def fallback_ask(prompt, model)
76
- model ||= "gpt-4o"
109
+ model ||= Ask::RAG.configuration.chat_model || "gpt-4o"
77
110
  info = Ask::ModelCatalog.find(model)
78
111
  klass = Ask::Provider.resolve(info.provider)
79
112
 
80
113
  config = Object.new
81
114
  klass.configuration_requirements.each do |req|
82
- val = ENV["#{req.to_s.upcase}"]
115
+ val = Credentials.fetch(klass.slug, req)
83
116
  config.define_singleton_method(req) { val } if val
84
117
  end
85
118
 
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module Ask
6
+ module RAG
7
+ # Rails integration for ask-rag, shipped inside this gem — there is no
8
+ # separate wrapper gem.
9
+ #
10
+ # This file is required from `lib/ask/rag.rb` only when `rails/railtie`
11
+ # is loadable, so plain Ruby apps never pay for it and never need the
12
+ # `railties` gem installed. (It stays a development dependency, the
13
+ # same arrangement Kamal uses.)
14
+ #
15
+ # @example config/application.rb (or an environment file)
16
+ # config.ask_rag.embedding_model = "text-embedding-3-small"
17
+ # config.ask_rag.chat_model = "gpt-4o"
18
+ # config.ask_rag.table_name = :embeddings
19
+ class Railtie < ::Rails::Railtie
20
+ config.ask_rag = ActiveSupport::OrderedOptions.new
21
+
22
+ initializer "ask_rag.configure" do |app|
23
+ Ask::RAG.configuration.apply_ordered_options(app.config.ask_rag)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module RAG
5
+ module Retrieval
6
+ # Okapi BM25 over an in-memory document set.
7
+ #
8
+ # This is the lexical half of hybrid retrieval: the exact terms —
9
+ # error codes, method names, identifiers, product numbers — that a
10
+ # dense embedding blurs away. It plays the same role for the
11
+ # InMemory store that full-text search plays for PGVector, and both
12
+ # exist for the same reason: neither half is reliable alone.
13
+ #
14
+ # Documents are tokenized and scored per call. The InMemory store
15
+ # holds every vector in a Ruby Hash by definition, so maintaining an
16
+ # inverted index in step with add/delete would add state without
17
+ # buying anything at that scale.
18
+ #
19
+ # @example
20
+ # bm25 = Ask::RAG::Retrieval::BM25.new([["a", "Ruby on Rails"], ["b", "Python"]])
21
+ # bm25.search("rails") # => [["a", 0.69...]]
22
+ #
23
+ class BM25
24
+ K1 = 1.2
25
+ B = 0.75
26
+
27
+ # Words carrying no retrieval signal: a question matched only on
28
+ # these has matched nothing, and returning a document for it is worse
29
+ # than returning none — the caller cannot tell the difference between
30
+ # "this page is about your question" and "this page also contains the
31
+ # word 'how'".
32
+ #
33
+ # IDF alone is supposed to handle this and does not at the corpus
34
+ # sizes this gem serves: in a folder of twenty documents a stopword
35
+ # appears in enough of them to score low but still positive, so it
36
+ # survives into the results. The standard remedy is to drop them
37
+ # before scoring, which is what this list does.
38
+ STOPWORDS = %w[
39
+ a about above after again against all am an and any are as at
40
+ be because been before being below between both but by can cannot
41
+ could did do does doing down during each few for from further had
42
+ has have having he her here hers herself him himself his how i if
43
+ in into is it its itself just me more most my myself no nor not of
44
+ off on once only or other ought our ours ourselves out over own
45
+ same she should so some such than that the their theirs them
46
+ themselves then there these they this those through to too under
47
+ until up very was we were what when where which while who whom why
48
+ will with would you your yours yourself yourselves
49
+ ].freeze
50
+
51
+ # @param documents [Array<Array(Object, String)>] id/content pairs
52
+ # @param k1 [Float] term-frequency saturation
53
+ # @param b [Float] length-normalization strength (0 = none, 1 = full)
54
+ # @param stopwords [Array<String>] terms to drop before scoring;
55
+ # pass [] to keep every token (the raw Okapi BM25 behaviour)
56
+ def initialize(documents, k1: K1, b: B, stopwords: STOPWORDS) # rubocop:disable Naming/MethodParameterName -- k1 and b are the algorithm's own names
57
+ @documents = documents
58
+ @k1 = k1
59
+ @b = b
60
+ @stopwords = stopwords.to_h { |word| [word, true] }
61
+ # Documents are tokenized the same way queries are, so a query term
62
+ # can only match a token the index actually holds. Keeping stopwords
63
+ # in the documents would also inflate every length, weakening the
64
+ # length normalization that `b` controls.
65
+ @tokens = documents.map { |(_id, content)| significant_terms(content) }
66
+ @lengths = @tokens.map(&:length)
67
+ @average_length = [@lengths.sum.to_f / [@lengths.length, 1].max, 1.0].max
68
+ @document_frequency = build_document_frequency
69
+ end
70
+
71
+ # @param query [String] the search query
72
+ # @param limit [Integer, nil] maximum results (nil = all scoring above zero)
73
+ # @return [Array<Array(Object, Float)>] id/score pairs, best first
74
+ def search(query, limit: nil)
75
+ terms = significant_terms(query).uniq
76
+ return [] if terms.empty?
77
+
78
+ scored = @documents.each_index.filter_map do |index|
79
+ score = score_document(index, terms)
80
+ [@documents[index].first, score] if score.positive?
81
+ end
82
+
83
+ scored.sort_by! { |(id, score)| [-score, id.to_s] }
84
+ limit ? scored.first(limit) : scored
85
+ end
86
+
87
+ private
88
+
89
+ def score_document(index, terms)
90
+ counts = @tokens[index].tally
91
+ length_ratio = @lengths[index] / @average_length
92
+
93
+ terms.sum do |term|
94
+ frequency = counts[term]
95
+ next 0 unless frequency
96
+
97
+ # The Okapi BM25 term weight: saturation on term frequency
98
+ # (`k1`) and normalization by document length (`b`).
99
+ saturation = frequency * (@k1 + 1)
100
+ normalization = frequency + (@k1 * (1 - @b + (@b * length_ratio)))
101
+
102
+ idf(term) * (saturation / normalization)
103
+ end
104
+ end
105
+
106
+ # The (1 + ...) form keeps IDF positive even for terms that appear in
107
+ # most documents — the raw log form goes negative there, which would
108
+ # rank a common word below no match at all.
109
+ def idf(term)
110
+ total = @documents.length
111
+ documents_with_term = @document_frequency[term]
112
+
113
+ Math.log(1 + ((total - documents_with_term + 0.5) / (documents_with_term + 0.5)))
114
+ end
115
+
116
+ def build_document_frequency
117
+ frequency = Hash.new(0)
118
+ @tokens.each { |tokens| tokens.uniq.each { |term| frequency[term] += 1 } }
119
+ frequency
120
+ end
121
+
122
+ # Words, lowercased and stripped of punctuation. Digits are kept —
123
+ # an identifier like `error_4021` is exactly the kind of token the
124
+ # lexical half exists to match — and underscores separate, which is
125
+ # how Postgres tokenizes them too. Without that, the same query
126
+ # would match in PGVector and not in this store.
127
+ def tokenize(text)
128
+ text.to_s.downcase.scan(/[a-z0-9]+/)
129
+ end
130
+
131
+ def significant_terms(text)
132
+ tokenize(text).reject { |term| @stopwords.key?(term) }
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module RAG
5
+ module Retrieval
6
+ # Max Marginal Relevance: picks results that are relevant *and* unlike
7
+ # each other.
8
+ #
9
+ # Plain top-k returns five paraphrases of the same paragraph. The
10
+ # failure is worst exactly when retrieval works well, because
11
+ # near-duplicate chunks all score highly and crowd out the one passage
12
+ # that answers a different part of the question. MMR trades a little
13
+ # relevance for coverage: each pick maximizes
14
+ #
15
+ # relevance - diversity_bonus * (similarity to the closest pick so far)
16
+ #
17
+ # diversity_bonus = 0 is plain relevance ranking, 1 is novelty above
18
+ # all. Both stores share this implementation so their results for the
19
+ # same parameters are the same shape — a documented option that
20
+ # silently does nothing in one backend is worse than no option.
21
+ module MaxMarginalRelevance
22
+ DEFAULT_DIVERSITY_BONUS = 0.3
23
+
24
+ # One retrieval hit on its way into selection.
25
+ # @!attribute document
26
+ # @return [Ask::Document] the hit, already carrying its :score
27
+ # @!attribute relevance
28
+ # @return [Float] the retrieval score that ranked it
29
+ # @!attribute vector
30
+ # @return [Array<Float>, nil] the stored embedding, for pairwise similarity
31
+ # @!attribute mmr_score
32
+ # @return [Float, nil] the adjusted score it was selected with
33
+ Candidate = Struct.new(:document, :relevance, :vector, :mmr_score, keyword_init: true)
34
+
35
+ # @param candidates [Array<Candidate>] hits to choose from, any order
36
+ # @param limit [Integer] how many to select
37
+ # @param diversity_bonus [Float] 0 = pure relevance, 1 = pure diversity
38
+ # @param similarity [#call, nil] pairwise vector similarity
39
+ # @return [Array<Ask::Document>] selected, best first, with :mmr_score
40
+ def self.select(candidates, limit:, diversity_bonus: DEFAULT_DIVERSITY_BONUS, similarity: nil)
41
+ # Ties broken by id, because two equally relevant documents must
42
+ # select in the same order on every run — an unstable sort makes
43
+ # retrieval results irreproducible.
44
+ remaining = candidates.sort_by { |candidate| [-candidate.relevance, candidate.document.id.to_s] }
45
+ return [] if remaining.empty? || limit <= 0
46
+
47
+ similarity ||= method(:cosine_similarity)
48
+ selected = [remaining.shift.tap { |first| first.mmr_score = first.relevance }]
49
+
50
+ while selected.length < limit && remaining.any?
51
+ pick = take_best(remaining, selected, diversity_bonus, similarity)
52
+ selected << pick
53
+ end
54
+
55
+ selected.map { |candidate| with_mmr_score(candidate) }
56
+ end
57
+
58
+ # Removes and returns the candidate maximizing relevance minus
59
+ # redundancy against what is already selected.
60
+ def self.take_best(remaining, selected, diversity_bonus, similarity)
61
+ scored = remaining.each_with_index.map do |candidate, index|
62
+ penalty = diversity_bonus * closest_similarity(candidate, selected, similarity)
63
+ [index, candidate.relevance - penalty]
64
+ end
65
+
66
+ index, score = scored.max_by { |(position, value)| [value, -position] }
67
+ remaining.delete_at(index).tap { |pick| pick.mmr_score = score }
68
+ end
69
+
70
+ # The similarity to the single most similar pick, not the average:
71
+ # a document needs to be different from *everything* already chosen,
72
+ # and averaging lets one close neighbour hide behind several distant
73
+ # ones.
74
+ def self.closest_similarity(candidate, selected, similarity)
75
+ selected.map { |pick| similarity.call(candidate.vector, pick.vector) }.max || 0.0
76
+ end
77
+ private_class_method :closest_similarity
78
+
79
+ def self.with_mmr_score(candidate)
80
+ Ask::Document.new(
81
+ content: candidate.document.content,
82
+ metadata: candidate.document.metadata.merge(mmr_score: candidate.mmr_score),
83
+ id: candidate.document.id
84
+ )
85
+ end
86
+ private_class_method :with_mmr_score
87
+
88
+ # Cosine similarity between two vectors, with every degenerate input
89
+ # (nil, empty, mismatched length, zero norm) scoring 0. A query
90
+ # embedded by a different model than the corpus produces a length
91
+ # mismatch, which must read as "no similarity" rather than raising
92
+ # on a search path.
93
+ def self.cosine_similarity(left, right)
94
+ return 0.0 if left.nil? || right.nil? || left.empty? || right.empty? || left.length != right.length
95
+
96
+ dot = 0.0
97
+ left_norm = 0.0
98
+ right_norm = 0.0
99
+
100
+ left.each_index do |index|
101
+ a = left[index]
102
+ b = right[index]
103
+ dot += a * b
104
+ left_norm += a * a
105
+ right_norm += b * b
106
+ end
107
+
108
+ denominator = Math.sqrt(left_norm) * Math.sqrt(right_norm)
109
+ denominator.positive? ? dot / denominator : 0.0
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module RAG
5
+ module Retrieval
6
+ # Reciprocal Rank Fusion: merges several ranked result lists into one
7
+ # by summing 1/(k + rank) per document across the lists that found it.
8
+ #
9
+ # RRF is used here rather than normalized score addition because it
10
+ # assumes nothing about the scores being comparable — a cosine
11
+ # similarity and a ts_rank are not on the same scale, and any
12
+ # normalization between them is a guess. It only needs each list to be
13
+ # ordered correctly, which both retrieval paths guarantee.
14
+ #
15
+ # k damps the top ranks so that no single list's first result can
16
+ # dominate the fused order. 60 is the constant from the original TREC
17
+ # paper and is not worth tuning before the retrieval itself is.
18
+ #
19
+ # A document found by both a keyword and a vector search outranks a
20
+ # document found by only one, which is the whole point: agreement
21
+ # between two independent signals is the strongest relevance evidence
22
+ # available without a reranker.
23
+ module ReciprocalRankFusion
24
+ K = 60
25
+
26
+ # @param result_sets [Array<Array<Ask::Document>>] ranked lists, best first
27
+ # @param limit [Integer] maximum fused results
28
+ # @param damping [Integer] rank-damping constant
29
+ # @return [Array<Ask::Document>] fused, best first, with :rrf_score in metadata
30
+ def self.fuse(result_sets, limit:, damping: K)
31
+ scores = Hash.new(0.0)
32
+ documents = {}
33
+
34
+ result_sets.each do |results|
35
+ Array(results).each_with_index do |document, rank|
36
+ key = key_for(document)
37
+ next unless key
38
+
39
+ scores[key] += 1.0 / (damping + rank + 1)
40
+ documents[key] ||= document
41
+ end
42
+ end
43
+
44
+ scores
45
+ .sort_by { |key, score| [-score, key.to_s] }
46
+ .first(limit)
47
+ .map { |key, score| with_fusion_score(documents[key], score) }
48
+ end
49
+
50
+ # Identity for fusion. Stores return documents carrying the id they
51
+ # were added under; content is the fallback for a document that has
52
+ # none, so a hit found by both paths still merges.
53
+ def self.key_for(document)
54
+ document.id || (document.content.to_s.empty? ? nil : document.content)
55
+ end
56
+ private_class_method :key_for
57
+
58
+ # The source list's own score is kept alongside :rrf_score — the
59
+ # fused number orders the result, the original says how strongly
60
+ # each half matched.
61
+ def self.with_fusion_score(document, score)
62
+ Ask::Document.new(
63
+ content: document.content,
64
+ metadata: document.metadata.merge(rrf_score: score),
65
+ id: document.id
66
+ )
67
+ end
68
+ private_class_method :with_fusion_score
69
+ end
70
+ end
71
+ end
72
+ end