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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +117 -0
- data/README.md +190 -7
- data/lib/ask/rag/configuration.rb +65 -0
- data/lib/ask/rag/credentials.rb +69 -0
- data/lib/ask/rag/document_id.rb +54 -0
- data/lib/ask/rag/embeddings.rb +138 -0
- data/lib/ask/rag/query.rb +48 -15
- data/lib/ask/rag/railtie.rb +27 -0
- data/lib/ask/rag/retrieval/bm25.rb +137 -0
- data/lib/ask/rag/retrieval/max_marginal_relevance.rb +114 -0
- data/lib/ask/rag/retrieval/reciprocal_rank_fusion.rb +72 -0
- data/lib/ask/rag/vector_store/base.rb +47 -3
- data/lib/ask/rag/vector_store/in_memory.rb +120 -171
- data/lib/ask/rag/vector_store/pgvector.rb +290 -84
- data/lib/ask/rag/version.rb +1 -1
- data/lib/ask/rag.rb +25 -5
- data/lib/generators/ask_rag/install/install_generator.rb +45 -0
- data/lib/generators/ask_rag/install/templates/initializer.rb.tt +25 -0
- data/lib/generators/ask_rag/install/templates/migration.rb.tt +19 -0
- metadata +29 -4
|
@@ -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: :
|
|
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
|
-
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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
|
data/lib/ask/rag/version.rb
CHANGED
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
|
-
#
|
|
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 "
|
|
45
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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: []
|