ask-rag 0.2.2 → 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.
@@ -15,7 +15,13 @@ module Ask
15
15
  # # t.jsonb :metadata
16
16
  # # t.vector :embedding, limit: 1536
17
17
  # # end
18
- # # add_index :documents, :embedding, using: :ivfflat, opclass: :vector_cosine_ops
18
+ # # add_index :documents, :embedding, using: :hnsw, opclass: :vector_cosine_ops
19
+ # #
20
+ # # # For hybrid search (recommended):
21
+ # # add_index :documents, "(to_tsvector('english', content))", using: :gin
22
+ # #
23
+ # # # Makes `add` an upsert at the database level too:
24
+ # # add_index :documents, "(metadata ->> 'id')", unique: true
19
25
  #
20
26
  # @example Usage
21
27
  # store = Ask::RAG::VectorStore::PGVector.new(
@@ -24,82 +30,151 @@ module Ask
24
30
  # )
25
31
  # store.add(chunks, model: "text-embedding-3-small")
26
32
  # results = store.similarity_search("query", limit: 5)
33
+ # results = store.hybrid_search("query", limit: 5)
34
+ #
35
+ # Document identity lives in the metadata column under +id+ (see
36
+ # DocumentId), not in the table's primary key: the table is the
37
+ # store's, the document id is the caller's. The effective primary key
38
+ # is the caller's, and `add` upserts on it.
27
39
  #
28
40
  class PGVector < VectorStore
29
- # @param table_name [Symbol] the ActiveRecord table name
30
- # @param content_column [Symbol] column storing text content (default: :content)
31
- # @param metadata_column [Symbol] column storing JSON metadata (default: :metadata)
32
- # @param embedding_column [Symbol] column storing the vector (default: :embedding)
41
+ include Embeddings
42
+ # How many dense results are pulled before MMR or fusion narrows
43
+ # them. Wider than any sane `limit` so MMR has candidates to choose
44
+ # between and RRF has ranks to merge.
45
+ CANDIDATE_LIMIT = 100
46
+
47
+ # @param table_name [Symbol, nil] the ActiveRecord table name
48
+ # (default: configured table_name)
49
+ # @param content_column [Symbol, nil] column storing text content
50
+ # (default: configured content_column)
51
+ # @param metadata_column [Symbol, nil] column storing JSON metadata
52
+ # (default: configured metadata_column)
53
+ # @param embedding_column [Symbol, nil] column storing the vector
54
+ # (default: configured embedding_column)
55
+ # @param text_search_config [String, nil] Postgres text search
56
+ # configuration used by hybrid search (default: configured)
33
57
  # @param model_class [Class, nil] ActiveRecord model class (default: inferred from table_name)
34
- def initialize(table_name:, content_column: :content, metadata_column: :metadata,
35
- embedding_column: :embedding, model_class: nil)
36
- @table_name = table_name.to_s
37
- @content_column = content_column.to_s
38
- @metadata_column = metadata_column.to_s
39
- @embedding_column = embedding_column.to_s
58
+ def initialize(table_name: nil, content_column: nil, metadata_column: nil,
59
+ embedding_column: nil, text_search_config: nil, model_class: nil)
60
+ # Nothing in this file needs the ORM or the pgvector client until
61
+ # an instance exists, so both load here rather than at require
62
+ # time: a plain-Ruby process that only uses InMemory should not
63
+ # boot ActiveRecord to require this gem.
64
+ begin
65
+ require "neighbor"
66
+ rescue LoadError
67
+ begin
68
+ require "pgvector"
69
+ rescue LoadError # rubocop:disable Lint/SuppressedException
70
+ end
71
+ end
72
+ begin
73
+ require "active_record"
74
+ rescue LoadError # rubocop:disable Lint/SuppressedException
75
+ end
76
+
77
+ unless defined?(ActiveRecord::Base)
78
+ raise EmbeddingError,
79
+ "Ask::RAG::VectorStore::PGVector needs ActiveRecord. " \
80
+ "Add `gem \"activerecord\"` (or `gem \"rails\"`) to your Gemfile, " \
81
+ "or use Ask::RAG::VectorStore::InMemory which needs no database."
82
+ end
83
+
84
+ config = Ask::RAG.configuration
85
+ @table_name = (table_name || config.table_name).to_s
86
+ @content_column = (content_column || config.content_column).to_s
87
+ @metadata_column = (metadata_column || config.metadata_column).to_s
88
+ @embedding_column = (embedding_column || config.embedding_column).to_s
89
+ @text_search_config = text_search_config || config.text_search_config
40
90
  @model_class = model_class || infer_model_class
41
91
  @embedding_model = nil
42
92
  end
43
93
 
44
- def add(documents, model:, batch_size: 20)
45
- ids = []
94
+ # Add documents, replacing any that share an id.
95
+ #
96
+ # Re-running an indexing job is the normal case, so this upserts:
97
+ # a document whose id already exists is updated in place rather than
98
+ # inserted a second time, and its embedding is not paid for twice
99
+ # beyond what the caller chose to re-embed.
100
+ #
101
+ # @param documents [Array<Ask::Document>] documents to add
102
+ # @param model [String, nil] embedding model name (default:
103
+ # configured embedding_model)
104
+ # @param batch_size [Integer] documents per embed API call
105
+ # @return [Array<String>] the documents' ids
106
+ def add(documents, model: nil, batch_size: 20)
107
+ ids = documents.map { |document| DocumentId.for(document) }
108
+ model ||= default_embedding_model
46
109
  @embedding_model = model
47
110
 
48
- documents.each_slice(batch_size) do |batch|
49
- texts = batch.map(&:content)
50
- vectors = embed_texts(texts, model)
51
-
52
- batch.each_with_index do |doc, idx|
53
- record = @model_class.create!(
54
- @content_column => doc.content,
55
- @metadata_column => doc.metadata,
56
- @embedding_column => vectors[idx]
57
- )
58
- ids << record.id.to_s
111
+ documents.each_slice(batch_size).each_with_index.flat_map do |batch, batch_index|
112
+ offset = batch_index * batch_size
113
+ batch_ids = ids[offset, batch.length]
114
+ vectors = embed_texts(batch.map(&:content), model: model)
115
+ existing = existing_by_document_id(batch_ids)
116
+
117
+ batch.each_with_index.map do |document, index|
118
+ id = batch_ids[index]
119
+ write(document, id: id, vector: vectors[index], record: existing[id])
120
+ id
59
121
  end
60
122
  end
61
-
62
- ids
63
123
  end
64
124
 
65
- def similarity_search(query, limit: 10, filter: nil, mmr: false, diversity_bonus: 0.3)
125
+ def similarity_search(query, limit: 10, filter: nil, min_score: nil,
126
+ mmr: false, diversity_bonus: Retrieval::MaxMarginalRelevance::DEFAULT_DIVERSITY_BONUS)
66
127
  query_vector = embed_query(query)
67
- similarity_search_by_vector(query_vector, limit: limit, filter: filter)
128
+ search_by_vector(query_vector, limit: limit, filter: filter, min_score: min_score,
129
+ mmr: mmr, diversity_bonus: diversity_bonus)
68
130
  end
69
131
 
70
- def similarity_search_by_vector(vector, limit: 10, filter: nil)
71
- vector_str = vector.is_a?(Array) ? "[#{vector.join(',')}]" : vector.to_s
72
- column = "#{@table_name}.#{@embedding_column}"
73
- model_class = @model_class
132
+ def similarity_search_by_vector(vector, limit: 10, filter: nil, min_score: nil,
133
+ mmr: false, diversity_bonus: Retrieval::MaxMarginalRelevance::DEFAULT_DIVERSITY_BONUS)
134
+ search_by_vector(vector, limit: limit, filter: filter, min_score: min_score,
135
+ mmr: mmr, diversity_bonus: diversity_bonus)
136
+ end
74
137
 
75
- scope = model_class
76
- .select("#{@table_name}.*, 1 - (#{column} <=> '#{vector_str}') AS score")
77
- .where("#{column} IS NOT NULL")
138
+ # Dense + keyword retrieval, fused by Reciprocal Rank Fusion.
139
+ #
140
+ # The keyword half is Postgres full-text search over the content
141
+ # column, which is what catches exact terms an embedding blurs:
142
+ # identifiers, error codes, method names. A document both halves find
143
+ # outranks one that only a single half found.
144
+ #
145
+ # Add the GIN index shown in the class docs or the keyword half is a
146
+ # sequential scan; correctness does not depend on it, speed does.
147
+ #
148
+ # @param query [String] the query text
149
+ # @param limit [Integer] maximum results
150
+ # @param filter [Hash, nil] metadata filter
151
+ # @param min_score [Float, nil] drop vector results scoring below this
152
+ # @return [Array<Ask::Document>] documents with +:rrf_score+ in metadata
153
+ def hybrid_search(query, limit: 10, filter: nil, min_score: nil)
154
+ dense = similarity_search(query, limit: CANDIDATE_LIMIT, filter: filter, min_score: min_score)
155
+ # A query with no dense matches can still have exact-term matches,
156
+ # so the keyword half runs regardless rather than short-circuiting
157
+ # on an empty dense list.
158
+ lexical = keyword_search(query, limit: CANDIDATE_LIMIT, filter: filter)
78
159
 
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
160
+ Retrieval::ReciprocalRankFusion.fuse([dense, lexical], limit: limit)
161
+ end
84
162
 
85
- records = scope
86
- .order(Arel.sql("#{column} <=> '#{vector_str}'"))
87
- .limit(limit)
88
-
89
- records.map do |record|
90
- Ask::Document.new(
91
- content: record.send(@content_column),
92
- metadata: (record.send(@metadata_column) || {}).merge(
93
- score: record.score,
94
- db_id: record.id
95
- ),
96
- id: record.id.to_s
97
- )
98
- end
163
+ # Remove every document whose metadata matches all given pairs.
164
+ # This is how stale chunks are cleared after a source is edited or
165
+ # removed — the complement to `add`'s stable ids.
166
+ #
167
+ # @param filter [Hash] metadata key/value pairs to match
168
+ # @return [Integer] number of documents removed
169
+ def delete_by(filter)
170
+ filter_scope(@model_class, filter).delete_all
99
171
  end
100
172
 
101
173
  def delete(ids)
102
- @model_class.where(id: ids).delete_all
174
+ ids = Array(ids)
175
+ return 0 if ids.empty?
176
+
177
+ @model_class.where("#{@metadata_column} ->> 'id' IN (?)", ids).delete_all
103
178
  end
104
179
 
105
180
  def clear
@@ -112,49 +187,180 @@ module Ask
112
187
 
113
188
  private
114
189
 
190
+ # --- Writing ---
191
+
115
192
  def infer_model_class
116
193
  klass = Class.new(ActiveRecord::Base) # rubocop:disable Rails/ApplicationRecord
117
194
  klass.table_name = @table_name
118
195
  klass
119
196
  end
120
197
 
121
- def embed_texts(texts, model)
122
- provider = resolve_provider(model)
123
- raw = provider.embed(texts, model: model)
124
- raw = raw.output if raw.is_a?(Ask::Result)
125
- Array(raw).map { |v| Array(v).map(&:to_f) }
198
+ # One query per batch rather than per document: the ids are known up
199
+ # front, so the upsert decision costs a single round trip.
200
+ #
201
+ # The store can only guarantee uniqueness for the rows it writes, so
202
+ # this takes the first row per id. A duplicate would mean the table
203
+ # was written outside the store; a unique index on
204
+ # `(metadata ->> 'id')` is the schema-level guarantee, documented in
205
+ # the class comment.
206
+ def existing_by_document_id(ids)
207
+ return {} if ids.empty?
208
+
209
+ @model_class
210
+ .where("#{@metadata_column} ->> 'id' IN (?)", ids)
211
+ .to_a
212
+ .each_with_object({}) do |record, result|
213
+ document_id = metadata_for(record)[:id]
214
+ result[document_id] ||= record
215
+ end
126
216
  end
127
217
 
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)
132
- raw = raw.output if raw.is_a?(Ask::Result)
133
- Array(raw).flatten.map(&:to_f)
134
- rescue StandardError => e
135
- raise EmbeddingError, "Failed to embed query with model #{model}: #{e.message}"
218
+ def write(document, id:, vector:, record:)
219
+ record ||= @model_class.new
220
+ record[@content_column] = document.content
221
+ record[@metadata_column] = stringify_metadata(document.metadata).merge("id" => id)
222
+ record[@embedding_column] = vector
223
+ record.save!
224
+ record
136
225
  end
137
226
 
138
- def resolve_provider(model)
139
- info = Ask::ModelCatalog.find(model)
140
- klass = Ask::Provider.resolve(info.provider)
141
- config = build_config(klass)
142
- klass.new(config)
143
- rescue Ask::ModelNotFound, Ask::UnknownProvider
144
- Ask::Provider.resolve(:openai).new(build_config(Ask::Provider.resolve(:openai)))
227
+ # jsonb stores string keys; writing symbol keys works but makes the
228
+ # stored row's shape depend on where it came from, which breaks the
229
+ # `@> ?` filters below.
230
+ def stringify_metadata(metadata)
231
+ metadata.each_with_object({}) { |(key, value), result| result[key.to_s] = value }
145
232
  end
146
233
 
147
- def build_config(klass)
148
- config = Object.new
149
- klass.configuration_requirements.each do |req|
150
- val = ENV["#{req.to_s.upcase}"]
151
- config.define_singleton_method(req) { val } if val
234
+ # jsonb stores string keys, so a round trip through the database
235
+ # would hand back `metadata["source"]` where the loaders wrote
236
+ # `metadata[:source]` — and `Query`, the README, and every caller
237
+ # written against the InMemory store read the symbol. Symbolizing on
238
+ # the way out keeps the two stores interchangeable, which is the
239
+ # point of having a base contract at all.
240
+ def metadata_for(record)
241
+ (record.send(@metadata_column) || {}).each_with_object({}) do |(key, value), result|
242
+ result[key.to_sym] = value
152
243
  end
153
- klass.configuration_options.each do |opt|
154
- val = ENV["#{opt.to_s.upcase}"]
155
- config.define_singleton_method(opt) { val } if val
244
+ end
245
+
246
+ # --- Search ---
247
+
248
+ def search_by_vector(vector, limit:, filter: nil, min_score: nil, mmr: false,
249
+ diversity_bonus: Retrieval::MaxMarginalRelevance::DEFAULT_DIVERSITY_BONUS)
250
+ distance = distance_expression
251
+ # The vector comes from an embeddings API and is bound as a
252
+ # parameter here, never interpolated: `?::vector` binds the literal
253
+ # and the cast is what makes Postgres treat the text as a vector.
254
+ score = @model_class.sanitize_sql_array(["1 - (#{distance} <=> ?::vector)", vector_literal(vector)])
255
+ nearest = @model_class.sanitize_sql_array(["#{distance} <=> ?::vector", vector_literal(vector)])
256
+
257
+ scope = @model_class
258
+ .select("#{@table_name}.*, #{score} AS score")
259
+ .where("#{distance} IS NOT NULL")
260
+
261
+ # The threshold is applied in SQL, not after limiting: filtering in
262
+ # Ruby would return fewer than `limit` rows and hide the strong
263
+ # matches a wider fetch would have found.
264
+ scope = scope.where(@model_class.sanitize_sql_array(["#{score} >= ?", min_score])) if min_score
265
+
266
+ scope = filter_scope(scope, filter)
267
+ records = scope.order(Arel.sql(nearest)).limit(candidates_for(limit, mmr)).to_a
268
+
269
+ documents = records.map { |record| document_for(record, score: record.score) }
270
+ return documents unless mmr
271
+
272
+ select_diverse(records, documents, limit: limit, diversity_bonus: diversity_bonus)
273
+ end
274
+
275
+ # MMR needs the stored vectors to measure how alike candidates are,
276
+ # which is why the embedding column is selected before narrowing.
277
+ def select_diverse(records, documents, limit:, diversity_bonus:)
278
+ candidates = records.each_with_index.map do |record, index|
279
+ Retrieval::MaxMarginalRelevance::Candidate.new(
280
+ document: documents[index],
281
+ relevance: record.score.to_f,
282
+ vector: parse_vector(record.send(@embedding_column))
283
+ )
284
+ end
285
+
286
+ Retrieval::MaxMarginalRelevance.select(candidates, limit: limit, diversity_bonus: diversity_bonus)
287
+ end
288
+
289
+ def candidates_for(limit, mmr)
290
+ mmr ? [limit * 4, CANDIDATE_LIMIT].min : limit
291
+ end
292
+
293
+ def keyword_search(query, limit:, filter: nil)
294
+ return [] if query.to_s.strip.empty?
295
+
296
+ predicate = @model_class.sanitize_sql_array(
297
+ ["#{tsvector_expression} @@ websearch_to_tsquery(?, ?)", @text_search_config, query]
298
+ )
299
+ rank = @model_class.sanitize_sql_array(
300
+ ["ts_rank_cd(#{tsvector_expression}, websearch_to_tsquery(?, ?))",
301
+ @text_search_config, query]
302
+ )
303
+ order = @model_class.sanitize_sql_array(["#{rank} DESC, #{@table_name}.id ASC"])
304
+
305
+ scope = @model_class
306
+ .select("#{@table_name}.*, #{rank} AS lexical_score")
307
+ .where(predicate)
308
+
309
+ scope = filter_scope(scope, filter)
310
+
311
+ scope.order(Arel.sql(order)).limit(limit).map do |record|
312
+ document_for(record, lexical_score: record.lexical_score.to_f)
156
313
  end
157
- config
314
+ end
315
+
316
+ # The text search configuration is a constructor argument rather than
317
+ # caller input, but it is interpolated into SQL, so it goes through
318
+ # the connection's quoting like anything else that reaches a query.
319
+ def tsvector_expression
320
+ config = @model_class.connection.quote(@text_search_config)
321
+ "to_tsvector(#{config}, #{@table_name}.#{@content_column})"
322
+ end
323
+
324
+ # The literal form of a vector for binding. Pgvector.encode is the
325
+ # ecosystem's encoder; the fallback keeps the store usable if a
326
+ # future pgvector drops it.
327
+ def vector_literal(vector)
328
+ values = vector.is_a?(Array) ? vector : Array(vector)
329
+ return Pgvector.encode(values) if defined?(Pgvector) && Pgvector.respond_to?(:encode)
330
+
331
+ "[#{values.map(&:to_f).join(",")}]"
332
+ end
333
+
334
+ def distance_expression
335
+ "#{@table_name}.#{@embedding_column}"
336
+ end
337
+
338
+ def filter_scope(scope, filter = nil)
339
+ return scope if filter.nil? || filter.empty?
340
+
341
+ scope.where("#{@metadata_column} @> ?", stringify_metadata(filter).to_json)
342
+ end
343
+
344
+ def document_for(record, score: nil, lexical_score: nil)
345
+ metadata = metadata_for(record)
346
+ extra = {}
347
+ extra[:score] = score.to_f unless score.nil?
348
+ extra[:lexical_score] = lexical_score unless lexical_score.nil?
349
+
350
+ Ask::Document.new(
351
+ content: record.send(@content_column),
352
+ metadata: metadata.merge(extra),
353
+ id: metadata[:id] || record.id.to_s
354
+ )
355
+ end
356
+
357
+ # Vectors arrive as arrays from pgvector's type decoder and as
358
+ # strings when the column is not registered on the model.
359
+ def parse_vector(value)
360
+ return nil if value.nil?
361
+ return value.to_a.map(&:to_f) if value.respond_to?(:to_a) && !value.is_a?(String)
362
+
363
+ value.to_s.delete("[]").split(",").map(&:to_f)
158
364
  end
159
365
  end
160
366
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module RAG
5
- VERSION = "0.2.2"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
  end
data/lib/ask/rag.rb CHANGED
@@ -1,7 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "rag/version"
4
+ require_relative "rag/configuration"
5
+ require_relative "rag/credentials"
4
6
  require_relative "rag/document"
7
+ require_relative "rag/document_id"
5
8
 
6
9
  # Optional: Matrix for faster cosine similarity
7
10
  begin
@@ -35,15 +38,32 @@ require_relative "rag/text_splitter/base"
35
38
  require_relative "rag/text_splitter/recursive_character"
36
39
  require_relative "rag/text_splitter/markdown"
37
40
 
41
+ # Retrieval primitives, shared by every store
42
+ require_relative "rag/embeddings"
43
+ require_relative "rag/retrieval/bm25"
44
+ require_relative "rag/retrieval/reciprocal_rank_fusion"
45
+ require_relative "rag/retrieval/max_marginal_relevance"
46
+
38
47
  # Vector stores
39
48
  require_relative "rag/vector_store/base"
40
49
  require_relative "rag/vector_store/in_memory"
41
50
 
42
- # Optional: PGVector
51
+ # The class definition below is pure Ruby and loads anywhere; the pgvector
52
+ # client gems and ActiveRecord itself are required lazily inside
53
+ # PGVector#initialize, so InMemory-only apps never pay for the database
54
+ # stack and the constructor can fail with a helpful message instead of a
55
+ # bare NameError.
56
+ require_relative "rag/vector_store/pgvector"
57
+
58
+ require_relative "rag/query"
59
+
60
+ # Rails integration ships in this gem: the Railtie loads only where
61
+ # `rails/railtie` exists, so plain Ruby keeps working without railties.
62
+ # ActiveSupport loads first — railtie.rb leans on delegate_missing_to and
63
+ # config.ask_rag is an OrderedOptions.
43
64
  begin
44
- require "pgvector"
45
- require_relative "rag/vector_store/pgvector"
65
+ require "active_support"
66
+ require "rails/railtie"
67
+ require_relative "rag/railtie"
46
68
  rescue LoadError # rubocop:disable Lint/SuppressedException
47
69
  end
48
-
49
- require_relative "rag/query"
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/migration"
5
+
6
+ module AskRag
7
+ module Generators
8
+ # Installs ask-rag into a Rails app: the pgvector migration and a
9
+ # commented initializer. The `kamal init` equivalent — run once, then
10
+ # edit the generated files.
11
+ #
12
+ # bin/rails generate ask_rag:install
13
+ class InstallGenerator < Rails::Generators::Base
14
+ include Rails::Generators::Migration
15
+ source_root File.expand_path("templates", __dir__)
16
+
17
+ namespace "ask_rag:install"
18
+ desc "Creates the ask-rag embeddings migration and initializer"
19
+
20
+ def self.next_migration_number(_dir)
21
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
22
+ end
23
+
24
+ def create_embeddings_migration
25
+ migration_template "migration.rb.tt", "db/migrate/create_ask_rag_embeddings.rb"
26
+ end
27
+
28
+ def create_initializer
29
+ template "initializer.rb.tt", "config/initializers/ask_rag.rb"
30
+ end
31
+
32
+ def show_next_steps
33
+ say ""
34
+ say "ask-rag installed. Next steps:", :green
35
+ say " 1. Make sure the pgvector extension and neighbor gem are available:"
36
+ say ' gem "neighbor" # registers the vector column type with ActiveRecord'
37
+ say " 2. Run the migration: bin/rails db:migrate"
38
+ say " 3. Put your provider key in credentials: bin/rails credentials:edit"
39
+ say " ask:"
40
+ say " openai_api_key: sk-..."
41
+ say " 4. Tune defaults in config/initializers/ask_rag.rb (all optional)"
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ask-rag configuration. Every setting below overrides the gem default of
4
+ # the same name; anything commented out keeps that default.
5
+ #
6
+ # Provider API keys resolve without any code here: Rails credentials first
7
+ # (ask.openai_api_key), then the OPENAI_API_KEY environment variable.
8
+
9
+ Ask::RAG.configure do |config|
10
+ # Embedding model used by stores when a call omits `model:`.
11
+ # config.embedding_model = "text-embedding-3-small"
12
+
13
+ # Answer model used by Ask::RAG::Query when a call omits `model:`.
14
+ # config.chat_model = "gpt-4o"
15
+
16
+ # PGVector store defaults — used when PGVector.new omits them.
17
+ # config.table_name = :ask_rag_embeddings
18
+ # config.content_column = :content
19
+ # config.metadata_column = :metadata
20
+ # config.embedding_column = :embedding
21
+ # config.text_search_config = "english"
22
+
23
+ # Vector length for the migration `t.vector limit:` above.
24
+ # config.embedding_dimensions = 1536
25
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateAskRagEmbeddings < ActiveRecord::Migration[7.0]
4
+ def change
5
+ enable_extension "vector"
6
+
7
+ create_table :ask_rag_embeddings do |t|
8
+ t.text :content, null: false
9
+ t.jsonb :metadata, null: false, default: {}
10
+ t.vector :embedding, limit: 1536
11
+ end
12
+
13
+ add_index :ask_rag_embeddings, :embedding, using: :hnsw, opclass: :vector_cosine_ops
14
+ add_index :ask_rag_embeddings, "(to_tsvector('english', content))",
15
+ using: :gin, name: "index_ask_rag_embeddings_on_content_tsvector"
16
+ add_index :ask_rag_embeddings, "(metadata ->> 'id')",
17
+ unique: true, name: "index_ask_rag_embeddings_on_metadata_id"
18
+ end
19
+ end
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.2
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 0.9.0
18
+ version: 0.11.3
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: 0.9.0
25
+ version: 0.11.3
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: csv
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -93,6 +93,20 @@ dependencies:
93
93
  - - ">="
94
94
  - !ruby/object:Gem::Version
95
95
  version: '0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: railties
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '7.0'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '7.0'
96
110
  description: Document loaders, text splitters, vector stores, and retrieval queries
97
111
  for building RAG applications with ask-rb.
98
112
  email:
@@ -106,7 +120,11 @@ files:
106
120
  - README.md
107
121
  - lib/ask-rag.rb
108
122
  - lib/ask/rag.rb
123
+ - lib/ask/rag/configuration.rb
124
+ - lib/ask/rag/credentials.rb
109
125
  - lib/ask/rag/document.rb
126
+ - lib/ask/rag/document_id.rb
127
+ - lib/ask/rag/embeddings.rb
110
128
  - lib/ask/rag/loader/base.rb
111
129
  - lib/ask/rag/loader/csv.rb
112
130
  - lib/ask/rag/loader/directory.rb
@@ -116,6 +134,10 @@ files:
116
134
  - lib/ask/rag/loader/pdf.rb
117
135
  - lib/ask/rag/loader/text.rb
118
136
  - lib/ask/rag/query.rb
137
+ - lib/ask/rag/railtie.rb
138
+ - lib/ask/rag/retrieval/bm25.rb
139
+ - lib/ask/rag/retrieval/max_marginal_relevance.rb
140
+ - lib/ask/rag/retrieval/reciprocal_rank_fusion.rb
119
141
  - lib/ask/rag/text_splitter/base.rb
120
142
  - lib/ask/rag/text_splitter/markdown.rb
121
143
  - lib/ask/rag/text_splitter/recursive_character.rb
@@ -123,6 +145,9 @@ files:
123
145
  - lib/ask/rag/vector_store/in_memory.rb
124
146
  - lib/ask/rag/vector_store/pgvector.rb
125
147
  - lib/ask/rag/version.rb
148
+ - lib/generators/ask_rag/install/install_generator.rb
149
+ - lib/generators/ask_rag/install/templates/initializer.rb.tt
150
+ - lib/generators/ask_rag/install/templates/migration.rb.tt
126
151
  homepage: https://github.com/ask-rb/ask-rag
127
152
  licenses:
128
153
  - MIT
@@ -144,7 +169,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
144
169
  - !ruby/object:Gem::Version
145
170
  version: '0'
146
171
  requirements: []
147
- rubygems_version: 4.0.3
172
+ rubygems_version: 4.0.18
148
173
  specification_version: 4
149
174
  summary: RAG pipeline for the ask-rb ecosystem
150
175
  test_files: []