ask-rag 0.1.0 → 0.2.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 +44 -0
- data/README.md +26 -0
- data/lib/ask/rag/loader/directory.rb +105 -0
- data/lib/ask/rag/vector_store/base.rb +9 -2
- data/lib/ask/rag/vector_store/in_memory.rb +147 -42
- data/lib/ask/rag/vector_store/pgvector.rb +10 -2
- data/lib/ask/rag/version.rb +1 -1
- data/lib/ask/rag.rb +7 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a55e3f456d3e444c8aa58ba2b4c0f909de86bffe87e3e08a0a50becdddd2232e
|
|
4
|
+
data.tar.gz: fd944ef839c03e0a5bf2b597e4867f05c835d62e6a3433529d8e5bfb4bc9ad54
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 698e26ae24b01752d2032ec7345b3671902e1ead593685902b308ebf81b3fdde203581184686e8a95deb02a981a42e46cf99cfb9be7b2a6bd3977f182228bd90
|
|
7
|
+
data.tar.gz: 722a7d7ac59603a15932adf20e4a99452f7f031e6f0623f63645701e36c1350730bc331e024666da953f185ac101bec115913fa53cc343f141e6a4eb0d220aba
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,47 @@
|
|
|
1
|
+
## [0.2.0] — 2026-07-26
|
|
2
|
+
|
|
3
|
+
### Fixed
|
|
4
|
+
|
|
5
|
+
- **Embedding model mismatch** — InMemory store now tracks the model used during `add` and reuses it for query embedding. Previously `embed_query` hardcoded `"text-embedding-3-small"`, which produced wrong-dimension vectors if documents were stored with a different model. Now raises `Ask::RAG::EmbeddingError` if query embedding fails instead of silently returning a zero vector.
|
|
6
|
+
|
|
7
|
+
- **Batch embedding** — `add` now actually uses the `batch_size` parameter to split large document sets into multiple embed API calls instead of sending everything at once.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Metadata filtering** — `similarity_search` now accepts a `filter:` parameter. Only entries whose metadata matches all key/value pairs are considered for search results. Works for both InMemory and PGVector stores.
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
store.similarity_search("query", filter: { section: "api" })
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
- **MMR (Max Marginal Relevance)** — `similarity_search` now accepts `mmr: true` and `diversity_bonus:` parameters. MMR diversifies search results by penalizing documents too similar to already-selected ones. Results include both `:score` and `:mmr_score` in metadata.
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
store.similarity_search("query", limit: 5, mmr: true, diversity_bonus: 0.3)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
- **Directory loader** — `Ask::RAG::Loader::Directory` auto-detects file types by extension and dispatches to the appropriate loader. Walks directories recursively, skips `.git`, `node_modules`, and other non-source directories. Supports optional glob pattern.
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
loader = Ask::RAG::Loader::Directory.new
|
|
27
|
+
docs = loader.load("docs/")
|
|
28
|
+
|
|
29
|
+
loader = Ask::RAG::Loader::Directory.new(pattern: "**/*.md")
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- **Faster cosine similarity** — Uses Ruby's `matrix` gem for vectorized computation when available, with a pure Ruby fallback.
|
|
33
|
+
|
|
34
|
+
- **`Ask::RAG::EmbeddingError`** — raised when embedding fails during query, with a clear error message.
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- `VectorStore#similarity_search` signature updated to accept `filter:`, `mmr:`, and `diversity_bonus:` keyword arguments.
|
|
39
|
+
|
|
40
|
+
### Tested
|
|
41
|
+
|
|
42
|
+
- 60 tests, 148 assertions, 0 failures
|
|
43
|
+
- New tests for: metadata filtering, MMR scoring, directory loader, batch embedding, embedding model tracking, EmbeddingError
|
|
44
|
+
|
|
1
45
|
## [0.1.0] — 2026-07-26
|
|
2
46
|
|
|
3
47
|
### Added
|
data/README.md
CHANGED
|
@@ -52,6 +52,7 @@ Load files into `Ask::Document` objects (text + metadata).
|
|
|
52
52
|
| `Ask::RAG::Loader::CSV` | CSV (1 doc/row) | None (`csv` gem) |
|
|
53
53
|
| `Ask::RAG::Loader::HTML` | HTML (strips script/style/nav) | `nokogiri` |
|
|
54
54
|
| `Ask::RAG::Loader::PDF` | PDF (1 doc/page) | `pdf-reader` |
|
|
55
|
+
| `Ask::RAG::Loader::Directory` | Auto-detects file types by extension | None (uses other loaders internally) |
|
|
55
56
|
|
|
56
57
|
Loaders without their dependency gem installed are skipped silently — install the gem when you need the format.
|
|
57
58
|
|
|
@@ -113,6 +114,31 @@ results.each do |doc|
|
|
|
113
114
|
end
|
|
114
115
|
```
|
|
115
116
|
|
|
117
|
+
### Metadata filtering
|
|
118
|
+
|
|
119
|
+
Filter results by document metadata:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
results = store.similarity_search(
|
|
123
|
+
"authentication",
|
|
124
|
+
filter: { source: "api_docs.md", version: "2.0" }
|
|
125
|
+
)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### MMR (diversified search)
|
|
129
|
+
|
|
130
|
+
Avoid getting 5 near-identical chunks by applying Max Marginal Relevance:
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
results = store.similarity_search(
|
|
134
|
+
"authentication",
|
|
135
|
+
limit: 5,
|
|
136
|
+
mmr: true,
|
|
137
|
+
diversity_bonus: 0.3
|
|
138
|
+
)
|
|
139
|
+
# Results include both :score and :mmr_score in metadata
|
|
140
|
+
```
|
|
141
|
+
|
|
116
142
|
### High-Level RAG Query
|
|
117
143
|
|
|
118
144
|
Retrieve + prompt + answer in one call:
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module RAG
|
|
5
|
+
module Loader
|
|
6
|
+
# Walks a directory tree and loads all supported file types.
|
|
7
|
+
#
|
|
8
|
+
# Automatically detects file types by extension and dispatches to the
|
|
9
|
+
# appropriate loader. Ignores common non-source directories and files.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# loader = Ask::RAG::Loader::Directory.new
|
|
13
|
+
# docs = loader.load("path/to/docs/")
|
|
14
|
+
# # Loads all .md, .txt, .html, .csv, .pdf files recursively
|
|
15
|
+
#
|
|
16
|
+
# @example With glob pattern
|
|
17
|
+
# loader = Ask::RAG::Loader::Directory.new(pattern: "**/*.md")
|
|
18
|
+
# docs = loader.load("docs/")
|
|
19
|
+
# # Only loads Markdown files
|
|
20
|
+
#
|
|
21
|
+
class Directory < Base
|
|
22
|
+
EXTENSION_MAP = {
|
|
23
|
+
".txt" => :text,
|
|
24
|
+
".md" => :markdown,
|
|
25
|
+
".markdown" => :markdown,
|
|
26
|
+
".html" => :html,
|
|
27
|
+
".htm" => :html,
|
|
28
|
+
".csv" => :csv,
|
|
29
|
+
".pdf" => :pdf
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
# Directories to skip when walking (matches basename).
|
|
33
|
+
SKIP_DIRS = %w[
|
|
34
|
+
.git .svn .hg .bzr _darcs node_modules .bundle vendor
|
|
35
|
+
.yardoc coverage tmp log public/assets
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
# @param pattern [String, nil] optional glob pattern (e.g., "**/*.md")
|
|
39
|
+
# When nil, walks all files with known extensions.
|
|
40
|
+
# @param skip_dirs [Array<String>] directory names to skip
|
|
41
|
+
def initialize(pattern: nil, skip_dirs: SKIP_DIRS)
|
|
42
|
+
@pattern = pattern
|
|
43
|
+
@skip_dirs = skip_dirs.map(&:freeze).freeze
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Load all supported files from a directory tree.
|
|
47
|
+
#
|
|
48
|
+
# @param path [String] root directory path
|
|
49
|
+
# @return [Array<Ask::Document>] all loaded documents
|
|
50
|
+
def load(path)
|
|
51
|
+
docs = []
|
|
52
|
+
files(path).each do |file|
|
|
53
|
+
loader = loader_for(file)
|
|
54
|
+
next unless loader
|
|
55
|
+
|
|
56
|
+
docs.concat(loader.load(file))
|
|
57
|
+
rescue StandardError => e
|
|
58
|
+
warn "[ask-rag] Skipping #{file}: #{e.class}: #{e.message}"
|
|
59
|
+
end
|
|
60
|
+
docs
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def files(root)
|
|
66
|
+
if @pattern
|
|
67
|
+
Dir.glob(File.join(root, @pattern)).select { |f| File.file?(f) }
|
|
68
|
+
else
|
|
69
|
+
all_files(root)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def all_files(root)
|
|
74
|
+
result = []
|
|
75
|
+
Dir.entries(root).each do |entry|
|
|
76
|
+
next if entry.start_with?(".")
|
|
77
|
+
next if @skip_dirs.include?(entry)
|
|
78
|
+
|
|
79
|
+
full = File.join(root, entry)
|
|
80
|
+
if File.directory?(full)
|
|
81
|
+
result.concat(all_files(full))
|
|
82
|
+
elsif File.file?(full)
|
|
83
|
+
result << full
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
result
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def loader_for(file)
|
|
90
|
+
ext = File.extname(file).downcase
|
|
91
|
+
type = EXTENSION_MAP[ext]
|
|
92
|
+
return nil unless type
|
|
93
|
+
|
|
94
|
+
case type
|
|
95
|
+
when :text then Loader::Text.new
|
|
96
|
+
when :markdown then Loader::Markdown.new
|
|
97
|
+
when :html then defined?(Loader::HTML) ? Loader::HTML.new : nil
|
|
98
|
+
when :csv then Loader::CSV.new
|
|
99
|
+
when :pdf then defined?(Loader::PDF) ? Loader::PDF.new : nil
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
module Ask
|
|
4
4
|
module RAG
|
|
5
|
+
# Raised when embedding a query fails (e.g., no API key, network error).
|
|
6
|
+
class EmbeddingError < StandardError; end
|
|
7
|
+
|
|
5
8
|
# Abstract base class for vector stores.
|
|
6
9
|
#
|
|
7
10
|
# Vector stores store embedded documents and provide similarity search.
|
|
@@ -29,8 +32,12 @@ module Ask
|
|
|
29
32
|
#
|
|
30
33
|
# @param query [String] the query text
|
|
31
34
|
# @param limit [Integer] maximum number of results
|
|
32
|
-
# @
|
|
33
|
-
|
|
35
|
+
# @param filter [Hash, nil] metadata filter — only entries whose metadata
|
|
36
|
+
# matches all key/value pairs are considered
|
|
37
|
+
# @param mmr [Boolean] apply Max Marginal Relevance for diversity
|
|
38
|
+
# @param diversity_bonus [Float] MMR diversity factor
|
|
39
|
+
# @return [Array<Ask::Document>] documents with +:score+ in metadata
|
|
40
|
+
def similarity_search(query, limit: 10, filter: nil, mmr: false, diversity_bonus: 0.3)
|
|
34
41
|
raise NotImplementedError
|
|
35
42
|
end
|
|
36
43
|
|
|
@@ -14,55 +14,91 @@ module Ask
|
|
|
14
14
|
# store.add(chunks, model: "text-embedding-3-small")
|
|
15
15
|
# results = store.similarity_search("query", limit: 5)
|
|
16
16
|
#
|
|
17
|
+
# @example With metadata filtering
|
|
18
|
+
# results = store.similarity_search(
|
|
19
|
+
# "query",
|
|
20
|
+
# limit: 5,
|
|
21
|
+
# filter: { source: "api_docs.md" }
|
|
22
|
+
# )
|
|
23
|
+
#
|
|
24
|
+
# @example With MMR (diversified results)
|
|
25
|
+
# results = store.similarity_search(
|
|
26
|
+
# "query",
|
|
27
|
+
# limit: 5,
|
|
28
|
+
# mmr: true,
|
|
29
|
+
# diversity_bonus: 0.5
|
|
30
|
+
# )
|
|
31
|
+
#
|
|
17
32
|
class InMemory < VectorStore
|
|
18
33
|
Entry = Struct.new(:id, :document, :vector, keyword_init: true)
|
|
19
34
|
|
|
20
35
|
def initialize
|
|
21
36
|
@entries = {}
|
|
22
37
|
@mutex = Mutex.new
|
|
38
|
+
@embedding_model = nil
|
|
39
|
+
@embedding_dimensions = nil
|
|
23
40
|
end
|
|
24
41
|
|
|
42
|
+
# Add documents to the store.
|
|
43
|
+
#
|
|
44
|
+
# @param documents [Array<Ask::Document>] documents to add
|
|
45
|
+
# @param model [String] embedding model name
|
|
46
|
+
# @param batch_size [Integer] texts per embed API call
|
|
47
|
+
# @return [Array<String>] IDs of added documents
|
|
25
48
|
def add(documents, model:, batch_size: 20)
|
|
26
49
|
ids = documents.map { |d| d.id || SecureRandom.uuid }
|
|
27
|
-
texts = documents.map(&:content)
|
|
28
|
-
|
|
29
|
-
# Embed using ask-llm-providers
|
|
30
50
|
provider = resolve_embedding_provider(model)
|
|
31
|
-
raw_vectors = provider.embed(texts, model: model)
|
|
32
51
|
|
|
33
|
-
|
|
52
|
+
@embedding_model = model
|
|
34
53
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
54
|
+
documents.each_slice(batch_size).flat_map do |batch|
|
|
55
|
+
texts = batch.map(&:content)
|
|
56
|
+
raw_vectors = provider.embed(texts, model: model)
|
|
57
|
+
vectors = normalize_vectors(raw_vectors, texts)
|
|
58
|
+
|
|
59
|
+
@mutex.synchronize do
|
|
60
|
+
batch.each_with_index.map do |doc, idx|
|
|
61
|
+
entry_id = ids[documents.index(doc)]
|
|
62
|
+
@entries[entry_id] = Entry.new(
|
|
63
|
+
id: entry_id,
|
|
64
|
+
document: doc,
|
|
65
|
+
vector: vectors[idx]
|
|
66
|
+
)
|
|
67
|
+
entry_id
|
|
68
|
+
end
|
|
42
69
|
end
|
|
43
70
|
end
|
|
44
|
-
|
|
45
|
-
ids
|
|
46
71
|
end
|
|
47
72
|
|
|
48
|
-
|
|
49
|
-
|
|
73
|
+
# Search for documents similar to the query.
|
|
74
|
+
#
|
|
75
|
+
# @param query [String] the query text
|
|
76
|
+
# @param limit [Integer] maximum results (default: 10)
|
|
77
|
+
# @param filter [Hash, nil] metadata filter — only entries whose metadata
|
|
78
|
+
# matches all key/value pairs are considered
|
|
79
|
+
# @param mmr [Boolean] apply Max Marginal Relevance for diversity
|
|
80
|
+
# @param diversity_bonus [Float] MMR diversity factor (0 = pure relevance, 1 = pure diversity)
|
|
81
|
+
# @return [Array<Ask::Document>] documents with +:score+ and +:mmr_score+ in metadata
|
|
82
|
+
def similarity_search(query, limit: 10, filter: nil, mmr: false, diversity_bonus: 0.3)
|
|
83
|
+
query_vector = embed_query
|
|
84
|
+
|
|
85
|
+
candidates = @mutex.synchronize do
|
|
86
|
+
@entries.filter_map do |_id, entry|
|
|
87
|
+
next if filter && !matches_filter?(entry.document.metadata, filter)
|
|
50
88
|
|
|
51
|
-
scored = @mutex.synchronize do
|
|
52
|
-
@entries.map do |_id, entry|
|
|
53
89
|
score = cosine_similarity(query_vector, entry.vector)
|
|
54
90
|
[entry, score]
|
|
55
91
|
end
|
|
56
92
|
end
|
|
57
93
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
94
|
+
candidates.sort_by! { |_, score| -score }
|
|
95
|
+
|
|
96
|
+
if mmr
|
|
97
|
+
apply_mmr(candidates, query_vector, limit, diversity_bonus)
|
|
98
|
+
else
|
|
99
|
+
candidates.first(limit).map do |entry, score|
|
|
100
|
+
build_result(entry, score: score)
|
|
101
|
+
end
|
|
66
102
|
end
|
|
67
103
|
end
|
|
68
104
|
|
|
@@ -82,13 +118,14 @@ module Ask
|
|
|
82
118
|
|
|
83
119
|
private
|
|
84
120
|
|
|
121
|
+
# --- Embedding ---
|
|
122
|
+
|
|
85
123
|
def resolve_embedding_provider(model)
|
|
86
124
|
info = Ask::ModelCatalog.find(model)
|
|
87
125
|
provider_class = Ask::Provider.resolve(info.provider)
|
|
88
126
|
config = build_provider_config(provider_class)
|
|
89
127
|
provider_class.new(config)
|
|
90
128
|
rescue Ask::ModelNotFound, Ask::UnknownProvider
|
|
91
|
-
# Fallback: try to use OpenAI-compatible directly
|
|
92
129
|
Ask::Provider.resolve(:openai).new(
|
|
93
130
|
build_provider_config(Ask::Provider.resolve(:openai))
|
|
94
131
|
)
|
|
@@ -107,18 +144,18 @@ module Ask
|
|
|
107
144
|
config
|
|
108
145
|
end
|
|
109
146
|
|
|
110
|
-
def embed_query
|
|
111
|
-
|
|
112
|
-
|
|
147
|
+
def embed_query
|
|
148
|
+
model = @embedding_model || "text-embedding-3-small"
|
|
149
|
+
provider = resolve_embedding_provider(model)
|
|
150
|
+
raw = provider.embed("query", model: model)
|
|
113
151
|
|
|
114
152
|
if raw.is_a?(Ask::Result)
|
|
115
153
|
raw = raw.output
|
|
116
154
|
end
|
|
117
155
|
|
|
118
156
|
Array(raw).flatten.map(&:to_f)
|
|
119
|
-
rescue StandardError
|
|
120
|
-
|
|
121
|
-
[0.0] * 256
|
|
157
|
+
rescue StandardError => e
|
|
158
|
+
raise EmbeddingError, "Failed to embed query with model #{model}: #{e.message}"
|
|
122
159
|
end
|
|
123
160
|
|
|
124
161
|
def normalize_vectors(raw_vectors, texts)
|
|
@@ -130,29 +167,97 @@ module Ask
|
|
|
130
167
|
|
|
131
168
|
result = Array(result)
|
|
132
169
|
|
|
133
|
-
|
|
134
|
-
if result.length == 1 && texts.length == 1
|
|
170
|
+
vectors = if result.length == 1 && texts.length == 1
|
|
135
171
|
[Array(result.first).map(&:to_f)]
|
|
136
172
|
elsif result.first.is_a?(Array)
|
|
137
173
|
result.map { |v| Array(v).map(&:to_f) }
|
|
138
174
|
else
|
|
139
|
-
|
|
140
|
-
dims = infer_dimensions(result)
|
|
175
|
+
dims = @embedding_dimensions || result.length / texts.length
|
|
141
176
|
result.each_slice(dims).to_a
|
|
142
177
|
end
|
|
178
|
+
|
|
179
|
+
@embedding_dimensions ||= vectors.first&.length
|
|
180
|
+
vectors
|
|
143
181
|
end
|
|
144
182
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
183
|
+
# --- Filtering ---
|
|
184
|
+
|
|
185
|
+
def matches_filter?(metadata, filter)
|
|
186
|
+
filter.all? do |key, value|
|
|
187
|
+
metadata.key?(key) && metadata[key] == value
|
|
149
188
|
end
|
|
150
|
-
flat.length
|
|
151
189
|
end
|
|
152
190
|
|
|
191
|
+
# --- Result building ---
|
|
192
|
+
|
|
193
|
+
def build_result(entry, score:, mmr_score: nil)
|
|
194
|
+
meta = entry.document.metadata.merge(score: score)
|
|
195
|
+
meta[:mmr_score] = mmr_score if mmr_score
|
|
196
|
+
Ask::Document.new(
|
|
197
|
+
content: entry.document.content,
|
|
198
|
+
metadata: meta,
|
|
199
|
+
id: entry.document.id
|
|
200
|
+
)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# --- MMR (Max Marginal Relevance) ---
|
|
204
|
+
|
|
205
|
+
def apply_mmr(candidates, query_vector, limit, diversity_bonus)
|
|
206
|
+
selected = []
|
|
207
|
+
remaining = candidates.dup
|
|
208
|
+
|
|
209
|
+
limit = [limit, remaining.size].min
|
|
210
|
+
|
|
211
|
+
# Pick the first result by relevance
|
|
212
|
+
first = remaining.shift
|
|
213
|
+
selected << build_result(first[0], score: first[1], mmr_score: first[1])
|
|
214
|
+
|
|
215
|
+
while selected.size < limit && remaining.any?
|
|
216
|
+
best_idx = 0
|
|
217
|
+
best_mmr = -Float::INFINITY
|
|
218
|
+
|
|
219
|
+
remaining.each_with_index do |(entry, rel_score), idx|
|
|
220
|
+
# Find max similarity to any already-selected document
|
|
221
|
+
max_sim_to_selected = selected.map do |sel_doc|
|
|
222
|
+
sel_entry = @entries.values.find { |e| e.document.content == sel_doc.content }
|
|
223
|
+
sel_entry ? cosine_similarity(entry.vector, sel_entry.vector) : 0.0
|
|
224
|
+
end.max
|
|
225
|
+
|
|
226
|
+
mmr_score = rel_score - diversity_bonus * max_sim_to_selected
|
|
227
|
+
|
|
228
|
+
if mmr_score > best_mmr
|
|
229
|
+
best_mmr = mmr_score
|
|
230
|
+
best_idx = idx
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
entry, rel_score = remaining.delete_at(best_idx)
|
|
235
|
+
selected << build_result(entry, score: rel_score, mmr_score: best_mmr)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
selected
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# --- Cosine Similarity ---
|
|
242
|
+
|
|
153
243
|
def cosine_similarity(a, b)
|
|
154
244
|
return 0.0 if a.empty? || b.empty? || a.length != b.length
|
|
155
245
|
|
|
246
|
+
# Use Matrix for vectorized computation if available
|
|
247
|
+
if defined?(Matrix)
|
|
248
|
+
begin
|
|
249
|
+
va = Matrix.row_vector(a)
|
|
250
|
+
vb = Matrix.row_vector(b)
|
|
251
|
+
dot = (va * vb.transpose)[0, 0]
|
|
252
|
+
norm_a = Math.sqrt((va * va.transpose)[0, 0])
|
|
253
|
+
norm_b = Math.sqrt((vb * vb.transpose)[0, 0])
|
|
254
|
+
denom = norm_a * norm_b
|
|
255
|
+
return denom > 0 ? dot / denom : 0.0
|
|
256
|
+
rescue StandardError
|
|
257
|
+
# Fall through to pure Ruby
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
156
261
|
dot = 0.0
|
|
157
262
|
norm_a = 0.0
|
|
158
263
|
norm_b = 0.0
|
|
@@ -61,14 +61,22 @@ module Ask
|
|
|
61
61
|
ids
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
-
def similarity_search(query, limit: 10)
|
|
64
|
+
def similarity_search(query, limit: 10, filter: nil, mmr: false, diversity_bonus: 0.3)
|
|
65
65
|
query_vector = embed_query(query)
|
|
66
66
|
column = "#{@table_name}.#{@embedding_column}"
|
|
67
67
|
model_class = @model_class
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
scope = model_class
|
|
70
70
|
.select("#{@table_name}.*, 1 - (#{column} <=> '#{query_vector.to_s}') AS score")
|
|
71
71
|
.where("#{column} IS NOT NULL")
|
|
72
|
+
|
|
73
|
+
if filter
|
|
74
|
+
filter.each do |key, value|
|
|
75
|
+
scope = scope.where("#{@metadata_column} @> ?", { key.to_s => value }.to_json)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
records = scope
|
|
72
80
|
.order(Arel.sql("#{column} <=> '#{query_vector.to_s}'"))
|
|
73
81
|
.limit(limit)
|
|
74
82
|
|
data/lib/ask/rag/version.rb
CHANGED
data/lib/ask/rag.rb
CHANGED
|
@@ -3,11 +3,18 @@
|
|
|
3
3
|
require_relative "rag/version"
|
|
4
4
|
require_relative "rag/document"
|
|
5
5
|
|
|
6
|
+
# Optional: Matrix for faster cosine similarity
|
|
7
|
+
begin
|
|
8
|
+
require "matrix"
|
|
9
|
+
rescue LoadError # rubocop:disable Lint/SuppressedException
|
|
10
|
+
end
|
|
11
|
+
|
|
6
12
|
# Loaders
|
|
7
13
|
require_relative "rag/loader/base"
|
|
8
14
|
require_relative "rag/loader/text"
|
|
9
15
|
require_relative "rag/loader/markdown"
|
|
10
16
|
require_relative "rag/loader/csv"
|
|
17
|
+
require_relative "rag/loader/directory"
|
|
11
18
|
|
|
12
19
|
# Optional loaders (loaded only when their dependency gem is available)
|
|
13
20
|
begin
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ask-rag
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -109,6 +109,7 @@ files:
|
|
|
109
109
|
- lib/ask/rag/document.rb
|
|
110
110
|
- lib/ask/rag/loader/base.rb
|
|
111
111
|
- lib/ask/rag/loader/csv.rb
|
|
112
|
+
- lib/ask/rag/loader/directory.rb
|
|
112
113
|
- lib/ask/rag/loader/html.rb
|
|
113
114
|
- lib/ask/rag/loader/markdown.rb
|
|
114
115
|
- lib/ask/rag/loader/pdf.rb
|