ask-rag 0.1.0 → 0.2.1

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: b79936f65b8b4b8f7cbfc1667703b645df6f411e84e7ee543071e4fabe9e6015
4
+ data.tar.gz: 474bac5cbc7f1d3f26b3ec0115591947577a3957aff4733a815f3f96b6f63dd6
5
5
  SHA512:
6
- metadata.gz: 20d21031b52e4b0594f5485bbd8e709a71fa716115676ad493ebee998085e4ce8a378d4023a94b3c9587221837c4fcd18488c09c270c306714b4ff16ab9e7b1c
7
- data.tar.gz: 2b2a31e86ae1d5adceb08782d186db4347b57aa1b15e899e8210c20dd65732c3ba7e32ff79b1109e8fe958b77ddd8e2a91361f0c389c14769a70f0bbb2bad323
6
+ metadata.gz: 3b7042d0c1f27aee6ceb4a21122f3c84691afb3b635a8b2f25f94a8c8cac3d633e09507f5a5b11347c241885016bdca1261be9322a0823034c4d97e11d8bce9e
7
+ data.tar.gz: 48b984042f7138262c13cd1458fa9191ed914909b35690b524cf3917e8837009644d71078908c47e1d1332b8895c4cee3ec5e453315442ef13091ff77420c6da
data/CHANGELOG.md CHANGED
@@ -1,3 +1,78 @@
1
+ ## [0.2.1] — 2026-07-26
2
+
3
+ ### Fixed
4
+
5
+ - **PGVector model tracking** — same fix as InMemory: now tracks the embedding model used during `add` and reuses it for query embedding. Also raises `EmbeddingError` instead of silently returning a zero vector on failure.
6
+
7
+ - **PGVector document mutability** — no longer calls `define_singleton_method(:score)` on frozen `Ask::Document`. Score is now returned in `metadata[:score]` consistently with InMemory.
8
+
9
+ - **PGVector embed_query** — now accepts the query text as an argument (was hardcoded to embed the literal string "query").
10
+
11
+ ### Added
12
+
13
+ - **`similarity_search_by_vector`** — search using a raw vector instead of text. Available on both InMemory and PGVector stores. Accepts `limit:` and `filter:` params.
14
+
15
+ ```ruby
16
+ store.similarity_search_by_vector([0.1, 0.2, ...], limit: 5)
17
+ ```
18
+
19
+ - **`Ask::RAG::Loader::JSON`** — loads JSON files. Supports arrays of objects (one document per object) and single objects. Configurable `content_key` and `metadata_keys`.
20
+
21
+ ```ruby
22
+ loader = Ask::RAG::Loader::JSON.new(content_key: "body")
23
+ docs = loader.load("articles.json")
24
+ ```
25
+
26
+ - **Directory loader** now also recognizes `.json` files.
27
+
28
+ ### Tested
29
+
30
+ - 65 tests, 163 assertions, 0 failures
31
+
32
+ ## [0.2.0] — 2026-07-26
33
+
34
+ ### Fixed
35
+
36
+ - **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.
37
+
38
+ - **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.
39
+
40
+ ### Added
41
+
42
+ - **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.
43
+
44
+ ```ruby
45
+ store.similarity_search("query", filter: { section: "api" })
46
+ ```
47
+
48
+ - **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.
49
+
50
+ ```ruby
51
+ store.similarity_search("query", limit: 5, mmr: true, diversity_bonus: 0.3)
52
+ ```
53
+
54
+ - **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.
55
+
56
+ ```ruby
57
+ loader = Ask::RAG::Loader::Directory.new
58
+ docs = loader.load("docs/")
59
+
60
+ loader = Ask::RAG::Loader::Directory.new(pattern: "**/*.md")
61
+ ```
62
+
63
+ - **Faster cosine similarity** — Uses Ruby's `matrix` gem for vectorized computation when available, with a pure Ruby fallback.
64
+
65
+ - **`Ask::RAG::EmbeddingError`** — raised when embedding fails during query, with a clear error message.
66
+
67
+ ### Changed
68
+
69
+ - `VectorStore#similarity_search` signature updated to accept `filter:`, `mmr:`, and `diversity_bonus:` keyword arguments.
70
+
71
+ ### Tested
72
+
73
+ - 60 tests, 148 assertions, 0 failures
74
+ - New tests for: metadata filtering, MMR scoring, directory loader, batch embedding, embedding model tracking, EmbeddingError
75
+
1
76
  ## [0.1.0] — 2026-07-26
2
77
 
3
78
  ### 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,107 @@
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
+ ".json" => :json,
30
+ ".pdf" => :pdf
31
+ }.freeze
32
+
33
+ # Directories to skip when walking (matches basename).
34
+ SKIP_DIRS = %w[
35
+ .git .svn .hg .bzr _darcs node_modules .bundle vendor
36
+ .yardoc coverage tmp log public/assets
37
+ ].freeze
38
+
39
+ # @param pattern [String, nil] optional glob pattern (e.g., "**/*.md")
40
+ # When nil, walks all files with known extensions.
41
+ # @param skip_dirs [Array<String>] directory names to skip
42
+ def initialize(pattern: nil, skip_dirs: SKIP_DIRS)
43
+ @pattern = pattern
44
+ @skip_dirs = skip_dirs.map(&:freeze).freeze
45
+ end
46
+
47
+ # Load all supported files from a directory tree.
48
+ #
49
+ # @param path [String] root directory path
50
+ # @return [Array<Ask::Document>] all loaded documents
51
+ def load(path)
52
+ docs = []
53
+ files(path).each do |file|
54
+ loader = loader_for(file)
55
+ next unless loader
56
+
57
+ docs.concat(loader.load(file))
58
+ rescue StandardError => e
59
+ warn "[ask-rag] Skipping #{file}: #{e.class}: #{e.message}"
60
+ end
61
+ docs
62
+ end
63
+
64
+ private
65
+
66
+ def files(root)
67
+ if @pattern
68
+ Dir.glob(File.join(root, @pattern)).select { |f| File.file?(f) }
69
+ else
70
+ all_files(root)
71
+ end
72
+ end
73
+
74
+ def all_files(root)
75
+ result = []
76
+ Dir.entries(root).each do |entry|
77
+ next if entry.start_with?(".")
78
+ next if @skip_dirs.include?(entry)
79
+
80
+ full = File.join(root, entry)
81
+ if File.directory?(full)
82
+ result.concat(all_files(full))
83
+ elsif File.file?(full)
84
+ result << full
85
+ end
86
+ end
87
+ result
88
+ end
89
+
90
+ def loader_for(file)
91
+ ext = File.extname(file).downcase
92
+ type = EXTENSION_MAP[ext]
93
+ return nil unless type
94
+
95
+ case type
96
+ when :text then Loader::Text.new
97
+ when :markdown then Loader::Markdown.new
98
+ when :html then defined?(Loader::HTML) ? Loader::HTML.new : nil
99
+ when :csv then Loader::CSV.new
100
+ when :json then Loader::JSON.new
101
+ when :pdf then defined?(Loader::PDF) ? Loader::PDF.new : nil
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module RAG
5
+ module Loader
6
+ # Loads a JSON file as documents.
7
+ #
8
+ # Supports two modes:
9
+ # 1. Array of objects — each object becomes one document.
10
+ # Specify +content_key+ and +metadata_keys+ to map fields.
11
+ # 2. Single object — becomes one document.
12
+ # Specify +content_key+ for which field holds the text.
13
+ #
14
+ # @example Array of objects
15
+ # # [{ "title": "...", "body": "...", "tags": [...] }, ...]
16
+ # loader = Ask::RAG::Loader::JSON.new(content_key: "body")
17
+ # docs = loader.load("articles.json")
18
+ #
19
+ # @example With metadata mapping
20
+ # loader = Ask::RAG::Loader::JSON.new(
21
+ # content_key: "content",
22
+ # metadata_keys: ["source", "date"]
23
+ # )
24
+ #
25
+ class JSON < Base
26
+ # @param content_key [String] key whose value becomes document content
27
+ # @param metadata_keys [Array<String>, nil] keys to include in metadata
28
+ # (nil = all keys except content_key)
29
+ def initialize(content_key: "content", metadata_keys: nil)
30
+ @content_key = content_key
31
+ @metadata_keys = metadata_keys
32
+ end
33
+
34
+ def load(path)
35
+ raw = ::JSON.parse(File.read(path))
36
+ items = raw.is_a?(Array) ? raw : [raw]
37
+
38
+ items.map { |item| build_document(item, path) }
39
+ end
40
+
41
+ private
42
+
43
+ def build_document(item, path)
44
+ content = item[@content_key].to_s
45
+ metadata = { source: path }
46
+
47
+ if @metadata_keys
48
+ @metadata_keys.each do |key|
49
+ metadata[key] = item[key] if item.key?(key)
50
+ end
51
+ else
52
+ item.each do |key, value|
53
+ next if key == @content_key
54
+ metadata[key] = value if value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false
55
+ end
56
+ end
57
+
58
+ Ask::Document.new(content: content, metadata: metadata)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ 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,23 @@ 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)
41
+ raise NotImplementedError
42
+ end
43
+
44
+ # Search by a raw vector instead of a text query.
45
+ # Useful when you've already embedded the query externally.
46
+ #
47
+ # @param vector [Array<Float>] the query vector
48
+ # @param limit [Integer] maximum number of results
49
+ # @param filter [Hash, nil] metadata filter
50
+ # @return [Array<Ask::Document>] documents with +:score+ in metadata
51
+ def similarity_search_by_vector(vector, limit: 10, filter: nil)
34
52
  raise NotImplementedError
35
53
  end
36
54
 
@@ -14,58 +14,106 @@ 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
71
+ end
72
+
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
+ search_by_vector(query_vector, limit: limit, filter: filter,
85
+ mmr: mmr, diversity_bonus: diversity_bonus)
86
+ end
44
87
 
45
- ids
88
+ def similarity_search_by_vector(vector, limit: 10, filter: nil)
89
+ search_by_vector(vector, limit: limit, filter: filter)
46
90
  end
47
91
 
48
- def similarity_search(query, limit: 10)
49
- query_vector = embed_query(query)
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)
50
98
 
51
- scored = @mutex.synchronize do
52
- @entries.map do |_id, entry|
53
- score = cosine_similarity(query_vector, entry.vector)
99
+ score = cosine_similarity(vector, entry.vector)
54
100
  [entry, score]
55
101
  end
56
102
  end
57
103
 
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
- )
104
+ candidates.sort_by! { |_, score| -score }
105
+
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
66
112
  end
67
113
  end
68
114
 
115
+ public
116
+
69
117
  def delete(ids)
70
118
  @mutex.synchronize do
71
119
  ids.each { |id| @entries.delete(id) }
@@ -82,13 +130,14 @@ module Ask
82
130
 
83
131
  private
84
132
 
133
+ # --- Embedding ---
134
+
85
135
  def resolve_embedding_provider(model)
86
136
  info = Ask::ModelCatalog.find(model)
87
137
  provider_class = Ask::Provider.resolve(info.provider)
88
138
  config = build_provider_config(provider_class)
89
139
  provider_class.new(config)
90
140
  rescue Ask::ModelNotFound, Ask::UnknownProvider
91
- # Fallback: try to use OpenAI-compatible directly
92
141
  Ask::Provider.resolve(:openai).new(
93
142
  build_provider_config(Ask::Provider.resolve(:openai))
94
143
  )
@@ -107,18 +156,18 @@ module Ask
107
156
  config
108
157
  end
109
158
 
110
- def embed_query(query)
111
- provider = resolve_embedding_provider("text-embedding-3-small")
112
- raw = provider.embed(query, model: "text-embedding-3-small")
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)
113
163
 
114
164
  if raw.is_a?(Ask::Result)
115
165
  raw = raw.output
116
166
  end
117
167
 
118
168
  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
169
+ rescue StandardError => e
170
+ raise EmbeddingError, "Failed to embed query with model #{model}: #{e.message}"
122
171
  end
123
172
 
124
173
  def normalize_vectors(raw_vectors, texts)
@@ -130,29 +179,97 @@ module Ask
130
179
 
131
180
  result = Array(result)
132
181
 
133
- # If the provider returned a flat vector for a single document
134
- if result.length == 1 && texts.length == 1
182
+ vectors = if result.length == 1 && texts.length == 1
135
183
  [Array(result.first).map(&:to_f)]
136
184
  elsif result.first.is_a?(Array)
137
185
  result.map { |v| Array(v).map(&:to_f) }
138
186
  else
139
- # Flattened try to split by dimensions
140
- dims = infer_dimensions(result)
187
+ dims = @embedding_dimensions || result.length / texts.length
141
188
  result.each_slice(dims).to_a
142
189
  end
190
+
191
+ @embedding_dimensions ||= vectors.first&.length
192
+ vectors
143
193
  end
144
194
 
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
195
+ # --- Filtering ---
196
+
197
+ def matches_filter?(metadata, filter)
198
+ filter.all? do |key, value|
199
+ metadata.key?(key) && metadata[key] == value
149
200
  end
150
- flat.length
151
201
  end
152
202
 
203
+ # --- Result building ---
204
+
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
208
+ Ask::Document.new(
209
+ content: entry.document.content,
210
+ metadata: meta,
211
+ id: entry.document.id
212
+ )
213
+ end
214
+
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
+
153
255
  def cosine_similarity(a, b)
154
256
  return 0.0 if a.empty? || b.empty? || a.length != b.length
155
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
+
156
273
  dot = 0.0
157
274
  norm_a = 0.0
158
275
  norm_b = 0.0
@@ -19,7 +19,7 @@ module Ask
19
19
  #
20
20
  # @example Usage
21
21
  # store = Ask::RAG::VectorStore::PGVector.new(
22
- # table_name: :documents,
22
+ # table_name: :embeddings,
23
23
  # embedding_column: :embedding
24
24
  # )
25
25
  # store.add(chunks, model: "text-embedding-3-small")
@@ -38,18 +38,19 @@ module Ask
38
38
  @metadata_column = metadata_column.to_s
39
39
  @embedding_column = embedding_column.to_s
40
40
  @model_class = model_class || infer_model_class
41
+ @embedding_model = nil
41
42
  end
42
43
 
43
44
  def add(documents, model:, batch_size: 20)
44
45
  ids = []
45
- model_class = @model_class
46
+ @embedding_model = model
46
47
 
47
48
  documents.each_slice(batch_size) do |batch|
48
49
  texts = batch.map(&:content)
49
50
  vectors = embed_texts(texts, model)
50
51
 
51
52
  batch.each_with_index do |doc, idx|
52
- record = model_class.create!(
53
+ record = @model_class.create!(
53
54
  @content_column => doc.content,
54
55
  @metadata_column => doc.metadata,
55
56
  @embedding_column => vectors[idx]
@@ -61,24 +62,39 @@ module Ask
61
62
  ids
62
63
  end
63
64
 
64
- def similarity_search(query, limit: 10)
65
+ def similarity_search(query, limit: 10, filter: nil, mmr: false, diversity_bonus: 0.3)
65
66
  query_vector = embed_query(query)
67
+ similarity_search_by_vector(query_vector, limit: limit, filter: filter)
68
+ end
69
+
70
+ def similarity_search_by_vector(vector, limit: 10, filter: nil)
71
+ vector_str = vector.is_a?(Array) ? "[#{vector.join(',')}]" : vector.to_s
66
72
  column = "#{@table_name}.#{@embedding_column}"
67
73
  model_class = @model_class
68
74
 
69
- records = model_class
70
- .select("#{@table_name}.*, 1 - (#{column} <=> '#{query_vector.to_s}') AS score")
75
+ scope = model_class
76
+ .select("#{@table_name}.*, 1 - (#{column} <=> '#{vector_str}') AS score")
71
77
  .where("#{column} IS NOT NULL")
72
- .order(Arel.sql("#{column} <=> '#{query_vector.to_s}'"))
78
+
79
+ if filter
80
+ filter.each do |key, value|
81
+ scope = scope.where("#{@metadata_column} @> ?", { key.to_s => value }.to_json)
82
+ end
83
+ end
84
+
85
+ records = scope
86
+ .order(Arel.sql("#{column} <=> '#{vector_str}'"))
73
87
  .limit(limit)
74
88
 
75
89
  records.map do |record|
76
- doc = Ask::Document.new(
90
+ Ask::Document.new(
77
91
  content: record.send(@content_column),
78
- metadata: (record.send(@metadata_column) || {}).merge(db_id: record.id)
92
+ metadata: (record.send(@metadata_column) || {}).merge(
93
+ score: record.score,
94
+ db_id: record.id
95
+ ),
96
+ id: record.id.to_s
79
97
  )
80
- doc.define_singleton_method(:score) { record.score }
81
- doc
82
98
  end
83
99
  end
84
100
 
@@ -109,13 +125,14 @@ module Ask
109
125
  Array(raw).map { |v| Array(v).map(&:to_f) }
110
126
  end
111
127
 
112
- def embed_query(query)
113
- provider = resolve_provider("text-embedding-3-small")
114
- raw = provider.embed(query, model: "text-embedding-3-small")
128
+ def embed_query(query = "query")
129
+ model = @embedding_model || "text-embedding-3-small"
130
+ provider = resolve_provider(model)
131
+ raw = provider.embed(query, model: model)
115
132
  raw = raw.output if raw.is_a?(Ask::Result)
116
133
  Array(raw).flatten.map(&:to_f)
117
- rescue StandardError
118
- [0.0] * 1536
134
+ rescue StandardError => e
135
+ raise EmbeddingError, "Failed to embed query with model #{model}: #{e.message}"
119
136
  end
120
137
 
121
138
  def resolve_provider(model)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module RAG
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.1"
6
6
  end
7
7
  end
data/lib/ask/rag.rb CHANGED
@@ -3,11 +3,19 @@
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/json"
18
+ require_relative "rag/loader/directory"
11
19
 
12
20
  # Optional loaders (loaded only when their dependency gem is available)
13
21
  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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -109,7 +109,9 @@ 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
114
+ - lib/ask/rag/loader/json.rb
113
115
  - lib/ask/rag/loader/markdown.rb
114
116
  - lib/ask/rag/loader/pdf.rb
115
117
  - lib/ask/rag/loader/text.rb