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.
@@ -2,7 +2,8 @@
2
2
 
3
3
  module Ask
4
4
  module RAG
5
- # Raised when embedding a query fails (e.g., no API key, network error).
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, mmr: false, diversity_bonus: 0.3)
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:, batch_size: 20)
49
- ids = documents.map { |d| d.id || SecureRandom.uuid }
50
- provider = resolve_embedding_provider(model)
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
- texts = batch.map(&:content)
56
- raw_vectors = provider.embed(texts, model: model)
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 |doc, idx|
61
- entry_id = ids[documents.index(doc)]
62
- @entries[entry_id] = Entry.new(
63
- id: entry_id,
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+ and +:mmr_score+ in metadata
82
- def similarity_search(query, limit: 10, filter: nil, mmr: false, diversity_bonus: 0.3)
83
- query_vector = embed_query
84
- search_by_vector(query_vector, limit: limit, filter: filter,
85
- mmr: mmr, diversity_bonus: diversity_bonus)
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
- private
93
-
94
- def search_by_vector(vector, limit:, filter: nil, mmr: false, diversity_bonus: 0.3)
95
- candidates = @mutex.synchronize do
96
- @entries.filter_map do |_id, entry|
97
- next if filter && !matches_filter?(entry.document.metadata, filter)
98
-
99
- score = cosine_similarity(vector, entry.vector)
100
- [entry, score]
101
- end
102
- end
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
- candidates.sort_by! { |_, score| -score }
125
+ Retrieval::ReciprocalRankFusion.fuse([dense, lexical], limit: limit)
126
+ end
105
127
 
106
- if mmr
107
- apply_mmr(candidates, vector, limit, diversity_bonus)
108
- else
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
- public
116
-
117
- def delete(ids)
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
- ids.each { |id| @entries.delete(id) }
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
- # --- Embedding ---
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
- def resolve_embedding_provider(model)
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
- def build_provider_config(provider_class)
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 embed_query
160
- model = @embedding_model || "text-embedding-3-small"
161
- provider = resolve_embedding_provider(model)
162
- raw = provider.embed("query", model: model)
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
- if raw.is_a?(Ask::Result)
165
- raw = raw.output
166
- end
171
+ score = cosine_similarity(vector, entry.vector)
172
+ next if min_score && score < min_score
167
173
 
168
- Array(raw).flatten.map(&:to_f)
169
- rescue StandardError => e
170
- raise EmbeddingError, "Failed to embed query with model #{model}: #{e.message}"
171
- end
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
- def normalize_vectors(raw_vectors, texts)
174
- result = if raw_vectors.is_a?(Ask::Result)
175
- raw_vectors.output
182
+ if mmr
183
+ Retrieval::MaxMarginalRelevance.select(candidates, limit: limit, diversity_bonus: diversity_bonus)
176
184
  else
177
- raw_vectors
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
- result = Array(result)
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
- vectors = if result.length == 1 && texts.length == 1
183
- [Array(result.first).map(&:to_f)]
184
- elsif result.first.is_a?(Array)
185
- result.map { |v| Array(v).map(&:to_f) }
186
- else
187
- dims = @embedding_dimensions || result.length / texts.length
188
- result.each_slice(dims).to_a
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:, mmr_score: nil)
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: meta,
211
- id: entry.document.id
227
+ metadata: entry.document.metadata.merge(score: score),
228
+ id: entry.id
212
229
  )
213
230
  end
214
231
 
215
- # --- MMR (Max Marginal Relevance) ---
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