ask-rag 0.1.0 → 0.2.1
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 +75 -0
- data/README.md +26 -0
- data/lib/ask/rag/loader/directory.rb +107 -0
- data/lib/ask/rag/loader/json.rb +63 -0
- data/lib/ask/rag/vector_store/base.rb +20 -2
- data/lib/ask/rag/vector_store/in_memory.rb +159 -42
- data/lib/ask/rag/vector_store/pgvector.rb +33 -16
- data/lib/ask/rag/version.rb +1 -1
- data/lib/ask/rag.rb +8 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b79936f65b8b4b8f7cbfc1667703b645df6f411e84e7ee543071e4fabe9e6015
|
|
4
|
+
data.tar.gz: 474bac5cbc7f1d3f26b3ec0115591947577a3957aff4733a815f3f96b6f63dd6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3b7042d0c1f27aee6ceb4a21122f3c84691afb3b635a8b2f25f94a8c8cac3d633e09507f5a5b11347c241885016bdca1261be9322a0823034c4d97e11d8bce9e
|
|
7
|
+
data.tar.gz: 48b984042f7138262c13cd1458fa9191ed914909b35690b524cf3917e8837009644d71078908c47e1d1332b8895c4cee3ec5e453315442ef13091ff77420c6da
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,78 @@
|
|
|
1
|
+
## [0.2.1] — 2026-07-26
|
|
2
|
+
|
|
3
|
+
### Fixed
|
|
4
|
+
|
|
5
|
+
- **PGVector model tracking** — same fix as InMemory: now tracks the embedding model used during `add` and reuses it for query embedding. Also raises `EmbeddingError` instead of silently returning a zero vector on failure.
|
|
6
|
+
|
|
7
|
+
- **PGVector document mutability** — no longer calls `define_singleton_method(:score)` on frozen `Ask::Document`. Score is now returned in `metadata[:score]` consistently with InMemory.
|
|
8
|
+
|
|
9
|
+
- **PGVector embed_query** — now accepts the query text as an argument (was hardcoded to embed the literal string "query").
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **`similarity_search_by_vector`** — search using a raw vector instead of text. Available on both InMemory and PGVector stores. Accepts `limit:` and `filter:` params.
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
store.similarity_search_by_vector([0.1, 0.2, ...], limit: 5)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
- **`Ask::RAG::Loader::JSON`** — loads JSON files. Supports arrays of objects (one document per object) and single objects. Configurable `content_key` and `metadata_keys`.
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
loader = Ask::RAG::Loader::JSON.new(content_key: "body")
|
|
23
|
+
docs = loader.load("articles.json")
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
- **Directory loader** now also recognizes `.json` files.
|
|
27
|
+
|
|
28
|
+
### Tested
|
|
29
|
+
|
|
30
|
+
- 65 tests, 163 assertions, 0 failures
|
|
31
|
+
|
|
32
|
+
## [0.2.0] — 2026-07-26
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- **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.
|
|
37
|
+
|
|
38
|
+
- **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.
|
|
39
|
+
|
|
40
|
+
### Added
|
|
41
|
+
|
|
42
|
+
- **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.
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
store.similarity_search("query", filter: { section: "api" })
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- **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.
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
store.similarity_search("query", limit: 5, mmr: true, diversity_bonus: 0.3)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- **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.
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
loader = Ask::RAG::Loader::Directory.new
|
|
58
|
+
docs = loader.load("docs/")
|
|
59
|
+
|
|
60
|
+
loader = Ask::RAG::Loader::Directory.new(pattern: "**/*.md")
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
- **Faster cosine similarity** — Uses Ruby's `matrix` gem for vectorized computation when available, with a pure Ruby fallback.
|
|
64
|
+
|
|
65
|
+
- **`Ask::RAG::EmbeddingError`** — raised when embedding fails during query, with a clear error message.
|
|
66
|
+
|
|
67
|
+
### Changed
|
|
68
|
+
|
|
69
|
+
- `VectorStore#similarity_search` signature updated to accept `filter:`, `mmr:`, and `diversity_bonus:` keyword arguments.
|
|
70
|
+
|
|
71
|
+
### Tested
|
|
72
|
+
|
|
73
|
+
- 60 tests, 148 assertions, 0 failures
|
|
74
|
+
- New tests for: metadata filtering, MMR scoring, directory loader, batch embedding, embedding model tracking, EmbeddingError
|
|
75
|
+
|
|
1
76
|
## [0.1.0] — 2026-07-26
|
|
2
77
|
|
|
3
78
|
### 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,107 @@
|
|
|
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
|
+
".json" => :json,
|
|
30
|
+
".pdf" => :pdf
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
# Directories to skip when walking (matches basename).
|
|
34
|
+
SKIP_DIRS = %w[
|
|
35
|
+
.git .svn .hg .bzr _darcs node_modules .bundle vendor
|
|
36
|
+
.yardoc coverage tmp log public/assets
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
# @param pattern [String, nil] optional glob pattern (e.g., "**/*.md")
|
|
40
|
+
# When nil, walks all files with known extensions.
|
|
41
|
+
# @param skip_dirs [Array<String>] directory names to skip
|
|
42
|
+
def initialize(pattern: nil, skip_dirs: SKIP_DIRS)
|
|
43
|
+
@pattern = pattern
|
|
44
|
+
@skip_dirs = skip_dirs.map(&:freeze).freeze
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Load all supported files from a directory tree.
|
|
48
|
+
#
|
|
49
|
+
# @param path [String] root directory path
|
|
50
|
+
# @return [Array<Ask::Document>] all loaded documents
|
|
51
|
+
def load(path)
|
|
52
|
+
docs = []
|
|
53
|
+
files(path).each do |file|
|
|
54
|
+
loader = loader_for(file)
|
|
55
|
+
next unless loader
|
|
56
|
+
|
|
57
|
+
docs.concat(loader.load(file))
|
|
58
|
+
rescue StandardError => e
|
|
59
|
+
warn "[ask-rag] Skipping #{file}: #{e.class}: #{e.message}"
|
|
60
|
+
end
|
|
61
|
+
docs
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def files(root)
|
|
67
|
+
if @pattern
|
|
68
|
+
Dir.glob(File.join(root, @pattern)).select { |f| File.file?(f) }
|
|
69
|
+
else
|
|
70
|
+
all_files(root)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def all_files(root)
|
|
75
|
+
result = []
|
|
76
|
+
Dir.entries(root).each do |entry|
|
|
77
|
+
next if entry.start_with?(".")
|
|
78
|
+
next if @skip_dirs.include?(entry)
|
|
79
|
+
|
|
80
|
+
full = File.join(root, entry)
|
|
81
|
+
if File.directory?(full)
|
|
82
|
+
result.concat(all_files(full))
|
|
83
|
+
elsif File.file?(full)
|
|
84
|
+
result << full
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
result
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def loader_for(file)
|
|
91
|
+
ext = File.extname(file).downcase
|
|
92
|
+
type = EXTENSION_MAP[ext]
|
|
93
|
+
return nil unless type
|
|
94
|
+
|
|
95
|
+
case type
|
|
96
|
+
when :text then Loader::Text.new
|
|
97
|
+
when :markdown then Loader::Markdown.new
|
|
98
|
+
when :html then defined?(Loader::HTML) ? Loader::HTML.new : nil
|
|
99
|
+
when :csv then Loader::CSV.new
|
|
100
|
+
when :json then Loader::JSON.new
|
|
101
|
+
when :pdf then defined?(Loader::PDF) ? Loader::PDF.new : nil
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module RAG
|
|
5
|
+
module Loader
|
|
6
|
+
# Loads a JSON file as documents.
|
|
7
|
+
#
|
|
8
|
+
# Supports two modes:
|
|
9
|
+
# 1. Array of objects — each object becomes one document.
|
|
10
|
+
# Specify +content_key+ and +metadata_keys+ to map fields.
|
|
11
|
+
# 2. Single object — becomes one document.
|
|
12
|
+
# Specify +content_key+ for which field holds the text.
|
|
13
|
+
#
|
|
14
|
+
# @example Array of objects
|
|
15
|
+
# # [{ "title": "...", "body": "...", "tags": [...] }, ...]
|
|
16
|
+
# loader = Ask::RAG::Loader::JSON.new(content_key: "body")
|
|
17
|
+
# docs = loader.load("articles.json")
|
|
18
|
+
#
|
|
19
|
+
# @example With metadata mapping
|
|
20
|
+
# loader = Ask::RAG::Loader::JSON.new(
|
|
21
|
+
# content_key: "content",
|
|
22
|
+
# metadata_keys: ["source", "date"]
|
|
23
|
+
# )
|
|
24
|
+
#
|
|
25
|
+
class JSON < Base
|
|
26
|
+
# @param content_key [String] key whose value becomes document content
|
|
27
|
+
# @param metadata_keys [Array<String>, nil] keys to include in metadata
|
|
28
|
+
# (nil = all keys except content_key)
|
|
29
|
+
def initialize(content_key: "content", metadata_keys: nil)
|
|
30
|
+
@content_key = content_key
|
|
31
|
+
@metadata_keys = metadata_keys
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def load(path)
|
|
35
|
+
raw = ::JSON.parse(File.read(path))
|
|
36
|
+
items = raw.is_a?(Array) ? raw : [raw]
|
|
37
|
+
|
|
38
|
+
items.map { |item| build_document(item, path) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def build_document(item, path)
|
|
44
|
+
content = item[@content_key].to_s
|
|
45
|
+
metadata = { source: path }
|
|
46
|
+
|
|
47
|
+
if @metadata_keys
|
|
48
|
+
@metadata_keys.each do |key|
|
|
49
|
+
metadata[key] = item[key] if item.key?(key)
|
|
50
|
+
end
|
|
51
|
+
else
|
|
52
|
+
item.each do |key, value|
|
|
53
|
+
next if key == @content_key
|
|
54
|
+
metadata[key] = value if value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
Ask::Document.new(content: content, metadata: metadata)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
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,23 @@ 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)
|
|
41
|
+
raise NotImplementedError
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Search by a raw vector instead of a text query.
|
|
45
|
+
# Useful when you've already embedded the query externally.
|
|
46
|
+
#
|
|
47
|
+
# @param vector [Array<Float>] the query vector
|
|
48
|
+
# @param limit [Integer] maximum number of results
|
|
49
|
+
# @param filter [Hash, nil] metadata filter
|
|
50
|
+
# @return [Array<Ask::Document>] documents with +:score+ in metadata
|
|
51
|
+
def similarity_search_by_vector(vector, limit: 10, filter: nil)
|
|
34
52
|
raise NotImplementedError
|
|
35
53
|
end
|
|
36
54
|
|
|
@@ -14,58 +14,106 @@ 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
|
|
71
|
+
end
|
|
72
|
+
|
|
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
|
+
search_by_vector(query_vector, limit: limit, filter: filter,
|
|
85
|
+
mmr: mmr, diversity_bonus: diversity_bonus)
|
|
86
|
+
end
|
|
44
87
|
|
|
45
|
-
|
|
88
|
+
def similarity_search_by_vector(vector, limit: 10, filter: nil)
|
|
89
|
+
search_by_vector(vector, limit: limit, filter: filter)
|
|
46
90
|
end
|
|
47
91
|
|
|
48
|
-
|
|
49
|
-
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def search_by_vector(vector, limit:, filter: nil, mmr: false, diversity_bonus: 0.3)
|
|
95
|
+
candidates = @mutex.synchronize do
|
|
96
|
+
@entries.filter_map do |_id, entry|
|
|
97
|
+
next if filter && !matches_filter?(entry.document.metadata, filter)
|
|
50
98
|
|
|
51
|
-
|
|
52
|
-
@entries.map do |_id, entry|
|
|
53
|
-
score = cosine_similarity(query_vector, entry.vector)
|
|
99
|
+
score = cosine_similarity(vector, entry.vector)
|
|
54
100
|
[entry, score]
|
|
55
101
|
end
|
|
56
102
|
end
|
|
57
103
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
104
|
+
candidates.sort_by! { |_, score| -score }
|
|
105
|
+
|
|
106
|
+
if mmr
|
|
107
|
+
apply_mmr(candidates, vector, limit, diversity_bonus)
|
|
108
|
+
else
|
|
109
|
+
candidates.first(limit).map do |entry, score|
|
|
110
|
+
build_result(entry, score: score)
|
|
111
|
+
end
|
|
66
112
|
end
|
|
67
113
|
end
|
|
68
114
|
|
|
115
|
+
public
|
|
116
|
+
|
|
69
117
|
def delete(ids)
|
|
70
118
|
@mutex.synchronize do
|
|
71
119
|
ids.each { |id| @entries.delete(id) }
|
|
@@ -82,13 +130,14 @@ module Ask
|
|
|
82
130
|
|
|
83
131
|
private
|
|
84
132
|
|
|
133
|
+
# --- Embedding ---
|
|
134
|
+
|
|
85
135
|
def resolve_embedding_provider(model)
|
|
86
136
|
info = Ask::ModelCatalog.find(model)
|
|
87
137
|
provider_class = Ask::Provider.resolve(info.provider)
|
|
88
138
|
config = build_provider_config(provider_class)
|
|
89
139
|
provider_class.new(config)
|
|
90
140
|
rescue Ask::ModelNotFound, Ask::UnknownProvider
|
|
91
|
-
# Fallback: try to use OpenAI-compatible directly
|
|
92
141
|
Ask::Provider.resolve(:openai).new(
|
|
93
142
|
build_provider_config(Ask::Provider.resolve(:openai))
|
|
94
143
|
)
|
|
@@ -107,18 +156,18 @@ module Ask
|
|
|
107
156
|
config
|
|
108
157
|
end
|
|
109
158
|
|
|
110
|
-
def embed_query
|
|
111
|
-
|
|
112
|
-
|
|
159
|
+
def embed_query
|
|
160
|
+
model = @embedding_model || "text-embedding-3-small"
|
|
161
|
+
provider = resolve_embedding_provider(model)
|
|
162
|
+
raw = provider.embed("query", model: model)
|
|
113
163
|
|
|
114
164
|
if raw.is_a?(Ask::Result)
|
|
115
165
|
raw = raw.output
|
|
116
166
|
end
|
|
117
167
|
|
|
118
168
|
Array(raw).flatten.map(&:to_f)
|
|
119
|
-
rescue StandardError
|
|
120
|
-
|
|
121
|
-
[0.0] * 256
|
|
169
|
+
rescue StandardError => e
|
|
170
|
+
raise EmbeddingError, "Failed to embed query with model #{model}: #{e.message}"
|
|
122
171
|
end
|
|
123
172
|
|
|
124
173
|
def normalize_vectors(raw_vectors, texts)
|
|
@@ -130,29 +179,97 @@ module Ask
|
|
|
130
179
|
|
|
131
180
|
result = Array(result)
|
|
132
181
|
|
|
133
|
-
|
|
134
|
-
if result.length == 1 && texts.length == 1
|
|
182
|
+
vectors = if result.length == 1 && texts.length == 1
|
|
135
183
|
[Array(result.first).map(&:to_f)]
|
|
136
184
|
elsif result.first.is_a?(Array)
|
|
137
185
|
result.map { |v| Array(v).map(&:to_f) }
|
|
138
186
|
else
|
|
139
|
-
|
|
140
|
-
dims = infer_dimensions(result)
|
|
187
|
+
dims = @embedding_dimensions || result.length / texts.length
|
|
141
188
|
result.each_slice(dims).to_a
|
|
142
189
|
end
|
|
190
|
+
|
|
191
|
+
@embedding_dimensions ||= vectors.first&.length
|
|
192
|
+
vectors
|
|
143
193
|
end
|
|
144
194
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
195
|
+
# --- Filtering ---
|
|
196
|
+
|
|
197
|
+
def matches_filter?(metadata, filter)
|
|
198
|
+
filter.all? do |key, value|
|
|
199
|
+
metadata.key?(key) && metadata[key] == value
|
|
149
200
|
end
|
|
150
|
-
flat.length
|
|
151
201
|
end
|
|
152
202
|
|
|
203
|
+
# --- Result building ---
|
|
204
|
+
|
|
205
|
+
def build_result(entry, score:, mmr_score: nil)
|
|
206
|
+
meta = entry.document.metadata.merge(score: score)
|
|
207
|
+
meta[:mmr_score] = mmr_score if mmr_score
|
|
208
|
+
Ask::Document.new(
|
|
209
|
+
content: entry.document.content,
|
|
210
|
+
metadata: meta,
|
|
211
|
+
id: entry.document.id
|
|
212
|
+
)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# --- MMR (Max Marginal Relevance) ---
|
|
216
|
+
|
|
217
|
+
def apply_mmr(candidates, query_vector, limit, diversity_bonus)
|
|
218
|
+
selected = []
|
|
219
|
+
remaining = candidates.dup
|
|
220
|
+
|
|
221
|
+
limit = [limit, remaining.size].min
|
|
222
|
+
|
|
223
|
+
# Pick the first result by relevance
|
|
224
|
+
first = remaining.shift
|
|
225
|
+
selected << build_result(first[0], score: first[1], mmr_score: first[1])
|
|
226
|
+
|
|
227
|
+
while selected.size < limit && remaining.any?
|
|
228
|
+
best_idx = 0
|
|
229
|
+
best_mmr = -Float::INFINITY
|
|
230
|
+
|
|
231
|
+
remaining.each_with_index do |(entry, rel_score), idx|
|
|
232
|
+
# Find max similarity to any already-selected document
|
|
233
|
+
max_sim_to_selected = selected.map do |sel_doc|
|
|
234
|
+
sel_entry = @entries.values.find { |e| e.document.content == sel_doc.content }
|
|
235
|
+
sel_entry ? cosine_similarity(entry.vector, sel_entry.vector) : 0.0
|
|
236
|
+
end.max
|
|
237
|
+
|
|
238
|
+
mmr_score = rel_score - diversity_bonus * max_sim_to_selected
|
|
239
|
+
|
|
240
|
+
if mmr_score > best_mmr
|
|
241
|
+
best_mmr = mmr_score
|
|
242
|
+
best_idx = idx
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
entry, rel_score = remaining.delete_at(best_idx)
|
|
247
|
+
selected << build_result(entry, score: rel_score, mmr_score: best_mmr)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
selected
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# --- Cosine Similarity ---
|
|
254
|
+
|
|
153
255
|
def cosine_similarity(a, b)
|
|
154
256
|
return 0.0 if a.empty? || b.empty? || a.length != b.length
|
|
155
257
|
|
|
258
|
+
# Use Matrix for vectorized computation if available
|
|
259
|
+
if defined?(Matrix)
|
|
260
|
+
begin
|
|
261
|
+
va = Matrix.row_vector(a)
|
|
262
|
+
vb = Matrix.row_vector(b)
|
|
263
|
+
dot = (va * vb.transpose)[0, 0]
|
|
264
|
+
norm_a = Math.sqrt((va * va.transpose)[0, 0])
|
|
265
|
+
norm_b = Math.sqrt((vb * vb.transpose)[0, 0])
|
|
266
|
+
denom = norm_a * norm_b
|
|
267
|
+
return denom > 0 ? dot / denom : 0.0
|
|
268
|
+
rescue StandardError
|
|
269
|
+
# Fall through to pure Ruby
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
156
273
|
dot = 0.0
|
|
157
274
|
norm_a = 0.0
|
|
158
275
|
norm_b = 0.0
|
|
@@ -19,7 +19,7 @@ module Ask
|
|
|
19
19
|
#
|
|
20
20
|
# @example Usage
|
|
21
21
|
# store = Ask::RAG::VectorStore::PGVector.new(
|
|
22
|
-
# table_name: :
|
|
22
|
+
# table_name: :embeddings,
|
|
23
23
|
# embedding_column: :embedding
|
|
24
24
|
# )
|
|
25
25
|
# store.add(chunks, model: "text-embedding-3-small")
|
|
@@ -38,18 +38,19 @@ module Ask
|
|
|
38
38
|
@metadata_column = metadata_column.to_s
|
|
39
39
|
@embedding_column = embedding_column.to_s
|
|
40
40
|
@model_class = model_class || infer_model_class
|
|
41
|
+
@embedding_model = nil
|
|
41
42
|
end
|
|
42
43
|
|
|
43
44
|
def add(documents, model:, batch_size: 20)
|
|
44
45
|
ids = []
|
|
45
|
-
|
|
46
|
+
@embedding_model = model
|
|
46
47
|
|
|
47
48
|
documents.each_slice(batch_size) do |batch|
|
|
48
49
|
texts = batch.map(&:content)
|
|
49
50
|
vectors = embed_texts(texts, model)
|
|
50
51
|
|
|
51
52
|
batch.each_with_index do |doc, idx|
|
|
52
|
-
record = model_class.create!(
|
|
53
|
+
record = @model_class.create!(
|
|
53
54
|
@content_column => doc.content,
|
|
54
55
|
@metadata_column => doc.metadata,
|
|
55
56
|
@embedding_column => vectors[idx]
|
|
@@ -61,24 +62,39 @@ module Ask
|
|
|
61
62
|
ids
|
|
62
63
|
end
|
|
63
64
|
|
|
64
|
-
def similarity_search(query, limit: 10)
|
|
65
|
+
def similarity_search(query, limit: 10, filter: nil, mmr: false, diversity_bonus: 0.3)
|
|
65
66
|
query_vector = embed_query(query)
|
|
67
|
+
similarity_search_by_vector(query_vector, limit: limit, filter: filter)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def similarity_search_by_vector(vector, limit: 10, filter: nil)
|
|
71
|
+
vector_str = vector.is_a?(Array) ? "[#{vector.join(',')}]" : vector.to_s
|
|
66
72
|
column = "#{@table_name}.#{@embedding_column}"
|
|
67
73
|
model_class = @model_class
|
|
68
74
|
|
|
69
|
-
|
|
70
|
-
.select("#{@table_name}.*, 1 - (#{column} <=> '#{
|
|
75
|
+
scope = model_class
|
|
76
|
+
.select("#{@table_name}.*, 1 - (#{column} <=> '#{vector_str}') AS score")
|
|
71
77
|
.where("#{column} IS NOT NULL")
|
|
72
|
-
|
|
78
|
+
|
|
79
|
+
if filter
|
|
80
|
+
filter.each do |key, value|
|
|
81
|
+
scope = scope.where("#{@metadata_column} @> ?", { key.to_s => value }.to_json)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
records = scope
|
|
86
|
+
.order(Arel.sql("#{column} <=> '#{vector_str}'"))
|
|
73
87
|
.limit(limit)
|
|
74
88
|
|
|
75
89
|
records.map do |record|
|
|
76
|
-
|
|
90
|
+
Ask::Document.new(
|
|
77
91
|
content: record.send(@content_column),
|
|
78
|
-
metadata: (record.send(@metadata_column) || {}).merge(
|
|
92
|
+
metadata: (record.send(@metadata_column) || {}).merge(
|
|
93
|
+
score: record.score,
|
|
94
|
+
db_id: record.id
|
|
95
|
+
),
|
|
96
|
+
id: record.id.to_s
|
|
79
97
|
)
|
|
80
|
-
doc.define_singleton_method(:score) { record.score }
|
|
81
|
-
doc
|
|
82
98
|
end
|
|
83
99
|
end
|
|
84
100
|
|
|
@@ -109,13 +125,14 @@ module Ask
|
|
|
109
125
|
Array(raw).map { |v| Array(v).map(&:to_f) }
|
|
110
126
|
end
|
|
111
127
|
|
|
112
|
-
def embed_query(query)
|
|
113
|
-
|
|
114
|
-
|
|
128
|
+
def embed_query(query = "query")
|
|
129
|
+
model = @embedding_model || "text-embedding-3-small"
|
|
130
|
+
provider = resolve_provider(model)
|
|
131
|
+
raw = provider.embed(query, model: model)
|
|
115
132
|
raw = raw.output if raw.is_a?(Ask::Result)
|
|
116
133
|
Array(raw).flatten.map(&:to_f)
|
|
117
|
-
rescue StandardError
|
|
118
|
-
|
|
134
|
+
rescue StandardError => e
|
|
135
|
+
raise EmbeddingError, "Failed to embed query with model #{model}: #{e.message}"
|
|
119
136
|
end
|
|
120
137
|
|
|
121
138
|
def resolve_provider(model)
|
data/lib/ask/rag/version.rb
CHANGED
data/lib/ask/rag.rb
CHANGED
|
@@ -3,11 +3,19 @@
|
|
|
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/json"
|
|
18
|
+
require_relative "rag/loader/directory"
|
|
11
19
|
|
|
12
20
|
# Optional loaders (loaded only when their dependency gem is available)
|
|
13
21
|
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.1
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -109,7 +109,9 @@ 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
|
|
114
|
+
- lib/ask/rag/loader/json.rb
|
|
113
115
|
- lib/ask/rag/loader/markdown.rb
|
|
114
116
|
- lib/ask/rag/loader/pdf.rb
|
|
115
117
|
- lib/ask/rag/loader/text.rb
|