ask-rag 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 908700d8186c0433071a87f8b6f28ec3d60f857ef9bd0a5cf474a1804f888251
4
- data.tar.gz: 9abbec0e77ea2ca596b065c67af63d92ea373da22dd33f418442119a0d32defd
3
+ metadata.gz: a55e3f456d3e444c8aa58ba2b4c0f909de86bffe87e3e08a0a50becdddd2232e
4
+ data.tar.gz: fd944ef839c03e0a5bf2b597e4867f05c835d62e6a3433529d8e5bfb4bc9ad54
5
5
  SHA512:
6
- metadata.gz: 20d21031b52e4b0594f5485bbd8e709a71fa716115676ad493ebee998085e4ce8a378d4023a94b3c9587221837c4fcd18488c09c270c306714b4ff16ab9e7b1c
7
- data.tar.gz: 2b2a31e86ae1d5adceb08782d186db4347b57aa1b15e899e8210c20dd65732c3ba7e32ff79b1109e8fe958b77ddd8e2a91361f0c389c14769a70f0bbb2bad323
6
+ metadata.gz: 698e26ae24b01752d2032ec7345b3671902e1ead593685902b308ebf81b3fdde203581184686e8a95deb02a981a42e46cf99cfb9be7b2a6bd3977f182228bd90
7
+ data.tar.gz: 722a7d7ac59603a15932adf20e4a99452f7f031e6f0623f63645701e36c1350730bc331e024666da953f185ac101bec115913fa53cc343f141e6a4eb0d220aba
data/CHANGELOG.md CHANGED
@@ -1,3 +1,47 @@
1
+ ## [0.2.0] — 2026-07-26
2
+
3
+ ### Fixed
4
+
5
+ - **Embedding model mismatch** — InMemory store now tracks the model used during `add` and reuses it for query embedding. Previously `embed_query` hardcoded `"text-embedding-3-small"`, which produced wrong-dimension vectors if documents were stored with a different model. Now raises `Ask::RAG::EmbeddingError` if query embedding fails instead of silently returning a zero vector.
6
+
7
+ - **Batch embedding** — `add` now actually uses the `batch_size` parameter to split large document sets into multiple embed API calls instead of sending everything at once.
8
+
9
+ ### Added
10
+
11
+ - **Metadata filtering** — `similarity_search` now accepts a `filter:` parameter. Only entries whose metadata matches all key/value pairs are considered for search results. Works for both InMemory and PGVector stores.
12
+
13
+ ```ruby
14
+ store.similarity_search("query", filter: { section: "api" })
15
+ ```
16
+
17
+ - **MMR (Max Marginal Relevance)** — `similarity_search` now accepts `mmr: true` and `diversity_bonus:` parameters. MMR diversifies search results by penalizing documents too similar to already-selected ones. Results include both `:score` and `:mmr_score` in metadata.
18
+
19
+ ```ruby
20
+ store.similarity_search("query", limit: 5, mmr: true, diversity_bonus: 0.3)
21
+ ```
22
+
23
+ - **Directory loader** — `Ask::RAG::Loader::Directory` auto-detects file types by extension and dispatches to the appropriate loader. Walks directories recursively, skips `.git`, `node_modules`, and other non-source directories. Supports optional glob pattern.
24
+
25
+ ```ruby
26
+ loader = Ask::RAG::Loader::Directory.new
27
+ docs = loader.load("docs/")
28
+
29
+ loader = Ask::RAG::Loader::Directory.new(pattern: "**/*.md")
30
+ ```
31
+
32
+ - **Faster cosine similarity** — Uses Ruby's `matrix` gem for vectorized computation when available, with a pure Ruby fallback.
33
+
34
+ - **`Ask::RAG::EmbeddingError`** — raised when embedding fails during query, with a clear error message.
35
+
36
+ ### Changed
37
+
38
+ - `VectorStore#similarity_search` signature updated to accept `filter:`, `mmr:`, and `diversity_bonus:` keyword arguments.
39
+
40
+ ### Tested
41
+
42
+ - 60 tests, 148 assertions, 0 failures
43
+ - New tests for: metadata filtering, MMR scoring, directory loader, batch embedding, embedding model tracking, EmbeddingError
44
+
1
45
  ## [0.1.0] — 2026-07-26
2
46
 
3
47
  ### Added
data/README.md CHANGED
@@ -52,6 +52,7 @@ Load files into `Ask::Document` objects (text + metadata).
52
52
  | `Ask::RAG::Loader::CSV` | CSV (1 doc/row) | None (`csv` gem) |
53
53
  | `Ask::RAG::Loader::HTML` | HTML (strips script/style/nav) | `nokogiri` |
54
54
  | `Ask::RAG::Loader::PDF` | PDF (1 doc/page) | `pdf-reader` |
55
+ | `Ask::RAG::Loader::Directory` | Auto-detects file types by extension | None (uses other loaders internally) |
55
56
 
56
57
  Loaders without their dependency gem installed are skipped silently — install the gem when you need the format.
57
58
 
@@ -113,6 +114,31 @@ results.each do |doc|
113
114
  end
114
115
  ```
115
116
 
117
+ ### Metadata filtering
118
+
119
+ Filter results by document metadata:
120
+
121
+ ```ruby
122
+ results = store.similarity_search(
123
+ "authentication",
124
+ filter: { source: "api_docs.md", version: "2.0" }
125
+ )
126
+ ```
127
+
128
+ ### MMR (diversified search)
129
+
130
+ Avoid getting 5 near-identical chunks by applying Max Marginal Relevance:
131
+
132
+ ```ruby
133
+ results = store.similarity_search(
134
+ "authentication",
135
+ limit: 5,
136
+ mmr: true,
137
+ diversity_bonus: 0.3
138
+ )
139
+ # Results include both :score and :mmr_score in metadata
140
+ ```
141
+
116
142
  ### High-Level RAG Query
117
143
 
118
144
  Retrieve + prompt + answer in one call:
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module RAG
5
+ module Loader
6
+ # Walks a directory tree and loads all supported file types.
7
+ #
8
+ # Automatically detects file types by extension and dispatches to the
9
+ # appropriate loader. Ignores common non-source directories and files.
10
+ #
11
+ # @example
12
+ # loader = Ask::RAG::Loader::Directory.new
13
+ # docs = loader.load("path/to/docs/")
14
+ # # Loads all .md, .txt, .html, .csv, .pdf files recursively
15
+ #
16
+ # @example With glob pattern
17
+ # loader = Ask::RAG::Loader::Directory.new(pattern: "**/*.md")
18
+ # docs = loader.load("docs/")
19
+ # # Only loads Markdown files
20
+ #
21
+ class Directory < Base
22
+ EXTENSION_MAP = {
23
+ ".txt" => :text,
24
+ ".md" => :markdown,
25
+ ".markdown" => :markdown,
26
+ ".html" => :html,
27
+ ".htm" => :html,
28
+ ".csv" => :csv,
29
+ ".pdf" => :pdf
30
+ }.freeze
31
+
32
+ # Directories to skip when walking (matches basename).
33
+ SKIP_DIRS = %w[
34
+ .git .svn .hg .bzr _darcs node_modules .bundle vendor
35
+ .yardoc coverage tmp log public/assets
36
+ ].freeze
37
+
38
+ # @param pattern [String, nil] optional glob pattern (e.g., "**/*.md")
39
+ # When nil, walks all files with known extensions.
40
+ # @param skip_dirs [Array<String>] directory names to skip
41
+ def initialize(pattern: nil, skip_dirs: SKIP_DIRS)
42
+ @pattern = pattern
43
+ @skip_dirs = skip_dirs.map(&:freeze).freeze
44
+ end
45
+
46
+ # Load all supported files from a directory tree.
47
+ #
48
+ # @param path [String] root directory path
49
+ # @return [Array<Ask::Document>] all loaded documents
50
+ def load(path)
51
+ docs = []
52
+ files(path).each do |file|
53
+ loader = loader_for(file)
54
+ next unless loader
55
+
56
+ docs.concat(loader.load(file))
57
+ rescue StandardError => e
58
+ warn "[ask-rag] Skipping #{file}: #{e.class}: #{e.message}"
59
+ end
60
+ docs
61
+ end
62
+
63
+ private
64
+
65
+ def files(root)
66
+ if @pattern
67
+ Dir.glob(File.join(root, @pattern)).select { |f| File.file?(f) }
68
+ else
69
+ all_files(root)
70
+ end
71
+ end
72
+
73
+ def all_files(root)
74
+ result = []
75
+ Dir.entries(root).each do |entry|
76
+ next if entry.start_with?(".")
77
+ next if @skip_dirs.include?(entry)
78
+
79
+ full = File.join(root, entry)
80
+ if File.directory?(full)
81
+ result.concat(all_files(full))
82
+ elsif File.file?(full)
83
+ result << full
84
+ end
85
+ end
86
+ result
87
+ end
88
+
89
+ def loader_for(file)
90
+ ext = File.extname(file).downcase
91
+ type = EXTENSION_MAP[ext]
92
+ return nil unless type
93
+
94
+ case type
95
+ when :text then Loader::Text.new
96
+ when :markdown then Loader::Markdown.new
97
+ when :html then defined?(Loader::HTML) ? Loader::HTML.new : nil
98
+ when :csv then Loader::CSV.new
99
+ when :pdf then defined?(Loader::PDF) ? Loader::PDF.new : nil
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -2,6 +2,9 @@
2
2
 
3
3
  module Ask
4
4
  module RAG
5
+ # Raised when embedding a query fails (e.g., no API key, network error).
6
+ class EmbeddingError < StandardError; end
7
+
5
8
  # Abstract base class for vector stores.
6
9
  #
7
10
  # Vector stores store embedded documents and provide similarity search.
@@ -29,8 +32,12 @@ module Ask
29
32
  #
30
33
  # @param query [String] the query text
31
34
  # @param limit [Integer] maximum number of results
32
- # @return [Array<Ask::Document>] documents with a +score+ attribute added
33
- def similarity_search(query, limit: 10)
35
+ # @param filter [Hash, nil] metadata filter only entries whose metadata
36
+ # matches all key/value pairs are considered
37
+ # @param mmr [Boolean] apply Max Marginal Relevance for diversity
38
+ # @param diversity_bonus [Float] MMR diversity factor
39
+ # @return [Array<Ask::Document>] documents with +:score+ in metadata
40
+ def similarity_search(query, limit: 10, filter: nil, mmr: false, diversity_bonus: 0.3)
34
41
  raise NotImplementedError
35
42
  end
36
43
 
@@ -14,55 +14,91 @@ 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
18
+ # results = store.similarity_search(
19
+ # "query",
20
+ # limit: 5,
21
+ # filter: { source: "api_docs.md" }
22
+ # )
23
+ #
24
+ # @example With MMR (diversified results)
25
+ # results = store.similarity_search(
26
+ # "query",
27
+ # limit: 5,
28
+ # mmr: true,
29
+ # diversity_bonus: 0.5
30
+ # )
31
+ #
17
32
  class InMemory < VectorStore
18
33
  Entry = Struct.new(:id, :document, :vector, keyword_init: true)
19
34
 
20
35
  def initialize
21
36
  @entries = {}
22
37
  @mutex = Mutex.new
38
+ @embedding_model = nil
39
+ @embedding_dimensions = nil
23
40
  end
24
41
 
42
+ # Add documents to the store.
43
+ #
44
+ # @param documents [Array<Ask::Document>] documents to add
45
+ # @param model [String] embedding model name
46
+ # @param batch_size [Integer] texts per embed API call
47
+ # @return [Array<String>] IDs of added documents
25
48
  def add(documents, model:, batch_size: 20)
26
49
  ids = documents.map { |d| d.id || SecureRandom.uuid }
27
- texts = documents.map(&:content)
28
-
29
- # Embed using ask-llm-providers
30
50
  provider = resolve_embedding_provider(model)
31
- raw_vectors = provider.embed(texts, model: model)
32
51
 
33
- vectors = normalize_vectors(raw_vectors, texts)
52
+ @embedding_model = model
34
53
 
35
- @mutex.synchronize do
36
- documents.each_with_index do |doc, idx|
37
- @entries[ids[idx]] = Entry.new(
38
- id: ids[idx],
39
- document: doc,
40
- vector: vectors[idx]
41
- )
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)
58
+
59
+ @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
68
+ end
42
69
  end
43
70
  end
44
-
45
- ids
46
71
  end
47
72
 
48
- def similarity_search(query, limit: 10)
49
- query_vector = embed_query(query)
73
+ # Search for documents similar to the query.
74
+ #
75
+ # @param query [String] the query text
76
+ # @param limit [Integer] maximum results (default: 10)
77
+ # @param filter [Hash, nil] metadata filter — only entries whose metadata
78
+ # matches all key/value pairs are considered
79
+ # @param mmr [Boolean] apply Max Marginal Relevance for diversity
80
+ # @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
+
85
+ candidates = @mutex.synchronize do
86
+ @entries.filter_map do |_id, entry|
87
+ next if filter && !matches_filter?(entry.document.metadata, filter)
50
88
 
51
- scored = @mutex.synchronize do
52
- @entries.map do |_id, entry|
53
89
  score = cosine_similarity(query_vector, entry.vector)
54
90
  [entry, score]
55
91
  end
56
92
  end
57
93
 
58
- scored.sort_by! { |_, score| -score }
59
- scored.first(limit).map do |entry, score|
60
- doc = entry.document
61
- Ask::Document.new(
62
- content: doc.content,
63
- metadata: doc.metadata.merge(score: score),
64
- id: doc.id
65
- )
94
+ candidates.sort_by! { |_, score| -score }
95
+
96
+ if mmr
97
+ apply_mmr(candidates, query_vector, limit, diversity_bonus)
98
+ else
99
+ candidates.first(limit).map do |entry, score|
100
+ build_result(entry, score: score)
101
+ end
66
102
  end
67
103
  end
68
104
 
@@ -82,13 +118,14 @@ module Ask
82
118
 
83
119
  private
84
120
 
121
+ # --- Embedding ---
122
+
85
123
  def resolve_embedding_provider(model)
86
124
  info = Ask::ModelCatalog.find(model)
87
125
  provider_class = Ask::Provider.resolve(info.provider)
88
126
  config = build_provider_config(provider_class)
89
127
  provider_class.new(config)
90
128
  rescue Ask::ModelNotFound, Ask::UnknownProvider
91
- # Fallback: try to use OpenAI-compatible directly
92
129
  Ask::Provider.resolve(:openai).new(
93
130
  build_provider_config(Ask::Provider.resolve(:openai))
94
131
  )
@@ -107,18 +144,18 @@ module Ask
107
144
  config
108
145
  end
109
146
 
110
- def embed_query(query)
111
- provider = resolve_embedding_provider("text-embedding-3-small")
112
- raw = provider.embed(query, model: "text-embedding-3-small")
147
+ def embed_query
148
+ model = @embedding_model || "text-embedding-3-small"
149
+ provider = resolve_embedding_provider(model)
150
+ raw = provider.embed("query", model: model)
113
151
 
114
152
  if raw.is_a?(Ask::Result)
115
153
  raw = raw.output
116
154
  end
117
155
 
118
156
  Array(raw).flatten.map(&:to_f)
119
- rescue StandardError
120
- # Return a zero vector as fallback (will produce no meaningful matches)
121
- [0.0] * 256
157
+ rescue StandardError => e
158
+ raise EmbeddingError, "Failed to embed query with model #{model}: #{e.message}"
122
159
  end
123
160
 
124
161
  def normalize_vectors(raw_vectors, texts)
@@ -130,29 +167,97 @@ module Ask
130
167
 
131
168
  result = Array(result)
132
169
 
133
- # If the provider returned a flat vector for a single document
134
- if result.length == 1 && texts.length == 1
170
+ vectors = if result.length == 1 && texts.length == 1
135
171
  [Array(result.first).map(&:to_f)]
136
172
  elsif result.first.is_a?(Array)
137
173
  result.map { |v| Array(v).map(&:to_f) }
138
174
  else
139
- # Flattened try to split by dimensions
140
- dims = infer_dimensions(result)
175
+ dims = @embedding_dimensions || result.length / texts.length
141
176
  result.each_slice(dims).to_a
142
177
  end
178
+
179
+ @embedding_dimensions ||= vectors.first&.length
180
+ vectors
143
181
  end
144
182
 
145
- def infer_dimensions(flat)
146
- # Common embedding dimensions
147
- [3072, 1536, 1024, 768, 512, 384, 256].each do |d|
148
- return d if flat.length % d == 0 && flat.length / d > 0
183
+ # --- Filtering ---
184
+
185
+ def matches_filter?(metadata, filter)
186
+ filter.all? do |key, value|
187
+ metadata.key?(key) && metadata[key] == value
149
188
  end
150
- flat.length
151
189
  end
152
190
 
191
+ # --- Result building ---
192
+
193
+ def build_result(entry, score:, mmr_score: nil)
194
+ meta = entry.document.metadata.merge(score: score)
195
+ meta[:mmr_score] = mmr_score if mmr_score
196
+ Ask::Document.new(
197
+ content: entry.document.content,
198
+ metadata: meta,
199
+ id: entry.document.id
200
+ )
201
+ end
202
+
203
+ # --- MMR (Max Marginal Relevance) ---
204
+
205
+ def apply_mmr(candidates, query_vector, limit, diversity_bonus)
206
+ selected = []
207
+ remaining = candidates.dup
208
+
209
+ limit = [limit, remaining.size].min
210
+
211
+ # Pick the first result by relevance
212
+ first = remaining.shift
213
+ selected << build_result(first[0], score: first[1], mmr_score: first[1])
214
+
215
+ while selected.size < limit && remaining.any?
216
+ best_idx = 0
217
+ best_mmr = -Float::INFINITY
218
+
219
+ remaining.each_with_index do |(entry, rel_score), idx|
220
+ # Find max similarity to any already-selected document
221
+ max_sim_to_selected = selected.map do |sel_doc|
222
+ sel_entry = @entries.values.find { |e| e.document.content == sel_doc.content }
223
+ sel_entry ? cosine_similarity(entry.vector, sel_entry.vector) : 0.0
224
+ end.max
225
+
226
+ mmr_score = rel_score - diversity_bonus * max_sim_to_selected
227
+
228
+ if mmr_score > best_mmr
229
+ best_mmr = mmr_score
230
+ best_idx = idx
231
+ end
232
+ end
233
+
234
+ entry, rel_score = remaining.delete_at(best_idx)
235
+ selected << build_result(entry, score: rel_score, mmr_score: best_mmr)
236
+ end
237
+
238
+ selected
239
+ end
240
+
241
+ # --- Cosine Similarity ---
242
+
153
243
  def cosine_similarity(a, b)
154
244
  return 0.0 if a.empty? || b.empty? || a.length != b.length
155
245
 
246
+ # Use Matrix for vectorized computation if available
247
+ if defined?(Matrix)
248
+ begin
249
+ va = Matrix.row_vector(a)
250
+ vb = Matrix.row_vector(b)
251
+ dot = (va * vb.transpose)[0, 0]
252
+ norm_a = Math.sqrt((va * va.transpose)[0, 0])
253
+ norm_b = Math.sqrt((vb * vb.transpose)[0, 0])
254
+ denom = norm_a * norm_b
255
+ return denom > 0 ? dot / denom : 0.0
256
+ rescue StandardError
257
+ # Fall through to pure Ruby
258
+ end
259
+ end
260
+
156
261
  dot = 0.0
157
262
  norm_a = 0.0
158
263
  norm_b = 0.0
@@ -61,14 +61,22 @@ module Ask
61
61
  ids
62
62
  end
63
63
 
64
- def similarity_search(query, limit: 10)
64
+ def similarity_search(query, limit: 10, filter: nil, mmr: false, diversity_bonus: 0.3)
65
65
  query_vector = embed_query(query)
66
66
  column = "#{@table_name}.#{@embedding_column}"
67
67
  model_class = @model_class
68
68
 
69
- records = model_class
69
+ scope = model_class
70
70
  .select("#{@table_name}.*, 1 - (#{column} <=> '#{query_vector.to_s}') AS score")
71
71
  .where("#{column} IS NOT NULL")
72
+
73
+ if filter
74
+ filter.each do |key, value|
75
+ scope = scope.where("#{@metadata_column} @> ?", { key.to_s => value }.to_json)
76
+ end
77
+ end
78
+
79
+ records = scope
72
80
  .order(Arel.sql("#{column} <=> '#{query_vector.to_s}'"))
73
81
  .limit(limit)
74
82
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module RAG
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
data/lib/ask/rag.rb CHANGED
@@ -3,11 +3,18 @@
3
3
  require_relative "rag/version"
4
4
  require_relative "rag/document"
5
5
 
6
+ # Optional: Matrix for faster cosine similarity
7
+ begin
8
+ require "matrix"
9
+ rescue LoadError # rubocop:disable Lint/SuppressedException
10
+ end
11
+
6
12
  # Loaders
7
13
  require_relative "rag/loader/base"
8
14
  require_relative "rag/loader/text"
9
15
  require_relative "rag/loader/markdown"
10
16
  require_relative "rag/loader/csv"
17
+ require_relative "rag/loader/directory"
11
18
 
12
19
  # Optional loaders (loaded only when their dependency gem is available)
13
20
  begin
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-rag
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -109,6 +109,7 @@ files:
109
109
  - lib/ask/rag/document.rb
110
110
  - lib/ask/rag/loader/base.rb
111
111
  - lib/ask/rag/loader/csv.rb
112
+ - lib/ask/rag/loader/directory.rb
112
113
  - lib/ask/rag/loader/html.rb
113
114
  - lib/ask/rag/loader/markdown.rb
114
115
  - lib/ask/rag/loader/pdf.rb