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 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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module RAG
5
+ VERSION = "0.1.0"
6
+ end
7
+ 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
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ask"
4
+ require_relative "ask/rag"
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: []