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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 85bc75fa9d57ddf8d6ca80d7baa63c283223f6a0fcccf3303d117857f27eb736
4
- data.tar.gz: 8982a83aca2b04308bdf9f885457b5ec2944cd553b2ec4d184005705894aa300
3
+ metadata.gz: c1e704da2a70f456b0b4e155cc5530a6f5e701eec62831c6425187119ba04e73
4
+ data.tar.gz: 618753ff1f58592c41b7cf5a9f0369f12d6b460a4f6e865abdd77e4d197fbab1
5
5
  SHA512:
6
- metadata.gz: b55d1f856729e6b3bccd35ec5589dc55afe289474ebebc333d1229cb7c40cc405194522770a13f2fb1db4edaca42c0f9b6344ec2936446d65ddd2aefb8233229
7
- data.tar.gz: de95270aa7090a81fa0d5563d1dba63d57b9dd2b793a55442674ebdc953b951d95b6d33b558b15af137d0bb58f2f81bcf3f29a43e9616614b827fa5a62f2d3df
6
+ metadata.gz: 6554f213b35c30724385afcb8a95a266f834f141783cf2cd50cd333db6835e338b9f90dad9f1b5ce56e87c62804df3879ba175191319ba06be06c32531ea1295
7
+ data.tar.gz: e601fe5825391db348453562504be11f8ae42bd95c5a83a2fc166cee418b9e0140f8ef13a2dcd135376e0af9d3efb64cbb78724a1026ef8d284c866a086de486
data/CHANGELOG.md CHANGED
@@ -1,3 +1,120 @@
1
+ ## [0.4.0] — 2026-09-11
2
+
3
+ ### Added
4
+
5
+ - **Rails support, inside this gem.** There is no `ask-rag-rails` wrapper
6
+ and there will not be one: Rails integration ships here, the way Kamal
7
+ ships deployment tooling that happens to work well in Rails. Three
8
+ pieces, all optional, all inert outside Rails:
9
+ - `Ask::RAG.configure` — global defaults for the embedding model, the
10
+ chat model, and the PGVector table and columns, so an app configures
11
+ once instead of threading the same arguments through every call. Every
12
+ setting remains overridable per call.
13
+ - An in-gem `Railtie` exposing `config.ask_rag` (set it in
14
+ `config/application.rb` like anything else). It loads only where
15
+ `rails/railtie` exists; `railties` stays a development dependency,
16
+ never a runtime one.
17
+ - An `ask_rag:install` generator (`bin/rails generate ask_rag:install`)
18
+ that writes the pgvector migration and a commented initializer — the
19
+ `kamal init` equivalent.
20
+ - **API keys resolve from Rails credentials.** Provider keys are read from
21
+ `Rails.application.credentials` first (`ask.openai_api_key`, falling
22
+ back to `llm.openai_api_key`), then the same environment variables
23
+ plain Ruby uses. Non-Rails apps skip the lookup entirely; nothing about
24
+ the ENV behaviour changed.
25
+ - **`model:` is now optional everywhere.** `add`, `embed_texts`,
26
+ `Query.query`, and `Query.retrieve` fall back to the configured models,
27
+ which default to the previous hardcoded values — existing calls behave
28
+ exactly as before.
29
+ - **`PGVector.new` takes no arguments.** Table, columns, and text search
30
+ configuration default to the configured values, which match the table
31
+ the install generator migrates (`ask_rag_embeddings`). The previous
32
+ required `table_name:` keyword now has a default, so existing calls
33
+ that pass it explicitly are unaffected.
34
+ - **A helpful error when ActiveRecord is missing.** Building a `PGVector`
35
+ without the ORM raises `EmbeddingError` telling you which gem to add,
36
+ instead of a `NameError` deep in the stack. The `neighbor` and
37
+ `pgvector` client gems load lazily inside the constructor, so
38
+ InMemory-only apps never boot the database stack.
39
+ - **The generated migration is tested for real.** The suite runs the
40
+ template against live Postgres and searches through the resulting
41
+ table, so the install path cannot rot.
42
+
43
+ ## [0.3.0] — 2026-09-11
44
+
45
+ ### Fixed
46
+
47
+ - **`InMemory` ignored your query.** `embed_query` took no argument and
48
+ embedded the literal string `"query"`, so every search returned the
49
+ documents nearest that word — the same ranking for every question. This
50
+ was the same defect fixed in `PGVector` in 0.2.1; the fix had never
51
+ crossed over, and the suite stubbed `embed_query` in every search test, so
52
+ it stayed green. `similarity_search` now embeds the text the caller
53
+ passed. If you were relying on the old behaviour, nothing will look
54
+ familiar: results are now ranked by your query.
55
+ - **`InMemory` results carried no id.** `add` returned generated UUIDs while
56
+ results carried `document.id` (usually `nil`), so the documented
57
+ retrieve-then-delete loop could not be closed. Results now carry the id
58
+ the document was stored under.
59
+ - **`PGVector` ignored `mmr:`.** The argument was accepted and silently
60
+ dropped. It now applies Max Marginal Relevance, using the stored vectors
61
+ for the pairwise similarity.
62
+ - **`PGVector` interpolated vectors into SQL.** The embedding is
63
+ API-provided and was concatenated into `select`, `order`, and `where`
64
+ clauses. It is now bound as a parameter with a `::vector` cast. The
65
+ full-text query likewise goes through `websearch_to_tsquery` as a bound
66
+ parameter.
67
+ - **`PGVector#delete_by` deleted nothing.** It called `filter_scope` with
68
+ the filter where the relation belonged.
69
+ - **`PGVector` returned string metadata keys.** Rows read back as
70
+ `metadata["source"]` where the loaders, `InMemory`, and the documented
71
+ examples all use `:source`. Metadata is symbolized on the way out so the
72
+ two stores are interchangeable.
73
+ - Embedding failures now raise `EmbeddingError`. A provider returning an
74
+ `Ask::Result.failure` was coerced into floats, and a batch whose vector
75
+ count did not match its text count was silently truncated or misaligned.
76
+
77
+ ### Added
78
+
79
+ - **`hybrid_search` on both stores.** Dense plus lexical retrieval, fused
80
+ by Reciprocal Rank Fusion. Dense retrieval alone cannot find an error
81
+ code, a method name, or an identifier that is out of the embedding
82
+ model's vocabulary; lexical alone cannot find a paraphrase. A document
83
+ both halves find outranks one only a single half found. `InMemory` uses
84
+ BM25; `PGVector` uses Postgres full-text search.
85
+ - **Stopword handling in `InMemory`'s lexical half.** A question made only
86
+ of stopwords ("how do I do it") matched every document, and a real
87
+ question ("how do I deploy to kubernetes") matched unrelated pages, on the
88
+ strength of "how" and "to" alone. IDF does not suppress those at the
89
+ corpus sizes this gem serves. Terms are now dropped before scoring, and
90
+ tokenization splits identifiers the way Postgres does, so the two stores
91
+ agree on what matches.
92
+ - **`min_score:`** on `similarity_search`, `similarity_search_by_vector`,
93
+ and `hybrid_search`. Everything below the threshold is dropped rather
94
+ than the nearest-but-wrong documents being returned to fill `limit`. This
95
+ is the difference between "no relevant context" and five confident
96
+ passages that do not answer the question.
97
+ - **`delete_by(filter)`** on both stores, for clearing the chunks of a
98
+ source that has been edited or removed.
99
+ - **Stable document ids** (`Ask::RAG::DocumentId`). Documents without an
100
+ id get one derived from source, chunk index, and content, so re-indexing
101
+ the same pipeline output updates in place instead of duplicating the
102
+ corpus and re-embedding it. `add` is now an upsert on both stores.
103
+ - **`Query.retrieve`**, exposing retrieval without calling an LLM, plus
104
+ `hybrid:` and `min_score:` on `Query.query`.
105
+ - **PGVector tests against a live Postgres**, including the SQL paths above.
106
+ They skip when no database is reachable. Set `ASK_RAG_TEST_DATABASE_URL`
107
+ (default `postgres://localhost/ask_rag_test`) to run them.
108
+ - `Ask::RAG::Embeddings`, the shared embedding path both stores use, so a
109
+ store implements storage rather than an embeddings client.
110
+
111
+ ### Changed
112
+
113
+ - `similarity_search_by_vector` accepts `mmr:` and `diversity_bonus:`,
114
+ matching `similarity_search`.
115
+ - The `chunks.embedding` column's vector dimension is no longer assumed to
116
+ be 1536 in tests; the store reads whatever the column holds.
117
+
1
118
  ## [0.2.2] — 2026-08-03
2
119
 
3
120
  ### Fixed
data/README.md CHANGED
@@ -20,7 +20,7 @@ splitter = Ask::RAG::TextSplitter::RecursiveCharacter.new(
20
20
  )
21
21
  chunks = splitter.split_documents(docs)
22
22
 
23
- # 2. Embed and store
23
+ # 2. Embed and store (re-running this replaces, never duplicates)
24
24
  store = Ask::RAG::VectorStore::InMemory.new
25
25
  store.add(chunks, model: "text-embedding-3-small")
26
26
 
@@ -39,6 +39,124 @@ answer = Ask::RAG::Query.query(
39
39
  puts answer.content
40
40
  ```
41
41
 
42
+ ## Rails
43
+
44
+ Rails support lives in this gem — there is no separate wrapper gem. Add
45
+ `ask-rag` to your Gemfile and run the installer:
46
+
47
+ ```bash
48
+ bin/rails generate ask_rag:install
49
+ bin/rails db:migrate
50
+ ```
51
+
52
+ The generator writes a migration for an `ask_rag_embeddings` table
53
+ (content, metadata, embedding, plus the HNSW, full-text, and upsert
54
+ indexes the stores assume) and a commented initializer. Two more lines
55
+ in your Gemfile and credentials complete the setup:
56
+
57
+ ```ruby
58
+ gem "ask-rag"
59
+ gem "neighbor" # registers the vector column type with ActiveRecord
60
+ ```
61
+
62
+ ```yaml
63
+ # config/credentials.yml.enc (bin/rails credentials:edit)
64
+ ask:
65
+ openai_api_key: sk-...
66
+ ```
67
+
68
+ That is the whole integration. `config.ask_rag` (or
69
+ `Ask::RAG.configure`) sets the embedding model, the chat model, and the
70
+ store's table and columns once; provider keys resolve from credentials
71
+ first and `ENV` second; and `PGVector.new` with no arguments connects to
72
+ the migrated table:
73
+
74
+ ```ruby
75
+ store = Ask::RAG::VectorStore::PGVector.new
76
+ store.add(chunks) # model: defaults to the configured embedding model
77
+
78
+ answer = Ask::RAG::Query.query(store: store, question: "How do I reset my password?")
79
+ # model: defaults to the configured chat model
80
+ ```
81
+
82
+ Everything stays overridable per call, and everything outside Rails
83
+ works exactly as before — the Railtie loads only where `rails/railtie`
84
+ exists, and `railties` is a development dependency, never a runtime one.
85
+
86
+ ## Retrieval
87
+
88
+ Two ways to search, and the difference matters:
89
+
90
+ ```ruby
91
+ # Dense only: finds paraphrases, misses exact terms.
92
+ store.similarity_search("ERR_4021", limit: 5)
93
+
94
+ # Hybrid: dense + keyword, fused with Reciprocal Rank Fusion. Use this
95
+ # for anything a person or an agent will read and act on.
96
+ store.hybrid_search("ERR_4021", limit: 5)
97
+ ```
98
+
99
+ Dense retrieval is bad at strings that the embedding model has no concept
100
+ of: error codes, SKUs, method names, identifiers, rare proper nouns. A
101
+ question containing `ERR_4021` will not retrieve the page that documents
102
+ it, however good the model is. Lexical retrieval alone has the opposite
103
+ problem — it cannot match "how do I reset my password" to "changing your
104
+ credentials". `hybrid_search` runs both and merges the ranked lists, so a
105
+ document **both** halves find outranks one only a single half found. That
106
+ agreement is the strongest relevance signal available without a reranker.
107
+
108
+ ### Requiring a relevance floor
109
+
110
+ By default you get `limit` results whether or not any of them are relevant.
111
+ For a retrieval-augmented answer that is dangerous: five irrelevant chunks
112
+ look exactly like five relevant ones to the model reading them.
113
+
114
+ ```ruby
115
+ results = store.hybrid_search("what is the refund policy?", limit: 5, min_score: 0.35)
116
+ # => [] when nothing clears the bar
117
+ ```
118
+
119
+ An empty array is a legitimate answer — "this corpus does not cover that" —
120
+ and is far more useful than the nearest five documents. For cosine
121
+ similarity, 0.3–0.4 is a reasonable starting floor; there is no universal
122
+ number, so measure it against your own corpus.
123
+
124
+ ### Diversifying results
125
+
126
+ ```ruby
127
+ results = store.similarity_search(
128
+ "database configuration",
129
+ limit: 5,
130
+ mmr: true,
131
+ diversity_bonus: 0.5 # 0 = pure relevance, 1 = pure diversity
132
+ )
133
+ ```
134
+
135
+ Plain top-k returns five paraphrases of the same paragraph, because
136
+ near-duplicate chunks all score highly. Max Marginal Relevance trades a
137
+ little relevance for coverage, which matters most when retrieval works
138
+ *well*. Results carry `:mmr_score` alongside `:score`.
139
+
140
+ ### Re-indexing
141
+
142
+ `add` is an upsert. Documents without an id are given a stable one derived
143
+ from their source, chunk index, and content, so:
144
+
145
+ ```ruby
146
+ store.add(chunks, model: "text-embedding-3-small") # first run
147
+ store.add(chunks, model: "text-embedding-3-small") # no-op, not a duplicate
148
+ ```
149
+
150
+ When a source changes, its content hash changes and new ids appear. Clear
151
+ the old rows first:
152
+
153
+ ```ruby
154
+ store.delete_by(source: "docs/config.md")
155
+ store.add(reloaded_chunks, model: "text-embedding-3-small")
156
+ ```
157
+
158
+ Pass an explicit `id:` on the `Ask::Document` to control identity yourself.
159
+
42
160
  ## Components
43
161
 
44
162
  ### Loaders
@@ -50,6 +168,7 @@ Load files into `Ask::Document` objects (text + metadata).
50
168
  | `Ask::RAG::Loader::Text` | Plain text | None (stdlib) |
51
169
  | `Ask::RAG::Loader::Markdown` | Markdown | None (stdlib) |
52
170
  | `Ask::RAG::Loader::CSV` | CSV (1 doc/row) | None (`csv` gem) |
171
+ | `Ask::RAG::Loader::JSON` | JSON (1 doc/object) | None (stdlib) |
53
172
  | `Ask::RAG::Loader::HTML` | HTML (strips script/style/nav) | `nokogiri` |
54
173
  | `Ask::RAG::Loader::PDF` | PDF (1 doc/page) | `pdf-reader` |
55
174
  | `Ask::RAG::Loader::Directory` | Auto-detects file types by extension | None (uses other loaders internally) |
@@ -94,14 +213,18 @@ Store embeddings and search by similarity.
94
213
  | `InMemory` | Pure Ruby cosine similarity. Zero deps. | None |
95
214
  | `PGVector` (auto-loaded) | PostgreSQL + pgvector extension | `pgvector`, `activerecord` |
96
215
 
216
+ Both stores implement the same contract — including `hybrid_search`,
217
+ `min_score`, MMR, and upsert semantics — so switching between them is a
218
+ constructor change, not a rewrite.
219
+
97
220
  ```ruby
98
221
  # InMemory — no database needed
99
222
  store = Ask::RAG::VectorStore::InMemory.new
100
223
  store.add(documents, model: "text-embedding-3-small")
101
224
  results = store.similarity_search("query", limit: 5)
102
225
 
103
- # PGVector — for production Rails apps
104
- store = Ask::RAG::VectorStore::PGVector.new(table_name: :embeddings)
226
+ # PGVector — for production Rails apps (see Rails above)
227
+ store = Ask::RAG::VectorStore::PGVector.new
105
228
  store.add(documents, model: "text-embedding-3-small")
106
229
  ```
107
230
 
@@ -114,15 +237,44 @@ results.each do |doc|
114
237
  end
115
238
  ```
116
239
 
240
+ #### PGVector schema
241
+
242
+ ```ruby
243
+ create_table :ask_rag_embeddings do |t|
244
+ t.text :content
245
+ t.jsonb :metadata
246
+ t.vector :embedding, limit: 1536
247
+ end
248
+ add_index :ask_rag_embeddings, :embedding, using: :hnsw, opclass: :vector_cosine_ops
249
+
250
+ # Makes hybrid_search's keyword half fast rather than a sequential scan:
251
+ add_index :ask_rag_embeddings, "(to_tsvector('english', content))", using: :gin
252
+
253
+ # Makes `add` an upsert at the database level too:
254
+ add_index :ask_rag_embeddings, "(metadata ->> 'id')", unique: true
255
+ ```
256
+
257
+ This is what `bin/rails generate ask_rag:install` writes for you.
258
+
259
+ Document identity lives in `metadata ->> 'id'`, not in the table's primary
260
+ key — the table is the store's, the document id is yours. Correctness does
261
+ not depend on the indexes; speed does.
262
+
263
+ The text search configuration defaults to `"english"` and is settable via
264
+ `text_search_config:`.
265
+
117
266
  ### Metadata filtering
118
267
 
119
- Filter results by document metadata:
268
+ Filter results by document metadata. The same `filter:` applies to
269
+ `similarity_search`, `hybrid_search`, and `delete_by`:
120
270
 
121
271
  ```ruby
122
272
  results = store.similarity_search(
123
273
  "authentication",
124
274
  filter: { source: "api_docs.md", version: "2.0" }
125
275
  )
276
+
277
+ store.delete_by(source: "api_docs.md")
126
278
  ```
127
279
 
128
280
  ### MMR (diversified search)
@@ -154,6 +306,23 @@ puts answer.content # The LLM's answer
154
306
  puts answer.metadata[:sources] # ["config.md", "settings.md"]
155
307
  ```
156
308
 
309
+ `query` returns `nil` when nothing is retrieved, so an unanswerable
310
+ question is distinguishable from a model failure. Add `hybrid: true` and
311
+ `min_score:` to retrieve the way the section above recommends:
312
+
313
+ ```ruby
314
+ answer = Ask::RAG::Query.query(
315
+ store: store,
316
+ question: "What is the default timeout configuration?",
317
+ model: "gpt-4o",
318
+ hybrid: true,
319
+ min_score: 0.35
320
+ )
321
+ ```
322
+
323
+ To inspect what would be retrieved without calling an LLM, use
324
+ `Ask::RAG::Query.retrieve(store, question, hybrid: true, min_score: 0.35)`.
325
+
157
326
  ## Pipeline (End-to-End)
158
327
 
159
328
  ```ruby
@@ -175,7 +344,8 @@ store.add(chunks, model: "text-embedding-3-small")
175
344
  answer = Ask::RAG::Query.query(
176
345
  store: store,
177
346
  question: "How do I reset my password?",
178
- model: "gpt-4o"
347
+ model: "gpt-4o",
348
+ hybrid: true
179
349
  )
180
350
  puts answer.content
181
351
  ```
@@ -199,8 +369,11 @@ class SearchDocs < Ask::Tool
199
369
  param :query, type: :string, desc: "Search query"
200
370
 
201
371
  def execute(query:)
202
- store.similarity_search(query, limit: 3).map do |doc|
203
- { content: doc.content[0..500], score: doc.metadata[:score] }
372
+ # An agent acts on what it retrieves, so give it the hybrid path and a
373
+ # floor: "nothing relevant" is a better answer than the nearest five
374
+ # documents, which the agent cannot tell apart from real matches.
375
+ store.hybrid_search(query, limit: 3, min_score: 0.35).map do |doc|
376
+ { content: doc.content[0..500], score: doc.metadata[:rrf_score] }
204
377
  end
205
378
  end
206
379
  end
@@ -218,6 +391,16 @@ session.run("How do I configure authentication?")
218
391
  bundle exec rake test
219
392
  ```
220
393
 
394
+ The PGVector store is tested against a live Postgres; those tests skip when
395
+ no database is reachable. To run them:
396
+
397
+ ```bash
398
+ createdb ask_rag_test
399
+ bundle exec rake test
400
+ # or point elsewhere:
401
+ ASK_RAG_TEST_DATABASE_URL=postgres://localhost/my_test_db bundle exec rake test
402
+ ```
403
+
221
404
  ## License
222
405
 
223
406
  MIT
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module RAG
5
+ # Global configuration for Ask::RAG.
6
+ #
7
+ # Plain Ruby objects read these as defaults, so a Rails app configures
8
+ # once — in an initializer, via `config.ask_rag`, or through credentials
9
+ # — instead of threading the same arguments through every call site.
10
+ # Every setting remains overridable per call.
11
+ #
12
+ # @example
13
+ # Ask::RAG.configure do |config|
14
+ # config.embedding_model = "text-embedding-3-small"
15
+ # config.chat_model = "gpt-4o"
16
+ # config.table_name = :embeddings
17
+ # end
18
+ class Configuration
19
+ attr_accessor :embedding_model, :chat_model, :table_name, :content_column,
20
+ :metadata_column, :embedding_column, :text_search_config, :embedding_dimensions
21
+
22
+ def initialize
23
+ @embedding_model = "text-embedding-3-small"
24
+ @chat_model = nil
25
+ # Namespaced so the table never collides with the host app's own
26
+ # tables — in a Rails database this table lives alongside the
27
+ # app's, and a bare `embeddings` name is asking for a collision.
28
+ @table_name = :ask_rag_embeddings
29
+ @content_column = :content
30
+ @metadata_column = :metadata
31
+ @embedding_column = :embedding
32
+ @text_search_config = "english"
33
+ @embedding_dimensions = 1536
34
+ end
35
+
36
+ # Apply an ActiveSupport::OrderedOptions (or plain Hash), ignoring
37
+ # unknown keys so an app's `config.ask_rag` stays forward-compatible.
38
+ def apply_ordered_options(options)
39
+ options.each_pair do |key, value|
40
+ setter = :"#{key}="
41
+ public_send(setter, value) if respond_to?(setter)
42
+ end
43
+ self
44
+ end
45
+ end
46
+
47
+ class << self
48
+ # @return [Ask::RAG::Configuration] the global configuration
49
+ def configuration
50
+ @configuration ||= Configuration.new
51
+ end
52
+
53
+ # Configure Ask::RAG globally.
54
+ # @yield [Ask::RAG::Configuration]
55
+ def configure
56
+ yield configuration
57
+ end
58
+
59
+ # Reset the global configuration to its defaults. Mainly for tests.
60
+ def reset_configuration!
61
+ @configuration = Configuration.new
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module RAG
5
+ # API key resolution that works the Rails way with zero configuration.
6
+ #
7
+ # Provider keys resolve from +Rails.application.credentials+ first (under
8
+ # +ask+ or +llm+), then fall back to the same environment variables plain
9
+ # Ruby already uses — +OPENAI_API_KEY+ namespaced first, then bare
10
+ # +API_KEY+. Non-Rails apps skip the credentials lookup entirely.
11
+ #
12
+ # @example credentials (config/credentials.yml.enc)
13
+ # ask:
14
+ # openai_api_key: sk-...
15
+ module Credentials
16
+ class << self
17
+ # @param slug [String] provider slug, e.g. "openai"
18
+ # @param option [Symbol, String] configuration key, e.g. :api_key
19
+ # @return [String, nil] the first value found
20
+ def fetch(slug, option)
21
+ rails_credential(slug, option) || env_fallback(slug, option)
22
+ end
23
+
24
+ # @return [true, false] whether the app boots with Rails credentials
25
+ def rails_credentials_available?
26
+ defined?(::Rails) && ::Rails.respond_to?(:application) &&
27
+ !::Rails.application.nil? && ::Rails.application.respond_to?(:credentials) &&
28
+ !::Rails.application.credentials.nil?
29
+ rescue StandardError
30
+ false
31
+ end
32
+
33
+ private
34
+
35
+ # Credentials live one nesting down under either +ask+ or +llm+:
36
+ # `ask.openai_api_key` reads as "the ask ecosystem's OpenAI key"
37
+ # without colliding with a top-level `openai_api_key` the app
38
+ # itself might define.
39
+ def rails_credential(slug, option)
40
+ return nil unless rails_credentials_available?
41
+
42
+ credentials = ::Rails.application.credentials
43
+ key = :"#{slug}_#{option}"
44
+ value = dig_credentials(credentials, :ask, key) ||
45
+ dig_credentials(credentials, :llm, key) ||
46
+ dig_credentials(credentials, key)
47
+ value&.to_s
48
+ rescue StandardError
49
+ nil
50
+ end
51
+
52
+ def dig_credentials(credentials, *keys)
53
+ keys.reduce(credentials) do |node, key|
54
+ break nil if node.nil?
55
+ if node.respond_to?(:dig)
56
+ node.dig(key)
57
+ elsif node.is_a?(Hash)
58
+ node[key] || node[key.to_s]
59
+ end
60
+ end
61
+ end
62
+
63
+ def env_fallback(slug, option)
64
+ ENV["#{slug.to_s.upcase}_#{option.to_s.upcase}"] || ENV[option.to_s.upcase]
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Ask
6
+ module RAG
7
+ # Stable identity for a document that arrives without one.
8
+ #
9
+ # Re-indexing is the common case, not the exception: a folder gets
10
+ # re-crawled, a job is retried, a developer runs the same script twice.
11
+ # If every `add` mints a fresh id, each of those runs duplicates the
12
+ # corpus and re-embeds it, and `delete` has nothing stable to target.
13
+ #
14
+ # So identity is derived from what a document *is* rather than when it
15
+ # was added. Chunks carry metadata[:source] and metadata[:chunk] from
16
+ # the loaders and splitters, and the content hash covers edits: an
17
+ # unchanged file reproduces its ids and `add` overwrites in place, while
18
+ # an edited file yields new ids and the stale chunks are what
19
+ # `delete_by(source: ...)` is for.
20
+ #
21
+ # Documents with no source fall back to content alone, which dedupes
22
+ # identical text. Pass an explicit +id+ to override all of this.
23
+ module DocumentId
24
+ LENGTH = 32
25
+
26
+ class << self
27
+ # @param document [Ask::Document]
28
+ # @return [String] the document's own id, or a stable derived one
29
+ def for(document)
30
+ document.id || derived(document)
31
+ end
32
+
33
+ # @param document [Ask::Document]
34
+ # @return [String] a content-addressed id
35
+ def derived(document)
36
+ Digest::SHA256.hexdigest(identity_parts(document).join("\u0000"))[0, LENGTH]
37
+ end
38
+
39
+ private
40
+
41
+ # Metadata survives JSON round-trips in the loaders, so a key can be
42
+ # a symbol or a string depending on which loader produced it.
43
+ def identity_parts(document)
44
+ metadata = document.metadata
45
+ [
46
+ metadata[:source] || metadata["source"],
47
+ metadata[:chunk] || metadata["chunk"],
48
+ document.content
49
+ ]
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end