ask-rag 0.2.3 → 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 +26 -1
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module Ask
|
|
4
4
|
module RAG
|
|
5
|
-
# Raised when embedding a query fails (
|
|
5
|
+
# Raised when embedding a query or document fails (no API key, network
|
|
6
|
+
# error, a provider returning the wrong number of vectors).
|
|
6
7
|
class EmbeddingError < StandardError; end
|
|
7
8
|
|
|
8
9
|
# Abstract base class for vector stores.
|
|
@@ -11,15 +12,25 @@ module Ask
|
|
|
11
12
|
# Subclasses implement the storage and retrieval logic for specific
|
|
12
13
|
# backends (InMemory, PGVector, Chroma, etc.).
|
|
13
14
|
#
|
|
15
|
+
# Two retrieval modes are part of the contract, not a backend extra:
|
|
16
|
+
# +similarity_search+ (dense) and +hybrid_search+ (dense + lexical,
|
|
17
|
+
# fused). Every store implements both, so switching backends cannot
|
|
18
|
+
# silently downgrade retrieval quality.
|
|
19
|
+
#
|
|
14
20
|
# @example
|
|
15
21
|
# store = Ask::RAG::VectorStore::InMemory.new
|
|
16
22
|
# store.add(documents, model: "text-embedding-3-small")
|
|
17
23
|
# results = store.similarity_search("query text", limit: 5)
|
|
24
|
+
# results = store.hybrid_search("query text", limit: 5)
|
|
18
25
|
#
|
|
19
26
|
class VectorStore
|
|
20
27
|
# Add documents to the store. They are embedded using the given model
|
|
21
28
|
# via ask-llm-providers or a custom embedding function.
|
|
22
29
|
#
|
|
30
|
+
# Documents without an id are given a stable one derived from their
|
|
31
|
+
# source, chunk index, and content (see DocumentId), which makes
|
|
32
|
+
# re-adding the same pipeline output replace rather than duplicate.
|
|
33
|
+
#
|
|
23
34
|
# @param documents [Array<Ask::Document>] documents to add
|
|
24
35
|
# @param model [String] embedding model name (e.g. "text-embedding-3-small")
|
|
25
36
|
# @param batch_size [Integer] number of documents to embed per API call
|
|
@@ -34,10 +45,12 @@ module Ask
|
|
|
34
45
|
# @param limit [Integer] maximum number of results
|
|
35
46
|
# @param filter [Hash, nil] metadata filter — only entries whose metadata
|
|
36
47
|
# matches all key/value pairs are considered
|
|
48
|
+
# @param min_score [Float, nil] drop results scoring below this
|
|
37
49
|
# @param mmr [Boolean] apply Max Marginal Relevance for diversity
|
|
38
50
|
# @param diversity_bonus [Float] MMR diversity factor
|
|
39
51
|
# @return [Array<Ask::Document>] documents with +:score+ in metadata
|
|
40
|
-
def similarity_search(query, limit: 10, filter: nil,
|
|
52
|
+
def similarity_search(query, limit: 10, filter: nil, min_score: nil,
|
|
53
|
+
mmr: false, diversity_bonus: Retrieval::MaxMarginalRelevance::DEFAULT_DIVERSITY_BONUS)
|
|
41
54
|
raise NotImplementedError
|
|
42
55
|
end
|
|
43
56
|
|
|
@@ -47,8 +60,29 @@ module Ask
|
|
|
47
60
|
# @param vector [Array<Float>] the query vector
|
|
48
61
|
# @param limit [Integer] maximum number of results
|
|
49
62
|
# @param filter [Hash, nil] metadata filter
|
|
63
|
+
# @param min_score [Float, nil] drop results scoring below this
|
|
64
|
+
# @param mmr [Boolean] apply Max Marginal Relevance for diversity
|
|
65
|
+
# @param diversity_bonus [Float] MMR diversity factor
|
|
50
66
|
# @return [Array<Ask::Document>] documents with +:score+ in metadata
|
|
51
|
-
def similarity_search_by_vector(vector, limit: 10, filter: nil
|
|
67
|
+
def similarity_search_by_vector(vector, limit: 10, filter: nil, min_score: nil,
|
|
68
|
+
mmr: false, diversity_bonus: Retrieval::MaxMarginalRelevance::DEFAULT_DIVERSITY_BONUS)
|
|
69
|
+
raise NotImplementedError
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Search by dense and lexical retrieval at once, fused by Reciprocal
|
|
73
|
+
# Rank Fusion.
|
|
74
|
+
#
|
|
75
|
+
# Prefer this over +similarity_search+ for anything a person reads:
|
|
76
|
+
# dense retrieval alone misses exact terms (error codes, method names,
|
|
77
|
+
# identifiers), lexical alone misses paraphrase. A document both paths
|
|
78
|
+
# find outranks one only a single path found.
|
|
79
|
+
#
|
|
80
|
+
# @param query [String] the query text
|
|
81
|
+
# @param limit [Integer] maximum number of results
|
|
82
|
+
# @param filter [Hash, nil] metadata filter
|
|
83
|
+
# @param min_score [Float, nil] drop vector results scoring below this
|
|
84
|
+
# @return [Array<Ask::Document>] documents with +:rrf_score+ in metadata
|
|
85
|
+
def hybrid_search(query, limit: 10, filter: nil, min_score: nil)
|
|
52
86
|
raise NotImplementedError
|
|
53
87
|
end
|
|
54
88
|
|
|
@@ -58,6 +92,16 @@ module Ask
|
|
|
58
92
|
raise NotImplementedError
|
|
59
93
|
end
|
|
60
94
|
|
|
95
|
+
# Remove every document whose metadata matches all given pairs.
|
|
96
|
+
# This is how stale chunks are cleared after a source is edited or
|
|
97
|
+
# removed — the complement to `add`'s stable ids.
|
|
98
|
+
#
|
|
99
|
+
# @param filter [Hash] metadata key/value pairs to match
|
|
100
|
+
# @return [Integer] number of documents removed
|
|
101
|
+
def delete_by(filter)
|
|
102
|
+
raise NotImplementedError
|
|
103
|
+
end
|
|
104
|
+
|
|
61
105
|
# Remove all documents from the store.
|
|
62
106
|
def clear
|
|
63
107
|
raise NotImplementedError
|
|
@@ -14,11 +14,12 @@ module Ask
|
|
|
14
14
|
# store.add(chunks, model: "text-embedding-3-small")
|
|
15
15
|
# results = store.similarity_search("query", limit: 5)
|
|
16
16
|
#
|
|
17
|
-
# @example With metadata filtering
|
|
17
|
+
# @example With metadata filtering and a relevance floor
|
|
18
18
|
# results = store.similarity_search(
|
|
19
19
|
# "query",
|
|
20
20
|
# limit: 5,
|
|
21
|
-
# filter: { source: "api_docs.md" }
|
|
21
|
+
# filter: { source: "api_docs.md" },
|
|
22
|
+
# min_score: 0.35
|
|
22
23
|
# )
|
|
23
24
|
#
|
|
24
25
|
# @example With MMR (diversified results)
|
|
@@ -29,9 +30,18 @@ module Ask
|
|
|
29
30
|
# diversity_bonus: 0.5
|
|
30
31
|
# )
|
|
31
32
|
#
|
|
33
|
+
# @example Hybrid (dense + keyword, fused)
|
|
34
|
+
# results = store.hybrid_search("query", limit: 5)
|
|
35
|
+
#
|
|
32
36
|
class InMemory < VectorStore
|
|
37
|
+
include Embeddings
|
|
33
38
|
Entry = Struct.new(:id, :document, :vector, keyword_init: true)
|
|
34
39
|
|
|
40
|
+
# How many dense results are pulled before MMR or fusion narrows
|
|
41
|
+
# them. Wider than any sane `limit` so MMR has candidates to choose
|
|
42
|
+
# between and RRF has ranks to merge.
|
|
43
|
+
CANDIDATE_LIMIT = 100
|
|
44
|
+
|
|
35
45
|
def initialize
|
|
36
46
|
@entries = {}
|
|
37
47
|
@mutex = Mutex.new
|
|
@@ -41,30 +51,28 @@ module Ask
|
|
|
41
51
|
|
|
42
52
|
# Add documents to the store.
|
|
43
53
|
#
|
|
54
|
+
# An entry replaces any existing entry with the same id, so
|
|
55
|
+
# re-indexing the same chunks updates rather than duplicates them.
|
|
56
|
+
#
|
|
44
57
|
# @param documents [Array<Ask::Document>] documents to add
|
|
45
|
-
# @param model [String] embedding model name
|
|
58
|
+
# @param model [String, nil] embedding model name (default:
|
|
59
|
+
# configured embedding_model)
|
|
46
60
|
# @param batch_size [Integer] texts per embed API call
|
|
47
61
|
# @return [Array<String>] IDs of added documents
|
|
48
|
-
def add(documents, model
|
|
49
|
-
ids = documents.map { |
|
|
50
|
-
|
|
51
|
-
|
|
62
|
+
def add(documents, model: nil, batch_size: 20)
|
|
63
|
+
ids = documents.map { |document| DocumentId.for(document) }
|
|
64
|
+
model ||= default_embedding_model
|
|
52
65
|
@embedding_model = model
|
|
53
66
|
|
|
54
|
-
documents.each_slice(batch_size).flat_map do |batch|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
vectors = normalize_vectors(raw_vectors, texts)
|
|
67
|
+
documents.each_slice(batch_size).each_with_index.flat_map do |batch, batch_index|
|
|
68
|
+
vectors = embed_texts(batch.map(&:content), model: model)
|
|
69
|
+
offset = batch_index * batch_size
|
|
58
70
|
|
|
59
71
|
@mutex.synchronize do
|
|
60
|
-
batch.each_with_index.map do |
|
|
61
|
-
|
|
62
|
-
@entries[
|
|
63
|
-
|
|
64
|
-
document: doc,
|
|
65
|
-
vector: vectors[idx]
|
|
66
|
-
)
|
|
67
|
-
entry_id
|
|
72
|
+
batch.each_with_index.map do |document, index|
|
|
73
|
+
id = ids[offset + index]
|
|
74
|
+
@entries[id] = Entry.new(id: id, document: with_id(document, id), vector: vectors[index])
|
|
75
|
+
id
|
|
68
76
|
end
|
|
69
77
|
end
|
|
70
78
|
end
|
|
@@ -72,51 +80,65 @@ module Ask
|
|
|
72
80
|
|
|
73
81
|
# Search for documents similar to the query.
|
|
74
82
|
#
|
|
83
|
+
# The query is embedded with the same model the store was populated
|
|
84
|
+
# with: a query embedded by a different model lands in a different
|
|
85
|
+
# vector space, where cosine similarity is meaningless.
|
|
86
|
+
#
|
|
75
87
|
# @param query [String] the query text
|
|
76
88
|
# @param limit [Integer] maximum results (default: 10)
|
|
77
89
|
# @param filter [Hash, nil] metadata filter — only entries whose metadata
|
|
78
90
|
# matches all key/value pairs are considered
|
|
91
|
+
# @param min_score [Float, nil] drop results scoring below this
|
|
79
92
|
# @param mmr [Boolean] apply Max Marginal Relevance for diversity
|
|
80
93
|
# @param diversity_bonus [Float] MMR diversity factor (0 = pure relevance, 1 = pure diversity)
|
|
81
|
-
# @return [Array<Ask::Document>] documents with +:score+
|
|
82
|
-
def similarity_search(query, limit: 10, filter: nil,
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
94
|
+
# @return [Array<Ask::Document>] documents with +:score+ in metadata
|
|
95
|
+
def similarity_search(query, limit: 10, filter: nil, min_score: nil,
|
|
96
|
+
mmr: false, diversity_bonus: Retrieval::MaxMarginalRelevance::DEFAULT_DIVERSITY_BONUS)
|
|
97
|
+
query_vector = embed_query(query)
|
|
98
|
+
search_by_vector(query_vector, limit: limit, filter: filter, min_score: min_score,
|
|
99
|
+
mmr: mmr, diversity_bonus: diversity_bonus)
|
|
86
100
|
end
|
|
87
101
|
|
|
88
|
-
def similarity_search_by_vector(vector, limit: 10, filter: nil)
|
|
89
|
-
search_by_vector(vector, limit: limit, filter: filter)
|
|
102
|
+
def similarity_search_by_vector(vector, limit: 10, filter: nil, min_score: nil)
|
|
103
|
+
search_by_vector(vector, limit: limit, filter: filter, min_score: min_score)
|
|
90
104
|
end
|
|
91
105
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
106
|
+
# Dense + keyword retrieval, fused by Reciprocal Rank Fusion.
|
|
107
|
+
#
|
|
108
|
+
# The keyword half is BM25 over the stored documents, which is what
|
|
109
|
+
# catches the exact terms an embedding blurs: identifiers, error
|
|
110
|
+
# codes, method names. A document both halves find outranks one that
|
|
111
|
+
# only a single half found.
|
|
112
|
+
#
|
|
113
|
+
# @param query [String] the query text
|
|
114
|
+
# @param limit [Integer] maximum results
|
|
115
|
+
# @param filter [Hash, nil] metadata filter
|
|
116
|
+
# @param min_score [Float, nil] drop vector results scoring below this
|
|
117
|
+
# @return [Array<Ask::Document>] documents with +:rrf_score+ in metadata
|
|
118
|
+
def hybrid_search(query, limit: 10, filter: nil, min_score: nil)
|
|
119
|
+
dense = similarity_search(query, limit: CANDIDATE_LIMIT, filter: filter, min_score: min_score)
|
|
120
|
+
# A query with no dense matches (all below min_score, or an empty
|
|
121
|
+
# store) can still have exact-term matches, so the keyword half
|
|
122
|
+
# runs regardless rather than short-circuiting on an empty list.
|
|
123
|
+
lexical = keyword_search(query, limit: CANDIDATE_LIMIT, filter: filter)
|
|
103
124
|
|
|
104
|
-
|
|
125
|
+
Retrieval::ReciprocalRankFusion.fuse([dense, lexical], limit: limit)
|
|
126
|
+
end
|
|
105
127
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
candidates.first(limit).map do |entry, score|
|
|
110
|
-
build_result(entry, score: score)
|
|
111
|
-
end
|
|
128
|
+
def delete(ids)
|
|
129
|
+
Array(ids).each do |id|
|
|
130
|
+
@mutex.synchronize { @entries.delete(id) }
|
|
112
131
|
end
|
|
113
132
|
end
|
|
114
133
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
134
|
+
# Remove every entry whose metadata matches all given pairs.
|
|
135
|
+
# @param filter [Hash] metadata key/value pairs to match
|
|
136
|
+
# @return [Integer] number of entries removed
|
|
137
|
+
def delete_by(filter)
|
|
118
138
|
@mutex.synchronize do
|
|
119
|
-
|
|
139
|
+
doomed = @entries.select { |_id, entry| matches_filter?(entry.document.metadata, filter) }.keys
|
|
140
|
+
doomed.each { |id| @entries.delete(id) }
|
|
141
|
+
doomed.length
|
|
120
142
|
end
|
|
121
143
|
end
|
|
122
144
|
|
|
@@ -130,66 +152,63 @@ module Ask
|
|
|
130
152
|
|
|
131
153
|
private
|
|
132
154
|
|
|
133
|
-
#
|
|
155
|
+
# The stored document carries the id it was stored under, so
|
|
156
|
+
# `similarity_search` -> `delete` composes without bookkeeping.
|
|
157
|
+
def with_id(document, id)
|
|
158
|
+
return document if document.id == id
|
|
134
159
|
|
|
135
|
-
|
|
136
|
-
info = Ask::ModelCatalog.find(model)
|
|
137
|
-
provider_class = Ask::Provider.resolve(info.provider)
|
|
138
|
-
config = build_provider_config(provider_class)
|
|
139
|
-
provider_class.new(config)
|
|
140
|
-
rescue Ask::ModelNotFound, Ask::UnknownProvider
|
|
141
|
-
Ask::Provider.resolve(:openai).new(
|
|
142
|
-
build_provider_config(Ask::Provider.resolve(:openai))
|
|
143
|
-
)
|
|
160
|
+
Ask::Document.new(content: document.content, metadata: document.metadata, id: id)
|
|
144
161
|
end
|
|
145
162
|
|
|
146
|
-
|
|
147
|
-
config = Object.new
|
|
148
|
-
provider_class.configuration_requirements.each do |req|
|
|
149
|
-
val = ENV["#{req.to_s.upcase}"] || ENV["#{provider_class.slug.upcase}_#{req.to_s.upcase}"]
|
|
150
|
-
config.define_singleton_method(req) { val } if val
|
|
151
|
-
end
|
|
152
|
-
provider_class.configuration_options.each do |opt|
|
|
153
|
-
val = ENV["#{opt.to_s.upcase}"] || ENV["#{provider_class.slug.upcase}_#{opt.to_s.upcase}"]
|
|
154
|
-
config.define_singleton_method(opt) { val } if val
|
|
155
|
-
end
|
|
156
|
-
config
|
|
157
|
-
end
|
|
163
|
+
# --- Search ---
|
|
158
164
|
|
|
159
|
-
def
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
165
|
+
def search_by_vector(vector, limit:, filter: nil, min_score: nil, mmr: false,
|
|
166
|
+
diversity_bonus: Retrieval::MaxMarginalRelevance::DEFAULT_DIVERSITY_BONUS)
|
|
167
|
+
candidates = @mutex.synchronize do
|
|
168
|
+
@entries.each_value.filter_map do |entry|
|
|
169
|
+
next if filter && !matches_filter?(entry.document.metadata, filter)
|
|
163
170
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
end
|
|
171
|
+
score = cosine_similarity(vector, entry.vector)
|
|
172
|
+
next if min_score && score < min_score
|
|
167
173
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
174
|
+
Retrieval::MaxMarginalRelevance::Candidate.new(
|
|
175
|
+
document: build_result(entry, score: score),
|
|
176
|
+
relevance: score,
|
|
177
|
+
vector: entry.vector
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
172
181
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
raw_vectors.output
|
|
182
|
+
if mmr
|
|
183
|
+
Retrieval::MaxMarginalRelevance.select(candidates, limit: limit, diversity_bonus: diversity_bonus)
|
|
176
184
|
else
|
|
177
|
-
|
|
185
|
+
candidates.sort_by { |candidate| [-candidate.relevance, candidate.document.id.to_s] }
|
|
186
|
+
.first(limit)
|
|
187
|
+
.map(&:document)
|
|
178
188
|
end
|
|
189
|
+
end
|
|
179
190
|
|
|
180
|
-
|
|
191
|
+
def keyword_search(query, limit:, filter: nil)
|
|
192
|
+
pairs = @mutex.synchronize do
|
|
193
|
+
@entries.each_value.filter_map do |entry|
|
|
194
|
+
next if filter && !matches_filter?(entry.document.metadata, filter)
|
|
181
195
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
196
|
+
[entry.id, entry.document]
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
return [] if pairs.empty?
|
|
200
|
+
|
|
201
|
+
bm25 = Retrieval::BM25.new(pairs.map { |(id, document)| [id, document.content] })
|
|
202
|
+
by_id = pairs.to_h
|
|
203
|
+
|
|
204
|
+
bm25.search(query, limit: limit).map do |(id, score)|
|
|
205
|
+
document = by_id[id]
|
|
206
|
+
Ask::Document.new(
|
|
207
|
+
content: document.content,
|
|
208
|
+
metadata: document.metadata.merge(lexical_score: score),
|
|
209
|
+
id: id
|
|
210
|
+
)
|
|
189
211
|
end
|
|
190
|
-
|
|
191
|
-
@embedding_dimensions ||= vectors.first&.length
|
|
192
|
-
vectors
|
|
193
212
|
end
|
|
194
213
|
|
|
195
214
|
# --- Filtering ---
|
|
@@ -202,86 +221,16 @@ module Ask
|
|
|
202
221
|
|
|
203
222
|
# --- Result building ---
|
|
204
223
|
|
|
205
|
-
def build_result(entry, score
|
|
206
|
-
meta = entry.document.metadata.merge(score: score)
|
|
207
|
-
meta[:mmr_score] = mmr_score if mmr_score
|
|
224
|
+
def build_result(entry, score:)
|
|
208
225
|
Ask::Document.new(
|
|
209
226
|
content: entry.document.content,
|
|
210
|
-
metadata:
|
|
211
|
-
id: entry.
|
|
227
|
+
metadata: entry.document.metadata.merge(score: score),
|
|
228
|
+
id: entry.id
|
|
212
229
|
)
|
|
213
230
|
end
|
|
214
231
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
def apply_mmr(candidates, query_vector, limit, diversity_bonus)
|
|
218
|
-
selected = []
|
|
219
|
-
remaining = candidates.dup
|
|
220
|
-
|
|
221
|
-
limit = [limit, remaining.size].min
|
|
222
|
-
|
|
223
|
-
# Pick the first result by relevance
|
|
224
|
-
first = remaining.shift
|
|
225
|
-
selected << build_result(first[0], score: first[1], mmr_score: first[1])
|
|
226
|
-
|
|
227
|
-
while selected.size < limit && remaining.any?
|
|
228
|
-
best_idx = 0
|
|
229
|
-
best_mmr = -Float::INFINITY
|
|
230
|
-
|
|
231
|
-
remaining.each_with_index do |(entry, rel_score), idx|
|
|
232
|
-
# Find max similarity to any already-selected document
|
|
233
|
-
max_sim_to_selected = selected.map do |sel_doc|
|
|
234
|
-
sel_entry = @entries.values.find { |e| e.document.content == sel_doc.content }
|
|
235
|
-
sel_entry ? cosine_similarity(entry.vector, sel_entry.vector) : 0.0
|
|
236
|
-
end.max
|
|
237
|
-
|
|
238
|
-
mmr_score = rel_score - diversity_bonus * max_sim_to_selected
|
|
239
|
-
|
|
240
|
-
if mmr_score > best_mmr
|
|
241
|
-
best_mmr = mmr_score
|
|
242
|
-
best_idx = idx
|
|
243
|
-
end
|
|
244
|
-
end
|
|
245
|
-
|
|
246
|
-
entry, rel_score = remaining.delete_at(best_idx)
|
|
247
|
-
selected << build_result(entry, score: rel_score, mmr_score: best_mmr)
|
|
248
|
-
end
|
|
249
|
-
|
|
250
|
-
selected
|
|
251
|
-
end
|
|
252
|
-
|
|
253
|
-
# --- Cosine Similarity ---
|
|
254
|
-
|
|
255
|
-
def cosine_similarity(a, b)
|
|
256
|
-
return 0.0 if a.empty? || b.empty? || a.length != b.length
|
|
257
|
-
|
|
258
|
-
# Use Matrix for vectorized computation if available
|
|
259
|
-
if defined?(Matrix)
|
|
260
|
-
begin
|
|
261
|
-
va = Matrix.row_vector(a)
|
|
262
|
-
vb = Matrix.row_vector(b)
|
|
263
|
-
dot = (va * vb.transpose)[0, 0]
|
|
264
|
-
norm_a = Math.sqrt((va * va.transpose)[0, 0])
|
|
265
|
-
norm_b = Math.sqrt((vb * vb.transpose)[0, 0])
|
|
266
|
-
denom = norm_a * norm_b
|
|
267
|
-
return denom > 0 ? dot / denom : 0.0
|
|
268
|
-
rescue StandardError
|
|
269
|
-
# Fall through to pure Ruby
|
|
270
|
-
end
|
|
271
|
-
end
|
|
272
|
-
|
|
273
|
-
dot = 0.0
|
|
274
|
-
norm_a = 0.0
|
|
275
|
-
norm_b = 0.0
|
|
276
|
-
|
|
277
|
-
a.zip(b).each do |ai, bi|
|
|
278
|
-
dot += ai * bi
|
|
279
|
-
norm_a += ai * ai
|
|
280
|
-
norm_b += bi * bi
|
|
281
|
-
end
|
|
282
|
-
|
|
283
|
-
denom = Math.sqrt(norm_a) * Math.sqrt(norm_b)
|
|
284
|
-
denom > 0 ? dot / denom : 0.0
|
|
232
|
+
def cosine_similarity(left, right)
|
|
233
|
+
Retrieval::MaxMarginalRelevance.cosine_similarity(left, right)
|
|
285
234
|
end
|
|
286
235
|
end
|
|
287
236
|
end
|