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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +117 -0
- data/README.md +190 -7
- data/lib/ask/rag/configuration.rb +65 -0
- data/lib/ask/rag/credentials.rb +69 -0
- data/lib/ask/rag/document_id.rb +54 -0
- data/lib/ask/rag/embeddings.rb +138 -0
- data/lib/ask/rag/query.rb +48 -15
- data/lib/ask/rag/railtie.rb +27 -0
- data/lib/ask/rag/retrieval/bm25.rb +137 -0
- data/lib/ask/rag/retrieval/max_marginal_relevance.rb +114 -0
- data/lib/ask/rag/retrieval/reciprocal_rank_fusion.rb +72 -0
- data/lib/ask/rag/vector_store/base.rb +47 -3
- data/lib/ask/rag/vector_store/in_memory.rb +120 -171
- data/lib/ask/rag/vector_store/pgvector.rb +290 -84
- data/lib/ask/rag/version.rb +1 -1
- data/lib/ask/rag.rb +25 -5
- data/lib/generators/ask_rag/install/install_generator.rb +45 -0
- data/lib/generators/ask_rag/install/templates/initializer.rb.tt +25 -0
- data/lib/generators/ask_rag/install/templates/migration.rb.tt +19 -0
- metadata +29 -4
|
@@ -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,
|
|
32
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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 { |
|
|
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 =
|
|
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
|