ask-rag 0.2.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: a55e3f456d3e444c8aa58ba2b4c0f909de86bffe87e3e08a0a50becdddd2232e
4
- data.tar.gz: fd944ef839c03e0a5bf2b597e4867f05c835d62e6a3433529d8e5bfb4bc9ad54
3
+ metadata.gz: b79936f65b8b4b8f7cbfc1667703b645df6f411e84e7ee543071e4fabe9e6015
4
+ data.tar.gz: 474bac5cbc7f1d3f26b3ec0115591947577a3957aff4733a815f3f96b6f63dd6
5
5
  SHA512:
6
- metadata.gz: 698e26ae24b01752d2032ec7345b3671902e1ead593685902b308ebf81b3fdde203581184686e8a95deb02a981a42e46cf99cfb9be7b2a6bd3977f182228bd90
7
- data.tar.gz: 722a7d7ac59603a15932adf20e4a99452f7f031e6f0623f63645701e36c1350730bc331e024666da953f185ac101bec115913fa53cc343f141e6a4eb0d220aba
6
+ metadata.gz: 3b7042d0c1f27aee6ceb4a21122f3c84691afb3b635a8b2f25f94a8c8cac3d633e09507f5a5b11347c241885016bdca1261be9322a0823034c4d97e11d8bce9e
7
+ data.tar.gz: 48b984042f7138262c13cd1458fa9191ed914909b35690b524cf3917e8837009644d71078908c47e1d1332b8895c4cee3ec5e453315442ef13091ff77420c6da
data/CHANGELOG.md CHANGED
@@ -1,3 +1,34 @@
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
+
1
32
  ## [0.2.0] — 2026-07-26
2
33
 
3
34
  ### Fixed
@@ -26,6 +26,7 @@ module Ask
26
26
  ".html" => :html,
27
27
  ".htm" => :html,
28
28
  ".csv" => :csv,
29
+ ".json" => :json,
29
30
  ".pdf" => :pdf
30
31
  }.freeze
31
32
 
@@ -96,6 +97,7 @@ module Ask
96
97
  when :markdown then Loader::Markdown.new
97
98
  when :html then defined?(Loader::HTML) ? Loader::HTML.new : nil
98
99
  when :csv then Loader::CSV.new
100
+ when :json then Loader::JSON.new
99
101
  when :pdf then defined?(Loader::PDF) ? Loader::PDF.new : nil
100
102
  end
101
103
  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
@@ -41,6 +41,17 @@ module Ask
41
41
  raise NotImplementedError
42
42
  end
43
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)
52
+ raise NotImplementedError
53
+ end
54
+
44
55
  # Remove documents by ID.
45
56
  # @param ids [Array<String>] document IDs to remove
46
57
  def delete(ids)
@@ -81,12 +81,22 @@ module Ask
81
81
  # @return [Array<Ask::Document>] documents with +:score+ and +:mmr_score+ in metadata
82
82
  def similarity_search(query, limit: 10, filter: nil, mmr: false, diversity_bonus: 0.3)
83
83
  query_vector = embed_query
84
+ search_by_vector(query_vector, limit: limit, filter: filter,
85
+ mmr: mmr, diversity_bonus: diversity_bonus)
86
+ end
87
+
88
+ def similarity_search_by_vector(vector, limit: 10, filter: nil)
89
+ search_by_vector(vector, limit: limit, filter: filter)
90
+ end
84
91
 
92
+ private
93
+
94
+ def search_by_vector(vector, limit:, filter: nil, mmr: false, diversity_bonus: 0.3)
85
95
  candidates = @mutex.synchronize do
86
96
  @entries.filter_map do |_id, entry|
87
97
  next if filter && !matches_filter?(entry.document.metadata, filter)
88
98
 
89
- score = cosine_similarity(query_vector, entry.vector)
99
+ score = cosine_similarity(vector, entry.vector)
90
100
  [entry, score]
91
101
  end
92
102
  end
@@ -94,7 +104,7 @@ module Ask
94
104
  candidates.sort_by! { |_, score| -score }
95
105
 
96
106
  if mmr
97
- apply_mmr(candidates, query_vector, limit, diversity_bonus)
107
+ apply_mmr(candidates, vector, limit, diversity_bonus)
98
108
  else
99
109
  candidates.first(limit).map do |entry, score|
100
110
  build_result(entry, score: score)
@@ -102,6 +112,8 @@ module Ask
102
112
  end
103
113
  end
104
114
 
115
+ public
116
+
105
117
  def delete(ids)
106
118
  @mutex.synchronize do
107
119
  ids.each { |id| @entries.delete(id) }
@@ -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]
@@ -63,11 +64,16 @@ module Ask
63
64
 
64
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
75
  scope = model_class
70
- .select("#{@table_name}.*, 1 - (#{column} <=> '#{query_vector.to_s}') AS score")
76
+ .select("#{@table_name}.*, 1 - (#{column} <=> '#{vector_str}') AS score")
71
77
  .where("#{column} IS NOT NULL")
72
78
 
73
79
  if filter
@@ -77,16 +83,18 @@ module Ask
77
83
  end
78
84
 
79
85
  records = scope
80
- .order(Arel.sql("#{column} <=> '#{query_vector.to_s}'"))
86
+ .order(Arel.sql("#{column} <=> '#{vector_str}'"))
81
87
  .limit(limit)
82
88
 
83
89
  records.map do |record|
84
- doc = Ask::Document.new(
90
+ Ask::Document.new(
85
91
  content: record.send(@content_column),
86
- 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
87
97
  )
88
- doc.define_singleton_method(:score) { record.score }
89
- doc
90
98
  end
91
99
  end
92
100
 
@@ -117,13 +125,14 @@ module Ask
117
125
  Array(raw).map { |v| Array(v).map(&:to_f) }
118
126
  end
119
127
 
120
- def embed_query(query)
121
- provider = resolve_provider("text-embedding-3-small")
122
- 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)
123
132
  raw = raw.output if raw.is_a?(Ask::Result)
124
133
  Array(raw).flatten.map(&:to_f)
125
- rescue StandardError
126
- [0.0] * 1536
134
+ rescue StandardError => e
135
+ raise EmbeddingError, "Failed to embed query with model #{model}: #{e.message}"
127
136
  end
128
137
 
129
138
  def resolve_provider(model)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module RAG
5
- VERSION = "0.2.0"
5
+ VERSION = "0.2.1"
6
6
  end
7
7
  end
data/lib/ask/rag.rb CHANGED
@@ -14,6 +14,7 @@ require_relative "rag/loader/base"
14
14
  require_relative "rag/loader/text"
15
15
  require_relative "rag/loader/markdown"
16
16
  require_relative "rag/loader/csv"
17
+ require_relative "rag/loader/json"
17
18
  require_relative "rag/loader/directory"
18
19
 
19
20
  # Optional loaders (loaded only when their dependency gem is available)
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.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -111,6 +111,7 @@ files:
111
111
  - lib/ask/rag/loader/csv.rb
112
112
  - lib/ask/rag/loader/directory.rb
113
113
  - lib/ask/rag/loader/html.rb
114
+ - lib/ask/rag/loader/json.rb
114
115
  - lib/ask/rag/loader/markdown.rb
115
116
  - lib/ask/rag/loader/pdf.rb
116
117
  - lib/ask/rag/loader/text.rb