ask-rag 0.1.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 +7 -0
- data/CHANGELOG.md +31 -0
- data/LICENSE +21 -0
- data/README.md +197 -0
- data/lib/ask/rag/document.rb +9 -0
- data/lib/ask/rag/loader/base.rb +27 -0
- data/lib/ask/rag/loader/csv.rb +64 -0
- data/lib/ask/rag/loader/html.rb +33 -0
- data/lib/ask/rag/loader/markdown.rb +19 -0
- data/lib/ask/rag/loader/pdf.rb +21 -0
- data/lib/ask/rag/loader/text.rb +18 -0
- data/lib/ask/rag/query.rb +97 -0
- data/lib/ask/rag/text_splitter/base.rb +48 -0
- data/lib/ask/rag/text_splitter/markdown.rb +66 -0
- data/lib/ask/rag/text_splitter/recursive_character.rb +101 -0
- data/lib/ask/rag/vector_store/base.rb +54 -0
- data/lib/ask/rag/vector_store/in_memory.rb +172 -0
- data/lib/ask/rag/vector_store/pgvector.rb +145 -0
- data/lib/ask/rag/version.rb +7 -0
- data/lib/ask/rag.rb +41 -0
- data/lib/ask-rag.rb +4 -0
- metadata +148 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 908700d8186c0433071a87f8b6f28ec3d60f857ef9bd0a5cf474a1804f888251
|
|
4
|
+
data.tar.gz: 9abbec0e77ea2ca596b065c67af63d92ea373da22dd33f418442119a0d32defd
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 20d21031b52e4b0594f5485bbd8e709a71fa716115676ad493ebee998085e4ce8a378d4023a94b3c9587221837c4fcd18488c09c270c306714b4ff16ab9e7b1c
|
|
7
|
+
data.tar.gz: 2b2a31e86ae1d5adceb08782d186db4347b57aa1b15e899e8210c20dd65732c3ba7e32ff79b1109e8fe958b77ddd8e2a91361f0c389c14769a70f0bbb2bad323
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
## [0.1.0] — 2026-07-26
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- **Initial release** — RAG pipeline for the ask-rb ecosystem.
|
|
6
|
+
|
|
7
|
+
- **`Ask::Document`** — text+metadata value object (re-exported from ask-core 0.6.0).
|
|
8
|
+
|
|
9
|
+
- **Loaders** — load files into documents:
|
|
10
|
+
- `Ask::RAG::Loader::Text` — plain text files
|
|
11
|
+
- `Ask::RAG::Loader::Markdown` — Markdown files
|
|
12
|
+
- `Ask::RAG::Loader::CSV` — CSV files (one document per row, configurable content/metadata columns)
|
|
13
|
+
- `Ask::RAG::Loader::HTML` — HTML files with Nokogiri (removes script, style, nav elements)
|
|
14
|
+
- `Ask::RAG::Loader::PDF` — PDF files via pdf-reader (one document per page)
|
|
15
|
+
|
|
16
|
+
- **Text Splitters** — split documents into smaller chunks:
|
|
17
|
+
- `Ask::RAG::TextSplitter::RecursiveCharacter` — recursively splits by `\n\n`, `\n`, `.`, ` `, character
|
|
18
|
+
- `Ask::RAG::TextSplitter::Markdown` — splits by headers, preserves header hierarchy
|
|
19
|
+
|
|
20
|
+
- **Vector Stores** — store and search document embeddings:
|
|
21
|
+
- `Ask::RAG::VectorStore::InMemory` — pure Ruby cosine similarity, zero dependencies
|
|
22
|
+
- `Ask::RAG::VectorStore::PGVector` — PostgreSQL + pgvector adapter (optional, loaded if gem present)
|
|
23
|
+
|
|
24
|
+
- **High-level Query API** — `Ask::RAG::Query.query` for one-shot retrieve → prompt → answer
|
|
25
|
+
|
|
26
|
+
- **Design**:
|
|
27
|
+
- HTML and PDF loaders auto-activate when their dependency gems are installed
|
|
28
|
+
- PGVector adapter auto-activates when the pgvector gem is available
|
|
29
|
+
- All loaders provide both `load` (eager) and `lazy_load` (enumerator) interfaces
|
|
30
|
+
- All splitters implement `split_text` (raw text) and `split_documents` (Document arrays)
|
|
31
|
+
- Scores returned in document metadata as `:score`
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kaka Ruto
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# ask-rag
|
|
2
|
+
|
|
3
|
+
**RAG pipeline for the ask-rb ecosystem.** Load documents, split them into chunks, store embeddings, and retrieve relevant context — all from Ruby.
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
gem "ask-rag"
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Quick Start
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
require "ask-rag"
|
|
13
|
+
|
|
14
|
+
# 1. Load and split
|
|
15
|
+
loader = Ask::RAG::Loader::Text.new
|
|
16
|
+
docs = loader.load("README.md")
|
|
17
|
+
|
|
18
|
+
splitter = Ask::RAG::TextSplitter::RecursiveCharacter.new(
|
|
19
|
+
chunk_size: 500, chunk_overlap: 50
|
|
20
|
+
)
|
|
21
|
+
chunks = splitter.split_documents(docs)
|
|
22
|
+
|
|
23
|
+
# 2. Embed and store
|
|
24
|
+
store = Ask::RAG::VectorStore::InMemory.new
|
|
25
|
+
store.add(chunks, model: "text-embedding-3-small")
|
|
26
|
+
|
|
27
|
+
# 3. Search
|
|
28
|
+
results = store.similarity_search("How do I install ask-rag?", limit: 3)
|
|
29
|
+
results.each do |doc|
|
|
30
|
+
puts "[#{doc.metadata[:score].round(3)}] #{doc.content[0..80]}..."
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# 4. Or one-shot RAG query
|
|
34
|
+
answer = Ask::RAG::Query.query(
|
|
35
|
+
store: store,
|
|
36
|
+
question: "What are the dependencies?",
|
|
37
|
+
model: "gpt-4o"
|
|
38
|
+
)
|
|
39
|
+
puts answer.content
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Components
|
|
43
|
+
|
|
44
|
+
### Loaders
|
|
45
|
+
|
|
46
|
+
Load files into `Ask::Document` objects (text + metadata).
|
|
47
|
+
|
|
48
|
+
| Loader | Format | Dependency |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| `Ask::RAG::Loader::Text` | Plain text | None (stdlib) |
|
|
51
|
+
| `Ask::RAG::Loader::Markdown` | Markdown | None (stdlib) |
|
|
52
|
+
| `Ask::RAG::Loader::CSV` | CSV (1 doc/row) | None (`csv` gem) |
|
|
53
|
+
| `Ask::RAG::Loader::HTML` | HTML (strips script/style/nav) | `nokogiri` |
|
|
54
|
+
| `Ask::RAG::Loader::PDF` | PDF (1 doc/page) | `pdf-reader` |
|
|
55
|
+
|
|
56
|
+
Loaders without their dependency gem installed are skipped silently — install the gem when you need the format.
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
loader = Ask::RAG::Loader::PDF.new
|
|
60
|
+
docs = loader.load("manual.pdf")
|
|
61
|
+
docs.length # => number of pages
|
|
62
|
+
docs[0].metadata # => { source: "manual.pdf", page: 1, format: "pdf" }
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Text Splitters
|
|
66
|
+
|
|
67
|
+
Split documents into smaller, semantically coherent chunks.
|
|
68
|
+
|
|
69
|
+
| Splitter | Strategy | Best for |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| `RecursiveCharacter` | Descending separators (`\n\n`, `\n`, `. `, ` `, char) | General text |
|
|
72
|
+
| `Markdown` | Split on headers, preserve hierarchy | Markdown docs |
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
splitter = Ask::RAG::TextSplitter::RecursiveCharacter.new(
|
|
76
|
+
chunk_size: 1000,
|
|
77
|
+
chunk_overlap: 200
|
|
78
|
+
)
|
|
79
|
+
chunks = splitter.split_documents(docs)
|
|
80
|
+
# metadata[:chunk] tracks the chunk index per source document
|
|
81
|
+
|
|
82
|
+
splitter = Ask::RAG::TextSplitter::Markdown.new
|
|
83
|
+
chunks = splitter.split_text(markdown_content)
|
|
84
|
+
# Each chunk prepends header hierarchy: "Introduction > Background\n\ncontent..."
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Vector Stores
|
|
88
|
+
|
|
89
|
+
Store embeddings and search by similarity.
|
|
90
|
+
|
|
91
|
+
| Store | Description | Dependencies |
|
|
92
|
+
|---|---|---|
|
|
93
|
+
| `InMemory` | Pure Ruby cosine similarity. Zero deps. | None |
|
|
94
|
+
| `PGVector` (auto-loaded) | PostgreSQL + pgvector extension | `pgvector`, `activerecord` |
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
# InMemory — no database needed
|
|
98
|
+
store = Ask::RAG::VectorStore::InMemory.new
|
|
99
|
+
store.add(documents, model: "text-embedding-3-small")
|
|
100
|
+
results = store.similarity_search("query", limit: 5)
|
|
101
|
+
|
|
102
|
+
# PGVector — for production Rails apps
|
|
103
|
+
store = Ask::RAG::VectorStore::PGVector.new(table_name: :embeddings)
|
|
104
|
+
store.add(documents, model: "text-embedding-3-small")
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Search results have their similarity score in `metadata[:score]`:
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
results = store.similarity_search("authentication", limit: 3)
|
|
111
|
+
results.each do |doc|
|
|
112
|
+
puts "#{doc.metadata[:score]} — #{doc.content[0..60]}"
|
|
113
|
+
end
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### High-Level RAG Query
|
|
117
|
+
|
|
118
|
+
Retrieve + prompt + answer in one call:
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
answer = Ask::RAG::Query.query(
|
|
122
|
+
store: store,
|
|
123
|
+
question: "What is the default timeout configuration?",
|
|
124
|
+
model: "gpt-4o"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
puts answer.content # The LLM's answer
|
|
128
|
+
puts answer.metadata[:sources] # ["config.md", "settings.md"]
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Pipeline (End-to-End)
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
# Load
|
|
135
|
+
loader = Ask::RAG::Loader::PDF.new
|
|
136
|
+
docs = loader.load("user_manual.pdf")
|
|
137
|
+
|
|
138
|
+
# Split
|
|
139
|
+
splitter = Ask::RAG::TextSplitter::RecursiveCharacter.new(
|
|
140
|
+
chunk_size: 500, chunk_overlap: 50
|
|
141
|
+
)
|
|
142
|
+
chunks = splitter.split_documents(docs)
|
|
143
|
+
|
|
144
|
+
# Store
|
|
145
|
+
store = Ask::RAG::VectorStore::InMemory.new
|
|
146
|
+
store.add(chunks, model: "text-embedding-3-small")
|
|
147
|
+
|
|
148
|
+
# Ask
|
|
149
|
+
answer = Ask::RAG::Query.query(
|
|
150
|
+
store: store,
|
|
151
|
+
question: "How do I reset my password?",
|
|
152
|
+
model: "gpt-4o"
|
|
153
|
+
)
|
|
154
|
+
puts answer.content
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Integration with ask-agent
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
require "ask-rag"
|
|
161
|
+
require "ask-agent"
|
|
162
|
+
|
|
163
|
+
# Index your documentation
|
|
164
|
+
store = Ask::RAG::VectorStore::InMemory.new
|
|
165
|
+
loader = Ask::RAG::Loader::Markdown.new
|
|
166
|
+
splitter = Ask::RAG::TextSplitter::RecursiveCharacter.new(chunk_size: 1000)
|
|
167
|
+
docs = splitter.split_documents(loader.load("docs/"))
|
|
168
|
+
store.add(docs, model: "text-embedding-3-small")
|
|
169
|
+
|
|
170
|
+
# Give the agent a search tool
|
|
171
|
+
class SearchDocs < Ask::Tool
|
|
172
|
+
description "Search documentation for relevant information"
|
|
173
|
+
param :query, type: :string, desc: "Search query"
|
|
174
|
+
|
|
175
|
+
def execute(query:)
|
|
176
|
+
store.similarity_search(query, limit: 3).map do |doc|
|
|
177
|
+
{ content: doc.content[0..500], score: doc.metadata[:score] }
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
session = Ask::Agent::Session.new(
|
|
183
|
+
model: "gpt-4o",
|
|
184
|
+
tools: [SearchDocs]
|
|
185
|
+
)
|
|
186
|
+
session.run("How do I configure authentication?")
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Development
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
bundle exec rake test
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## License
|
|
196
|
+
|
|
197
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module RAG
|
|
5
|
+
# Re-export Ask::Document so it's accessible as Ask::RAG::Document too,
|
|
6
|
+
# for users who prefer to namespace everything under Ask::RAG.
|
|
7
|
+
Document = Ask::Document unless const_defined?(:Document)
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module RAG
|
|
5
|
+
module Loader
|
|
6
|
+
class Base
|
|
7
|
+
# Load a file and return an array of Ask::Document objects.
|
|
8
|
+
# Subclasses implement #load to produce documents from a specific format.
|
|
9
|
+
#
|
|
10
|
+
# @param path [String] path to the file
|
|
11
|
+
# @return [Array<Ask::Document>]
|
|
12
|
+
def load(path)
|
|
13
|
+
raise NotImplementedError, "#{self.class} must implement #load"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Load a file and return a lazy enumerator of documents.
|
|
17
|
+
# Override in subclasses that support streaming.
|
|
18
|
+
#
|
|
19
|
+
# @param path [String] path to the file
|
|
20
|
+
# @return [Enumerator<Ask::Document>]
|
|
21
|
+
def lazy_load(path)
|
|
22
|
+
load(path).each
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module RAG
|
|
7
|
+
module Loader
|
|
8
|
+
# Loads a CSV file, producing one document per row.
|
|
9
|
+
#
|
|
10
|
+
# Each document's content is the row's cell values joined by newlines.
|
|
11
|
+
# Metadata includes the source path and any identifying columns.
|
|
12
|
+
class CSV < Base
|
|
13
|
+
# @param path [String] path to the CSV file
|
|
14
|
+
# @param content_columns [Array<String>, nil] columns to include in content
|
|
15
|
+
# (nil = all columns)
|
|
16
|
+
# @param metadata_columns [Array<String>, nil] columns to include in metadata
|
|
17
|
+
# (nil = all columns except content_columns)
|
|
18
|
+
# @param separator [String] column separator (default: ",")
|
|
19
|
+
def initialize(content_columns: nil, metadata_columns: nil, separator: ",")
|
|
20
|
+
@content_columns = content_columns
|
|
21
|
+
@metadata_columns = metadata_columns
|
|
22
|
+
@separator = separator
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def load(path)
|
|
26
|
+
docs = []
|
|
27
|
+
options = { headers: true, col_sep: @separator }
|
|
28
|
+
|
|
29
|
+
::CSV.foreach(path, **options) do |row|
|
|
30
|
+
content = build_content(row)
|
|
31
|
+
metadata = build_metadata(row, path)
|
|
32
|
+
docs << Ask::Document.new(content: content, metadata: metadata)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
docs
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def build_content(row)
|
|
41
|
+
if @content_columns
|
|
42
|
+
@content_columns.map { |col| row[col] }.compact.join("\n")
|
|
43
|
+
else
|
|
44
|
+
row.fields.join("\n")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def build_metadata(row, path)
|
|
49
|
+
meta = { source: path }
|
|
50
|
+
if @metadata_columns
|
|
51
|
+
@metadata_columns.each do |col|
|
|
52
|
+
meta[col] = row[col] if row[col]
|
|
53
|
+
end
|
|
54
|
+
elsif @content_columns
|
|
55
|
+
row.headers.each do |h|
|
|
56
|
+
meta[h] = row[h] unless @content_columns.include?(h)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
meta
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module RAG
|
|
5
|
+
module Loader
|
|
6
|
+
# Loads an HTML file and extracts text content using Nokogiri.
|
|
7
|
+
# Strips script, style, and nav elements by default.
|
|
8
|
+
class HTML < Base
|
|
9
|
+
# @param elements_to_remove [Array<String>] CSS selectors to strip
|
|
10
|
+
def initialize(elements_to_remove: %w[script style nav footer header])
|
|
11
|
+
@elements_to_remove = elements_to_remove
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def load(path)
|
|
15
|
+
content = File.read(path)
|
|
16
|
+
doc = Nokogiri::HTML.parse(content)
|
|
17
|
+
|
|
18
|
+
@elements_to_remove.each do |selector|
|
|
19
|
+
doc.css(selector).each(&:remove)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
text = doc.at_xpath("//body")&.text || doc.text
|
|
23
|
+
text = text.gsub(/\s+/, " ").strip
|
|
24
|
+
|
|
25
|
+
[Ask::Document.new(
|
|
26
|
+
content: text,
|
|
27
|
+
metadata: { source: path, format: "html" }
|
|
28
|
+
)]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module RAG
|
|
5
|
+
module Loader
|
|
6
|
+
# Loads a Markdown file as a single document.
|
|
7
|
+
# Use MarkdownTextSplitter afterward to split by headers.
|
|
8
|
+
class Markdown < Base
|
|
9
|
+
def load(path)
|
|
10
|
+
content = File.read(path)
|
|
11
|
+
[Ask::Document.new(
|
|
12
|
+
content: content,
|
|
13
|
+
metadata: { source: path, format: "markdown" }
|
|
14
|
+
)]
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module RAG
|
|
5
|
+
module Loader
|
|
6
|
+
# Loads a PDF file using the pdf-reader gem, producing one document
|
|
7
|
+
# per page. Each document's metadata includes the page number.
|
|
8
|
+
class PDF < Base
|
|
9
|
+
def load(path)
|
|
10
|
+
reader = PDF::Reader.new(path)
|
|
11
|
+
reader.pages.map.with_index(1) do |page, idx|
|
|
12
|
+
Ask::Document.new(
|
|
13
|
+
content: page.text,
|
|
14
|
+
metadata: { source: path, page: idx, format: "pdf" }
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module RAG
|
|
5
|
+
module Loader
|
|
6
|
+
# Loads a plain text file as a single document.
|
|
7
|
+
class Text < Base
|
|
8
|
+
def load(path)
|
|
9
|
+
content = File.read(path)
|
|
10
|
+
[Ask::Document.new(
|
|
11
|
+
content: content,
|
|
12
|
+
metadata: { source: path }
|
|
13
|
+
)]
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module RAG
|
|
5
|
+
# High-level RAG query API.
|
|
6
|
+
#
|
|
7
|
+
# Retrieves relevant documents from a vector store and uses an LLM to
|
|
8
|
+
# answer a question based on those documents.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# answer = Ask::RAG.query(
|
|
12
|
+
# store: my_vector_store,
|
|
13
|
+
# question: "What is the default timeout?",
|
|
14
|
+
# model: "gpt-4o"
|
|
15
|
+
# )
|
|
16
|
+
# puts answer.content
|
|
17
|
+
#
|
|
18
|
+
module Query
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# Perform a RAG query: retrieve relevant documents, build a prompt,
|
|
22
|
+
# and ask an LLM for an answer based on those documents.
|
|
23
|
+
#
|
|
24
|
+
# @param store [Ask::RAG::VectorStore] the vector store to search
|
|
25
|
+
# @param question [String] the user's question
|
|
26
|
+
# @param model [String] LLM model to use for answering
|
|
27
|
+
# @param limit [Integer] number of documents to retrieve (default: 5)
|
|
28
|
+
# @param system_prompt [String, nil] custom system prompt override
|
|
29
|
+
# @param provider [Symbol, nil] optional provider override
|
|
30
|
+
# @return [Ask::Document, nil] the answer with metadata including sources
|
|
31
|
+
def query(store:, question:, model: nil, limit: 5, system_prompt: nil, provider: nil)
|
|
32
|
+
documents = store.similarity_search(question, limit: limit)
|
|
33
|
+
|
|
34
|
+
return nil if documents.empty?
|
|
35
|
+
|
|
36
|
+
context = documents.map.with_index(1) do |doc, i|
|
|
37
|
+
"[#{i}] #{doc.content}\n"
|
|
38
|
+
end.join("\n")
|
|
39
|
+
|
|
40
|
+
prompt = system_prompt || build_default_prompt
|
|
41
|
+
full_prompt = "#{prompt}\n\nContext:\n#{context}\n\nQuestion: #{question}"
|
|
42
|
+
|
|
43
|
+
answer = ask_llm(full_prompt, model: model, provider: provider)
|
|
44
|
+
|
|
45
|
+
Ask::Document.new(
|
|
46
|
+
content: answer,
|
|
47
|
+
metadata: {
|
|
48
|
+
sources: documents.map { |d| d.metadata[:source] }.compact.uniq,
|
|
49
|
+
model: model
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Generate an answer using the LLM.
|
|
55
|
+
def ask_llm(prompt, model: nil, provider: nil) # rubocop:disable Metrics/MethodLength
|
|
56
|
+
require "ask/agent" unless defined?(Ask::Agent)
|
|
57
|
+
|
|
58
|
+
session_opts = { tools: [] }
|
|
59
|
+
session_opts[:model] = model if model
|
|
60
|
+
session_opts[:provider] = provider if provider
|
|
61
|
+
|
|
62
|
+
session = Ask::Agent::Session.new(**session_opts)
|
|
63
|
+
response = session.run(prompt)
|
|
64
|
+
response.to_s
|
|
65
|
+
rescue NameError
|
|
66
|
+
# Fallback: use basic Ask::Provider directly if ask-agent isn't available
|
|
67
|
+
fallback_ask(prompt, model)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def build_default_prompt
|
|
71
|
+
"You are a helpful assistant. Answer the question based on the provided context. " \
|
|
72
|
+
"If the context doesn't contain enough information to answer, say so."
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def fallback_ask(prompt, model)
|
|
76
|
+
model ||= "gpt-4o"
|
|
77
|
+
info = Ask::ModelCatalog.find(model)
|
|
78
|
+
klass = Ask::Provider.resolve(info.provider)
|
|
79
|
+
|
|
80
|
+
config = Object.new
|
|
81
|
+
klass.configuration_requirements.each do |req|
|
|
82
|
+
val = ENV["#{req.to_s.upcase}"]
|
|
83
|
+
config.define_singleton_method(req) { val } if val
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
provider = klass.new(config)
|
|
87
|
+
conv = Ask::Conversation.new
|
|
88
|
+
conv.system("Answer based on the context provided.")
|
|
89
|
+
conv.user(prompt)
|
|
90
|
+
response = provider.chat(conv.to_a, model: model)
|
|
91
|
+
response.content.to_s
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private_class_method :build_default_prompt, :fallback_ask
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module RAG
|
|
5
|
+
module TextSplitter
|
|
6
|
+
class Base
|
|
7
|
+
# @param chunk_size [Integer] maximum size of each chunk
|
|
8
|
+
# @param chunk_overlap [Integer] overlap between consecutive chunks
|
|
9
|
+
# @param length_function [#call] function that measures text length (default: :length)
|
|
10
|
+
def initialize(chunk_size: 1000, chunk_overlap: 200, length_function: :length.to_proc)
|
|
11
|
+
@chunk_size = chunk_size
|
|
12
|
+
@chunk_overlap = chunk_overlap
|
|
13
|
+
@length_function = length_function
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Split a single string into chunks.
|
|
17
|
+
# @param text [String] text to split
|
|
18
|
+
# @return [Array<String>] text chunks
|
|
19
|
+
def split_text(text)
|
|
20
|
+
raise NotImplementedError
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Split documents into smaller documents.
|
|
24
|
+
# @param documents [Array<Ask::Document>] documents to split
|
|
25
|
+
# @return [Array<Ask::Document>] split documents with metadata preserved
|
|
26
|
+
def split_documents(documents)
|
|
27
|
+
documents.flat_map do |doc|
|
|
28
|
+
chunks = split_text(doc.content)
|
|
29
|
+
chunks.map.with_index do |chunk, idx|
|
|
30
|
+
Ask::Document.new(
|
|
31
|
+
content: chunk,
|
|
32
|
+
metadata: doc.metadata.merge(chunk: idx)
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
attr_reader :chunk_size, :chunk_overlap, :length_function
|
|
41
|
+
|
|
42
|
+
def measure(text)
|
|
43
|
+
length_function.call(text)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module RAG
|
|
5
|
+
module TextSplitter
|
|
6
|
+
# Splits Markdown text by headers, preserving header hierarchy in metadata.
|
|
7
|
+
#
|
|
8
|
+
# Each chunk starts at a header and includes all content until the next
|
|
9
|
+
# header at the same or higher level. Header context is tracked in
|
|
10
|
+
# metadata so retrieval results know which section they came from.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# splitter = Ask::RAG::TextSplitter::Markdown.new
|
|
14
|
+
# chunks = splitter.split_documents(docs)
|
|
15
|
+
# chunks[0].metadata[:headers] # => { h1: "Introduction", h2: "Background" }
|
|
16
|
+
#
|
|
17
|
+
class Markdown < Base
|
|
18
|
+
HEADER_PATTERN = /^(#{'#'}{1,6})\s+(.+)$/
|
|
19
|
+
|
|
20
|
+
def split_text(text)
|
|
21
|
+
chunks = []
|
|
22
|
+
current_header = {}
|
|
23
|
+
current_lines = []
|
|
24
|
+
|
|
25
|
+
text.each_line do |line|
|
|
26
|
+
if (match = line.match(HEADER_PATTERN))
|
|
27
|
+
# Save previous section
|
|
28
|
+
if current_lines.any?
|
|
29
|
+
chunks << build_chunk(current_lines.join, current_header)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
level = match[1].length
|
|
33
|
+
heading_text = match[2].strip
|
|
34
|
+
|
|
35
|
+
# Clear lower-level headers
|
|
36
|
+
current_header.select! { |k, _| HEADER_LEVELS[k] && HEADER_LEVELS[k] < level }
|
|
37
|
+
current_header["h#{level}".to_sym] = heading_text
|
|
38
|
+
|
|
39
|
+
current_lines = []
|
|
40
|
+
else
|
|
41
|
+
current_lines << line
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Last section
|
|
46
|
+
chunks << build_chunk(current_lines.join, current_header) if current_lines.any?
|
|
47
|
+
chunks
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
HEADER_LEVELS = { h1: 1, h2: 2, h3: 3, h4: 4, h5: 5, h6: 6 }.freeze
|
|
53
|
+
|
|
54
|
+
def build_chunk(text, headers)
|
|
55
|
+
cleaned = text.strip
|
|
56
|
+
return nil if cleaned.empty?
|
|
57
|
+
|
|
58
|
+
# Include header text at the top for context
|
|
59
|
+
header_line = headers.values.join(" > ")
|
|
60
|
+
content = header_line.empty? ? cleaned : "#{header_line}\n\n#{cleaned}"
|
|
61
|
+
content
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module RAG
|
|
5
|
+
module TextSplitter
|
|
6
|
+
# Splits text recursively by descending separator length.
|
|
7
|
+
#
|
|
8
|
+
# The default separators try paragraph breaks, then line breaks,
|
|
9
|
+
# then sentences, then words, then characters. This produces
|
|
10
|
+
# semantically coherent chunks that fit within chunk_size.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# splitter = Ask::RAG::TextSplitter::RecursiveCharacter.new(
|
|
14
|
+
# chunk_size: 500, chunk_overlap: 50
|
|
15
|
+
# )
|
|
16
|
+
# chunks = splitter.split_text(long_text)
|
|
17
|
+
#
|
|
18
|
+
class RecursiveCharacter < Base
|
|
19
|
+
# @param separators [Array<String>] ordered list of separators to try
|
|
20
|
+
# @param chunk_size [Integer] max chunk size
|
|
21
|
+
# @param chunk_overlap [Integer] overlap between chunks
|
|
22
|
+
def initialize(separators: ["\n\n", "\n", ". ", " ", ""], **options)
|
|
23
|
+
@separators = separators
|
|
24
|
+
super(**options)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def split_text(text)
|
|
28
|
+
splits = split_with_separators(text, @separators)
|
|
29
|
+
merge_splits(splits)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def split_with_separators(text, seps)
|
|
35
|
+
separator = seps.first || ""
|
|
36
|
+
splits = if separator.empty?
|
|
37
|
+
text.chars.map { |c| [c, separator] }
|
|
38
|
+
else
|
|
39
|
+
text.split(separator).map { |s| [s, separator] }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Recursively split any piece that's still too long
|
|
43
|
+
splits = splits.flat_map do |piece, sep|
|
|
44
|
+
remaining = seps[1..]
|
|
45
|
+
if remaining.any? && piece.length > chunk_size * 1.5
|
|
46
|
+
split_with_separators(piece, remaining)
|
|
47
|
+
else
|
|
48
|
+
[[piece, sep]]
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
splits
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def merge_splits(splits)
|
|
56
|
+
chunks = []
|
|
57
|
+
current = +""
|
|
58
|
+
current_size = 0
|
|
59
|
+
|
|
60
|
+
splits.each do |piece, _separator|
|
|
61
|
+
piece_size = measure(piece)
|
|
62
|
+
|
|
63
|
+
if current_size + piece_size <= chunk_size || current.empty?
|
|
64
|
+
current << piece
|
|
65
|
+
current_size += piece_size
|
|
66
|
+
else
|
|
67
|
+
# Finalize current chunk
|
|
68
|
+
chunks << current.dup
|
|
69
|
+
|
|
70
|
+
# Build overlap from the end of the current chunk
|
|
71
|
+
overlap = build_overlap(current)
|
|
72
|
+
current = overlap.dup
|
|
73
|
+
current_size = measure(current)
|
|
74
|
+
current << piece
|
|
75
|
+
current_size += piece_size
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
chunks << current unless current.empty?
|
|
80
|
+
chunks
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def build_overlap(text)
|
|
84
|
+
return +"" if chunk_overlap <= 0
|
|
85
|
+
|
|
86
|
+
chars = text.chars
|
|
87
|
+
overlap_chars = []
|
|
88
|
+
overlap_len = 0
|
|
89
|
+
|
|
90
|
+
chars.reverse_each do |c|
|
|
91
|
+
break if overlap_len >= chunk_overlap
|
|
92
|
+
overlap_chars.unshift(c)
|
|
93
|
+
overlap_len += measure(c)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
overlap_chars.join
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module RAG
|
|
5
|
+
# Abstract base class for vector stores.
|
|
6
|
+
#
|
|
7
|
+
# Vector stores store embedded documents and provide similarity search.
|
|
8
|
+
# Subclasses implement the storage and retrieval logic for specific
|
|
9
|
+
# backends (InMemory, PGVector, Chroma, etc.).
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# store = Ask::RAG::VectorStore::InMemory.new
|
|
13
|
+
# store.add(documents, model: "text-embedding-3-small")
|
|
14
|
+
# results = store.similarity_search("query text", limit: 5)
|
|
15
|
+
#
|
|
16
|
+
class VectorStore
|
|
17
|
+
# Add documents to the store. They are embedded using the given model
|
|
18
|
+
# via ask-llm-providers or a custom embedding function.
|
|
19
|
+
#
|
|
20
|
+
# @param documents [Array<Ask::Document>] documents to add
|
|
21
|
+
# @param model [String] embedding model name (e.g. "text-embedding-3-small")
|
|
22
|
+
# @param batch_size [Integer] number of documents to embed per API call
|
|
23
|
+
# @return [Array<String>] IDs of the added documents
|
|
24
|
+
def add(documents, model:, batch_size: 20)
|
|
25
|
+
raise NotImplementedError
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Search for documents similar to the given query text.
|
|
29
|
+
#
|
|
30
|
+
# @param query [String] the query text
|
|
31
|
+
# @param limit [Integer] maximum number of results
|
|
32
|
+
# @return [Array<Ask::Document>] documents with a +score+ attribute added
|
|
33
|
+
def similarity_search(query, limit: 10)
|
|
34
|
+
raise NotImplementedError
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Remove documents by ID.
|
|
38
|
+
# @param ids [Array<String>] document IDs to remove
|
|
39
|
+
def delete(ids)
|
|
40
|
+
raise NotImplementedError
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Remove all documents from the store.
|
|
44
|
+
def clear
|
|
45
|
+
raise NotImplementedError
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [Integer] number of documents in the store
|
|
49
|
+
def size
|
|
50
|
+
raise NotImplementedError
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module RAG
|
|
5
|
+
class VectorStore
|
|
6
|
+
# In-memory vector store using cosine similarity.
|
|
7
|
+
#
|
|
8
|
+
# Stores all documents and vectors in a Ruby Hash. Uses pure Ruby for
|
|
9
|
+
# cosine similarity — no external dependencies. Suitable for
|
|
10
|
+
# development, testing, and small-scale prototypes.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# store = Ask::RAG::VectorStore::InMemory.new
|
|
14
|
+
# store.add(chunks, model: "text-embedding-3-small")
|
|
15
|
+
# results = store.similarity_search("query", limit: 5)
|
|
16
|
+
#
|
|
17
|
+
class InMemory < VectorStore
|
|
18
|
+
Entry = Struct.new(:id, :document, :vector, keyword_init: true)
|
|
19
|
+
|
|
20
|
+
def initialize
|
|
21
|
+
@entries = {}
|
|
22
|
+
@mutex = Mutex.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def add(documents, model:, batch_size: 20)
|
|
26
|
+
ids = documents.map { |d| d.id || SecureRandom.uuid }
|
|
27
|
+
texts = documents.map(&:content)
|
|
28
|
+
|
|
29
|
+
# Embed using ask-llm-providers
|
|
30
|
+
provider = resolve_embedding_provider(model)
|
|
31
|
+
raw_vectors = provider.embed(texts, model: model)
|
|
32
|
+
|
|
33
|
+
vectors = normalize_vectors(raw_vectors, texts)
|
|
34
|
+
|
|
35
|
+
@mutex.synchronize do
|
|
36
|
+
documents.each_with_index do |doc, idx|
|
|
37
|
+
@entries[ids[idx]] = Entry.new(
|
|
38
|
+
id: ids[idx],
|
|
39
|
+
document: doc,
|
|
40
|
+
vector: vectors[idx]
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
ids
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def similarity_search(query, limit: 10)
|
|
49
|
+
query_vector = embed_query(query)
|
|
50
|
+
|
|
51
|
+
scored = @mutex.synchronize do
|
|
52
|
+
@entries.map do |_id, entry|
|
|
53
|
+
score = cosine_similarity(query_vector, entry.vector)
|
|
54
|
+
[entry, score]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
scored.sort_by! { |_, score| -score }
|
|
59
|
+
scored.first(limit).map do |entry, score|
|
|
60
|
+
doc = entry.document
|
|
61
|
+
Ask::Document.new(
|
|
62
|
+
content: doc.content,
|
|
63
|
+
metadata: doc.metadata.merge(score: score),
|
|
64
|
+
id: doc.id
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def delete(ids)
|
|
70
|
+
@mutex.synchronize do
|
|
71
|
+
ids.each { |id| @entries.delete(id) }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def clear
|
|
76
|
+
@mutex.synchronize { @entries.clear }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def size
|
|
80
|
+
@mutex.synchronize { @entries.size }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def resolve_embedding_provider(model)
|
|
86
|
+
info = Ask::ModelCatalog.find(model)
|
|
87
|
+
provider_class = Ask::Provider.resolve(info.provider)
|
|
88
|
+
config = build_provider_config(provider_class)
|
|
89
|
+
provider_class.new(config)
|
|
90
|
+
rescue Ask::ModelNotFound, Ask::UnknownProvider
|
|
91
|
+
# Fallback: try to use OpenAI-compatible directly
|
|
92
|
+
Ask::Provider.resolve(:openai).new(
|
|
93
|
+
build_provider_config(Ask::Provider.resolve(:openai))
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def build_provider_config(provider_class)
|
|
98
|
+
config = Object.new
|
|
99
|
+
provider_class.configuration_requirements.each do |req|
|
|
100
|
+
val = ENV["#{req.to_s.upcase}"] || ENV["#{provider_class.slug.upcase}_#{req.to_s.upcase}"]
|
|
101
|
+
config.define_singleton_method(req) { val } if val
|
|
102
|
+
end
|
|
103
|
+
provider_class.configuration_options.each do |opt|
|
|
104
|
+
val = ENV["#{opt.to_s.upcase}"] || ENV["#{provider_class.slug.upcase}_#{opt.to_s.upcase}"]
|
|
105
|
+
config.define_singleton_method(opt) { val } if val
|
|
106
|
+
end
|
|
107
|
+
config
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def embed_query(query)
|
|
111
|
+
provider = resolve_embedding_provider("text-embedding-3-small")
|
|
112
|
+
raw = provider.embed(query, model: "text-embedding-3-small")
|
|
113
|
+
|
|
114
|
+
if raw.is_a?(Ask::Result)
|
|
115
|
+
raw = raw.output
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
Array(raw).flatten.map(&:to_f)
|
|
119
|
+
rescue StandardError
|
|
120
|
+
# Return a zero vector as fallback (will produce no meaningful matches)
|
|
121
|
+
[0.0] * 256
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def normalize_vectors(raw_vectors, texts)
|
|
125
|
+
result = if raw_vectors.is_a?(Ask::Result)
|
|
126
|
+
raw_vectors.output
|
|
127
|
+
else
|
|
128
|
+
raw_vectors
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
result = Array(result)
|
|
132
|
+
|
|
133
|
+
# If the provider returned a flat vector for a single document
|
|
134
|
+
if result.length == 1 && texts.length == 1
|
|
135
|
+
[Array(result.first).map(&:to_f)]
|
|
136
|
+
elsif result.first.is_a?(Array)
|
|
137
|
+
result.map { |v| Array(v).map(&:to_f) }
|
|
138
|
+
else
|
|
139
|
+
# Flattened — try to split by dimensions
|
|
140
|
+
dims = infer_dimensions(result)
|
|
141
|
+
result.each_slice(dims).to_a
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def infer_dimensions(flat)
|
|
146
|
+
# Common embedding dimensions
|
|
147
|
+
[3072, 1536, 1024, 768, 512, 384, 256].each do |d|
|
|
148
|
+
return d if flat.length % d == 0 && flat.length / d > 0
|
|
149
|
+
end
|
|
150
|
+
flat.length
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def cosine_similarity(a, b)
|
|
154
|
+
return 0.0 if a.empty? || b.empty? || a.length != b.length
|
|
155
|
+
|
|
156
|
+
dot = 0.0
|
|
157
|
+
norm_a = 0.0
|
|
158
|
+
norm_b = 0.0
|
|
159
|
+
|
|
160
|
+
a.zip(b).each do |ai, bi|
|
|
161
|
+
dot += ai * bi
|
|
162
|
+
norm_a += ai * ai
|
|
163
|
+
norm_b += bi * bi
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
denom = Math.sqrt(norm_a) * Math.sqrt(norm_b)
|
|
167
|
+
denom > 0 ? dot / denom : 0.0
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module RAG
|
|
5
|
+
class VectorStore
|
|
6
|
+
# PGVector-backed vector store using PostgreSQL + the pgvector extension.
|
|
7
|
+
#
|
|
8
|
+
# Stores document text, metadata, and embeddings in a PostgreSQL table.
|
|
9
|
+
# Requires the `pgvector` gem and an ActiveRecord connection.
|
|
10
|
+
#
|
|
11
|
+
# @example Setup
|
|
12
|
+
# # In your migration:
|
|
13
|
+
# # create_table :documents do |t|
|
|
14
|
+
# # t.text :content
|
|
15
|
+
# # t.jsonb :metadata
|
|
16
|
+
# # t.vector :embedding, limit: 1536
|
|
17
|
+
# # end
|
|
18
|
+
# # add_index :documents, :embedding, using: :ivfflat, opclass: :vector_cosine_ops
|
|
19
|
+
#
|
|
20
|
+
# @example Usage
|
|
21
|
+
# store = Ask::RAG::VectorStore::PGVector.new(
|
|
22
|
+
# table_name: :documents,
|
|
23
|
+
# embedding_column: :embedding
|
|
24
|
+
# )
|
|
25
|
+
# store.add(chunks, model: "text-embedding-3-small")
|
|
26
|
+
# results = store.similarity_search("query", limit: 5)
|
|
27
|
+
#
|
|
28
|
+
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)
|
|
33
|
+
# @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
|
|
40
|
+
@model_class = model_class || infer_model_class
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def add(documents, model:, batch_size: 20)
|
|
44
|
+
ids = []
|
|
45
|
+
model_class = @model_class
|
|
46
|
+
|
|
47
|
+
documents.each_slice(batch_size) do |batch|
|
|
48
|
+
texts = batch.map(&:content)
|
|
49
|
+
vectors = embed_texts(texts, model)
|
|
50
|
+
|
|
51
|
+
batch.each_with_index do |doc, idx|
|
|
52
|
+
record = model_class.create!(
|
|
53
|
+
@content_column => doc.content,
|
|
54
|
+
@metadata_column => doc.metadata,
|
|
55
|
+
@embedding_column => vectors[idx]
|
|
56
|
+
)
|
|
57
|
+
ids << record.id.to_s
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
ids
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def similarity_search(query, limit: 10)
|
|
65
|
+
query_vector = embed_query(query)
|
|
66
|
+
column = "#{@table_name}.#{@embedding_column}"
|
|
67
|
+
model_class = @model_class
|
|
68
|
+
|
|
69
|
+
records = model_class
|
|
70
|
+
.select("#{@table_name}.*, 1 - (#{column} <=> '#{query_vector.to_s}') AS score")
|
|
71
|
+
.where("#{column} IS NOT NULL")
|
|
72
|
+
.order(Arel.sql("#{column} <=> '#{query_vector.to_s}'"))
|
|
73
|
+
.limit(limit)
|
|
74
|
+
|
|
75
|
+
records.map do |record|
|
|
76
|
+
doc = Ask::Document.new(
|
|
77
|
+
content: record.send(@content_column),
|
|
78
|
+
metadata: (record.send(@metadata_column) || {}).merge(db_id: record.id)
|
|
79
|
+
)
|
|
80
|
+
doc.define_singleton_method(:score) { record.score }
|
|
81
|
+
doc
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def delete(ids)
|
|
86
|
+
@model_class.where(id: ids).delete_all
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def clear
|
|
90
|
+
@model_class.delete_all
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def size
|
|
94
|
+
@model_class.count
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def infer_model_class
|
|
100
|
+
klass = Class.new(ActiveRecord::Base) # rubocop:disable Rails/ApplicationRecord
|
|
101
|
+
klass.table_name = @table_name
|
|
102
|
+
klass
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def embed_texts(texts, model)
|
|
106
|
+
provider = resolve_provider(model)
|
|
107
|
+
raw = provider.embed(texts, model: model)
|
|
108
|
+
raw = raw.output if raw.is_a?(Ask::Result)
|
|
109
|
+
Array(raw).map { |v| Array(v).map(&:to_f) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def embed_query(query)
|
|
113
|
+
provider = resolve_provider("text-embedding-3-small")
|
|
114
|
+
raw = provider.embed(query, model: "text-embedding-3-small")
|
|
115
|
+
raw = raw.output if raw.is_a?(Ask::Result)
|
|
116
|
+
Array(raw).flatten.map(&:to_f)
|
|
117
|
+
rescue StandardError
|
|
118
|
+
[0.0] * 1536
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def resolve_provider(model)
|
|
122
|
+
info = Ask::ModelCatalog.find(model)
|
|
123
|
+
klass = Ask::Provider.resolve(info.provider)
|
|
124
|
+
config = build_config(klass)
|
|
125
|
+
klass.new(config)
|
|
126
|
+
rescue Ask::ModelNotFound, Ask::UnknownProvider
|
|
127
|
+
Ask::Provider.resolve(:openai).new(build_config(Ask::Provider.resolve(:openai)))
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def build_config(klass)
|
|
131
|
+
config = Object.new
|
|
132
|
+
klass.configuration_requirements.each do |req|
|
|
133
|
+
val = ENV["#{req.to_s.upcase}"]
|
|
134
|
+
config.define_singleton_method(req) { val } if val
|
|
135
|
+
end
|
|
136
|
+
klass.configuration_options.each do |opt|
|
|
137
|
+
val = ENV["#{opt.to_s.upcase}"]
|
|
138
|
+
config.define_singleton_method(opt) { val } if val
|
|
139
|
+
end
|
|
140
|
+
config
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
data/lib/ask/rag.rb
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rag/version"
|
|
4
|
+
require_relative "rag/document"
|
|
5
|
+
|
|
6
|
+
# Loaders
|
|
7
|
+
require_relative "rag/loader/base"
|
|
8
|
+
require_relative "rag/loader/text"
|
|
9
|
+
require_relative "rag/loader/markdown"
|
|
10
|
+
require_relative "rag/loader/csv"
|
|
11
|
+
|
|
12
|
+
# Optional loaders (loaded only when their dependency gem is available)
|
|
13
|
+
begin
|
|
14
|
+
require "nokogiri"
|
|
15
|
+
require_relative "rag/loader/html"
|
|
16
|
+
rescue LoadError # rubocop:disable Lint/SuppressedException
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
begin
|
|
20
|
+
require "pdf-reader"
|
|
21
|
+
require_relative "rag/loader/pdf"
|
|
22
|
+
rescue LoadError # rubocop:disable Lint/SuppressedException
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Text splitters
|
|
26
|
+
require_relative "rag/text_splitter/base"
|
|
27
|
+
require_relative "rag/text_splitter/recursive_character"
|
|
28
|
+
require_relative "rag/text_splitter/markdown"
|
|
29
|
+
|
|
30
|
+
# Vector stores
|
|
31
|
+
require_relative "rag/vector_store/base"
|
|
32
|
+
require_relative "rag/vector_store/in_memory"
|
|
33
|
+
|
|
34
|
+
# Optional: PGVector
|
|
35
|
+
begin
|
|
36
|
+
require "pgvector"
|
|
37
|
+
require_relative "rag/vector_store/pgvector"
|
|
38
|
+
rescue LoadError # rubocop:disable Lint/SuppressedException
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
require_relative "rag/query"
|
data/lib/ask-rag.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ask-rag
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Kaka Ruto
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: ask-core
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.6'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.6'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: csv
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: minitest
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '5.25'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '5.25'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: mocha
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '3.1'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '3.1'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: rake
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '13.0'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '13.0'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: reline
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - ">="
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '0'
|
|
89
|
+
type: :development
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - ">="
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '0'
|
|
96
|
+
description: Document loaders, text splitters, vector stores, and retrieval queries
|
|
97
|
+
for building RAG applications with ask-rb.
|
|
98
|
+
email:
|
|
99
|
+
- kaka@myrrlabs.com
|
|
100
|
+
executables: []
|
|
101
|
+
extensions: []
|
|
102
|
+
extra_rdoc_files: []
|
|
103
|
+
files:
|
|
104
|
+
- CHANGELOG.md
|
|
105
|
+
- LICENSE
|
|
106
|
+
- README.md
|
|
107
|
+
- lib/ask-rag.rb
|
|
108
|
+
- lib/ask/rag.rb
|
|
109
|
+
- lib/ask/rag/document.rb
|
|
110
|
+
- lib/ask/rag/loader/base.rb
|
|
111
|
+
- lib/ask/rag/loader/csv.rb
|
|
112
|
+
- lib/ask/rag/loader/html.rb
|
|
113
|
+
- lib/ask/rag/loader/markdown.rb
|
|
114
|
+
- lib/ask/rag/loader/pdf.rb
|
|
115
|
+
- lib/ask/rag/loader/text.rb
|
|
116
|
+
- lib/ask/rag/query.rb
|
|
117
|
+
- lib/ask/rag/text_splitter/base.rb
|
|
118
|
+
- lib/ask/rag/text_splitter/markdown.rb
|
|
119
|
+
- lib/ask/rag/text_splitter/recursive_character.rb
|
|
120
|
+
- lib/ask/rag/vector_store/base.rb
|
|
121
|
+
- lib/ask/rag/vector_store/in_memory.rb
|
|
122
|
+
- lib/ask/rag/vector_store/pgvector.rb
|
|
123
|
+
- lib/ask/rag/version.rb
|
|
124
|
+
homepage: https://github.com/ask-rb/ask-rag
|
|
125
|
+
licenses:
|
|
126
|
+
- MIT
|
|
127
|
+
metadata:
|
|
128
|
+
homepage_uri: https://github.com/ask-rb/ask-rag
|
|
129
|
+
source_code_uri: https://github.com/ask-rb/ask-rag
|
|
130
|
+
changelog_uri: https://github.com/ask-rb/ask-rag/blob/master/CHANGELOG.md
|
|
131
|
+
rdoc_options: []
|
|
132
|
+
require_paths:
|
|
133
|
+
- lib
|
|
134
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
135
|
+
requirements:
|
|
136
|
+
- - ">="
|
|
137
|
+
- !ruby/object:Gem::Version
|
|
138
|
+
version: '3.2'
|
|
139
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
140
|
+
requirements:
|
|
141
|
+
- - ">="
|
|
142
|
+
- !ruby/object:Gem::Version
|
|
143
|
+
version: '0'
|
|
144
|
+
requirements: []
|
|
145
|
+
rubygems_version: 4.0.3
|
|
146
|
+
specification_version: 4
|
|
147
|
+
summary: RAG pipeline for the ask-rb ecosystem
|
|
148
|
+
test_files: []
|