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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c1e704da2a70f456b0b4e155cc5530a6f5e701eec62831c6425187119ba04e73
|
|
4
|
+
data.tar.gz: 618753ff1f58592c41b7cf5a9f0369f12d6b460a4f6e865abdd77e4d197fbab1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
203
|
-
|
|
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
|