ragnar-cli 0.1.0.pre.3 → 0.1.0.pre.5
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/README.md +249 -41
- data/lib/ragnar/cli.rb +563 -219
- data/lib/ragnar/cli_umap.rb +86 -0
- data/lib/ragnar/cli_visualization.rb +184 -0
- data/lib/ragnar/config.rb +320 -0
- data/lib/ragnar/database.rb +94 -8
- data/lib/ragnar/embedder.rb +1 -1
- data/lib/ragnar/indexer.rb +4 -2
- data/lib/ragnar/llm_manager.rb +31 -27
- data/lib/ragnar/query_processor.rb +123 -70
- data/lib/ragnar/query_rewriter.rb +21 -18
- data/lib/ragnar/topic_modeling.rb +13 -10
- data/lib/ragnar/umap_processor.rb +131 -95
- data/lib/ragnar/umap_transform_service.rb +169 -88
- data/lib/ragnar/version.rb +1 -1
- data/lib/ragnar.rb +3 -1
- metadata +71 -30
- data/lib/ragnar/topic_modeling/engine.rb +0 -301
- data/lib/ragnar/topic_modeling/labeling_strategies.rb +0 -300
- data/lib/ragnar/topic_modeling/llm_adapter.rb +0 -131
- data/lib/ragnar/topic_modeling/metrics.rb +0 -186
- data/lib/ragnar/topic_modeling/term_extractor.rb +0 -170
- data/lib/ragnar/topic_modeling/topic.rb +0 -117
- data/lib/ragnar/topic_modeling/topic_labeler.rb +0 -61
data/lib/ragnar/database.rb
CHANGED
|
@@ -5,6 +5,7 @@ module Ragnar
|
|
|
5
5
|
def initialize(db_path, table_name: "documents")
|
|
6
6
|
@db_path = db_path
|
|
7
7
|
@table_name = table_name
|
|
8
|
+
@dataset_cache = nil # Cache to prevent file descriptor leaks
|
|
8
9
|
ensure_database_exists
|
|
9
10
|
end
|
|
10
11
|
|
|
@@ -34,16 +35,23 @@ module Ragnar
|
|
|
34
35
|
metadata: :string
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
# Clear cache before modifying dataset
|
|
39
|
+
clear_dataset_cache
|
|
40
|
+
|
|
37
41
|
# Use the new open_or_create method from Lancelot
|
|
38
42
|
# This automatically handles both creating new and opening existing datasets
|
|
39
43
|
dataset = Lancelot::Dataset.open_or_create(@db_path, schema: schema)
|
|
40
44
|
dataset.add_documents(data)
|
|
45
|
+
|
|
46
|
+
# Clear cache after modification to ensure fresh data on next read
|
|
47
|
+
clear_dataset_cache
|
|
41
48
|
end
|
|
42
49
|
|
|
43
50
|
def get_embeddings(limit: nil, offset: 0)
|
|
44
51
|
return [] unless dataset_exists?
|
|
45
52
|
|
|
46
|
-
dataset =
|
|
53
|
+
dataset = cached_dataset
|
|
54
|
+
return [] unless dataset
|
|
47
55
|
|
|
48
56
|
# Get all documents or a subset
|
|
49
57
|
docs = if limit && offset > 0
|
|
@@ -67,7 +75,8 @@ module Ragnar
|
|
|
67
75
|
def update_reduced_embeddings(updates)
|
|
68
76
|
return if updates.empty?
|
|
69
77
|
|
|
70
|
-
dataset =
|
|
78
|
+
dataset = cached_dataset
|
|
79
|
+
return unless dataset
|
|
71
80
|
|
|
72
81
|
# Get all existing documents and safely extract their data
|
|
73
82
|
all_docs = dataset.to_a.map do |doc|
|
|
@@ -113,17 +122,24 @@ module Ragnar
|
|
|
113
122
|
metadata: :string
|
|
114
123
|
}
|
|
115
124
|
|
|
125
|
+
# Clear cache before recreating dataset
|
|
126
|
+
clear_dataset_cache
|
|
127
|
+
|
|
116
128
|
# Remove old dataset and create new one with updated data
|
|
117
129
|
FileUtils.rm_rf(@db_path)
|
|
118
130
|
# Use open_or_create which will create since we just deleted the path
|
|
119
131
|
dataset = Lancelot::Dataset.open_or_create(@db_path, schema: schema)
|
|
120
132
|
dataset.add_documents(updated_docs)
|
|
133
|
+
|
|
134
|
+
# Clear cache after modification
|
|
135
|
+
clear_dataset_cache
|
|
121
136
|
end
|
|
122
137
|
|
|
123
138
|
def search_similar(embedding, k: 10, use_reduced: false)
|
|
124
139
|
return [] unless dataset_exists?
|
|
125
140
|
|
|
126
|
-
dataset =
|
|
141
|
+
dataset = cached_dataset
|
|
142
|
+
return [] unless dataset
|
|
127
143
|
|
|
128
144
|
embedding_field = use_reduced ? :reduced_embedding : :embedding
|
|
129
145
|
|
|
@@ -149,7 +165,9 @@ module Ragnar
|
|
|
149
165
|
def count
|
|
150
166
|
return 0 unless dataset_exists?
|
|
151
167
|
|
|
152
|
-
dataset =
|
|
168
|
+
dataset = cached_dataset
|
|
169
|
+
return 0 unless dataset
|
|
170
|
+
|
|
153
171
|
dataset.to_a.size
|
|
154
172
|
end
|
|
155
173
|
|
|
@@ -166,7 +184,18 @@ module Ragnar
|
|
|
166
184
|
}
|
|
167
185
|
end
|
|
168
186
|
|
|
169
|
-
dataset =
|
|
187
|
+
dataset = cached_dataset
|
|
188
|
+
unless dataset
|
|
189
|
+
return {
|
|
190
|
+
document_count: 0,
|
|
191
|
+
total_documents: 0,
|
|
192
|
+
unique_files: 0,
|
|
193
|
+
total_chunks: 0,
|
|
194
|
+
with_embeddings: 0,
|
|
195
|
+
with_reduced_embeddings: 0,
|
|
196
|
+
total_size_mb: 0.0
|
|
197
|
+
}
|
|
198
|
+
end
|
|
170
199
|
|
|
171
200
|
# Get all documents
|
|
172
201
|
all_docs = dataset.to_a
|
|
@@ -214,7 +243,9 @@ module Ragnar
|
|
|
214
243
|
def get_all_documents_with_embeddings(limit: nil)
|
|
215
244
|
return [] unless dataset_exists?
|
|
216
245
|
|
|
217
|
-
dataset =
|
|
246
|
+
dataset = cached_dataset
|
|
247
|
+
return [] unless dataset
|
|
248
|
+
|
|
218
249
|
all_docs = limit ? dataset.first(limit) : dataset.to_a
|
|
219
250
|
|
|
220
251
|
all_docs.select { |doc| doc[:embedding] && !doc[:embedding].empty? }
|
|
@@ -223,7 +254,8 @@ module Ragnar
|
|
|
223
254
|
def full_text_search(query, limit: 10)
|
|
224
255
|
return [] unless dataset_exists?
|
|
225
256
|
|
|
226
|
-
dataset =
|
|
257
|
+
dataset = cached_dataset
|
|
258
|
+
return [] unless dataset
|
|
227
259
|
|
|
228
260
|
# Use Lancelot's full-text search
|
|
229
261
|
results = dataset.full_text_search(
|
|
@@ -243,11 +275,49 @@ module Ragnar
|
|
|
243
275
|
end
|
|
244
276
|
end
|
|
245
277
|
|
|
278
|
+
# Get the total number of documents in the database
|
|
279
|
+
def document_count
|
|
280
|
+
count
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Get documents by their IDs
|
|
284
|
+
# @param ids [Array<String>] Document IDs to fetch
|
|
285
|
+
# @return [Array<Hash>] Documents with their embeddings
|
|
286
|
+
def get_documents_by_ids(ids)
|
|
287
|
+
return [] if ids.empty? || !dataset_exists?
|
|
288
|
+
|
|
289
|
+
dataset = cached_dataset
|
|
290
|
+
return [] unless dataset
|
|
291
|
+
|
|
292
|
+
# Create ID lookup set for efficiency
|
|
293
|
+
id_set = ids.to_set
|
|
294
|
+
|
|
295
|
+
# Filter documents by IDs
|
|
296
|
+
dataset.to_a.select { |doc| id_set.include?(doc[:id]) }.map do |doc|
|
|
297
|
+
{
|
|
298
|
+
id: doc[:id],
|
|
299
|
+
chunk_text: doc[:chunk_text],
|
|
300
|
+
file_path: doc[:file_path],
|
|
301
|
+
chunk_index: doc[:chunk_index],
|
|
302
|
+
embedding: doc[:embedding],
|
|
303
|
+
reduced_embedding: doc[:reduced_embedding],
|
|
304
|
+
metadata: JSON.parse(doc[:metadata] || "{}")
|
|
305
|
+
}
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
246
309
|
def dataset_exists?
|
|
247
310
|
return false unless File.exist?(@db_path)
|
|
248
311
|
|
|
312
|
+
# Try to use cached dataset if available
|
|
313
|
+
if @dataset_cache
|
|
314
|
+
return true
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Otherwise check if we can open it
|
|
249
318
|
begin
|
|
250
|
-
|
|
319
|
+
# Don't cache here, just check existence
|
|
320
|
+
dataset = Lancelot::Dataset.open(@db_path)
|
|
251
321
|
true
|
|
252
322
|
rescue
|
|
253
323
|
false
|
|
@@ -263,5 +333,21 @@ module Ragnar
|
|
|
263
333
|
def table_exists?
|
|
264
334
|
dataset_exists?
|
|
265
335
|
end
|
|
336
|
+
|
|
337
|
+
# Cached dataset accessor to prevent file descriptor leaks
|
|
338
|
+
def cached_dataset
|
|
339
|
+
return nil unless File.exist?(@db_path)
|
|
340
|
+
|
|
341
|
+
@dataset_cache ||= begin
|
|
342
|
+
Lancelot::Dataset.open(@db_path)
|
|
343
|
+
rescue => e
|
|
344
|
+
nil
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Clear the cached dataset (e.g., after modifications)
|
|
349
|
+
def clear_dataset_cache
|
|
350
|
+
@dataset_cache = nil
|
|
351
|
+
end
|
|
266
352
|
end
|
|
267
353
|
end
|
data/lib/ragnar/embedder.rb
CHANGED
|
@@ -34,7 +34,7 @@ module Ragnar
|
|
|
34
34
|
def embed_batch(texts, show_progress: true)
|
|
35
35
|
embeddings = []
|
|
36
36
|
|
|
37
|
-
if show_progress
|
|
37
|
+
if show_progress && $stdout.respond_to?(:ioctl)
|
|
38
38
|
progressbar = TTY::ProgressBar.new(
|
|
39
39
|
"Generating embeddings [:bar] :percent :current/:total",
|
|
40
40
|
total: texts.size,
|
data/lib/ragnar/indexer.rb
CHANGED
|
@@ -31,7 +31,7 @@ module Ragnar
|
|
|
31
31
|
|
|
32
32
|
puts "Found #{files.size} file(s) to process" if @show_progress
|
|
33
33
|
|
|
34
|
-
file_progress = if @show_progress
|
|
34
|
+
file_progress = if @show_progress && $stdout.respond_to?(:ioctl)
|
|
35
35
|
TTY::ProgressBar.new(
|
|
36
36
|
"Processing [:bar] :percent :current/:total - :filename",
|
|
37
37
|
total: files.size,
|
|
@@ -43,13 +43,15 @@ module Ragnar
|
|
|
43
43
|
nil
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
-
files.
|
|
46
|
+
files.each_with_index do |file_path, idx|
|
|
47
47
|
begin
|
|
48
48
|
if file_progress
|
|
49
49
|
# Update the progress bar with current filename
|
|
50
50
|
filename = File.basename(file_path)
|
|
51
51
|
filename = filename[0..27] + "..." if filename.length > 30
|
|
52
52
|
file_progress.advance(0, filename: filename)
|
|
53
|
+
elsif @show_progress
|
|
54
|
+
puts "Processing (#{idx + 1}/#{files.size}): #{File.basename(file_path)}"
|
|
53
55
|
end
|
|
54
56
|
|
|
55
57
|
process_file(file_path, stats, file_progress)
|
data/lib/ragnar/llm_manager.rb
CHANGED
|
@@ -1,43 +1,47 @@
|
|
|
1
1
|
module Ragnar
|
|
2
|
-
# Singleton manager for
|
|
2
|
+
# Singleton manager for RubyLLM chat instances to avoid reloading models.
|
|
3
|
+
# Supports any RubyLLM provider (red_candle for local, openai, anthropic, etc.)
|
|
3
4
|
class LLMManager
|
|
4
5
|
include Singleton
|
|
5
|
-
|
|
6
|
+
|
|
6
7
|
def initialize
|
|
7
|
-
@
|
|
8
|
+
@chats = {}
|
|
8
9
|
@mutex = Mutex.new
|
|
9
10
|
end
|
|
10
|
-
|
|
11
|
-
# Get or create
|
|
12
|
-
# @param
|
|
13
|
-
# @param
|
|
14
|
-
# @return [
|
|
15
|
-
def
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
11
|
+
|
|
12
|
+
# Get or create a RubyLLM chat instance
|
|
13
|
+
# @param provider [String, Symbol] The RubyLLM provider (default from config)
|
|
14
|
+
# @param model [String] The model identifier (default from config)
|
|
15
|
+
# @return [RubyLLM::Chat] A cached chat instance
|
|
16
|
+
def get_chat(provider: nil, model: nil)
|
|
17
|
+
config = Config.instance
|
|
18
|
+
provider ||= config.llm_provider
|
|
19
|
+
model ||= config.llm_model
|
|
20
|
+
|
|
21
|
+
cache_key = "#{provider}:#{model}"
|
|
22
|
+
|
|
19
23
|
@mutex.synchronize do
|
|
20
|
-
@
|
|
21
|
-
puts "Loading LLM: #{
|
|
22
|
-
|
|
23
|
-
Candle::LLM.from_pretrained(model_id, gguf_file: gguf_file)
|
|
24
|
-
else
|
|
25
|
-
Candle::LLM.from_pretrained(model_id)
|
|
26
|
-
end
|
|
24
|
+
@chats[cache_key] ||= begin
|
|
25
|
+
puts "Loading LLM: #{model} (#{provider})..." if ENV['DEBUG']
|
|
26
|
+
Config.instance.create_chat
|
|
27
27
|
end
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
|
-
|
|
31
|
-
# Clear all cached
|
|
30
|
+
|
|
31
|
+
# Clear all cached chat instances (useful for memory management)
|
|
32
32
|
def clear_cache
|
|
33
33
|
@mutex.synchronize do
|
|
34
|
-
@
|
|
34
|
+
@chats.clear
|
|
35
35
|
end
|
|
36
36
|
end
|
|
37
|
-
|
|
38
|
-
# Get the default
|
|
39
|
-
def
|
|
40
|
-
|
|
37
|
+
|
|
38
|
+
# Get the default chat instance for the application
|
|
39
|
+
def default_chat
|
|
40
|
+
get_chat
|
|
41
41
|
end
|
|
42
|
+
|
|
43
|
+
# Backwards compatibility aliases
|
|
44
|
+
alias_method :get_llm, :get_chat
|
|
45
|
+
alias_method :default_llm, :default_chat
|
|
42
46
|
end
|
|
43
|
-
end
|
|
47
|
+
end
|
|
@@ -16,29 +16,55 @@ module Ragnar
|
|
|
16
16
|
@reranker = nil # Will initialize when needed
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
def query(user_query, top_k: 3, verbose: false)
|
|
19
|
+
def query(user_query, top_k: 3, verbose: false, enable_rewriting: true, enable_reranking: false)
|
|
20
20
|
puts "Processing query: #{user_query}" if verbose
|
|
21
21
|
|
|
22
|
-
# Step 1: Rewrite and analyze the query
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
puts "\nGenerated Sub-queries (#{rewritten['sub_queries'].length}):"
|
|
36
|
-
rewritten['sub_queries'].each_with_index do |sq, idx|
|
|
37
|
-
puts " #{idx + 1}. #{sq}"
|
|
22
|
+
# Step 1: Rewrite and analyze the query (if enabled)
|
|
23
|
+
if enable_rewriting
|
|
24
|
+
puts "\n#{'-'*60}" if verbose
|
|
25
|
+
puts "STEP 1: Query Analysis & Rewriting" if verbose
|
|
26
|
+
puts "-"*60 if verbose
|
|
27
|
+
|
|
28
|
+
rewritten = @rewriter.rewrite(user_query)
|
|
29
|
+
|
|
30
|
+
# Always include the original query in sub-queries to ensure direct matches
|
|
31
|
+
# are found regardless of how the rewriter reformulates
|
|
32
|
+
sub_queries = rewritten['sub_queries'] || []
|
|
33
|
+
unless sub_queries.include?(user_query)
|
|
34
|
+
sub_queries.unshift(user_query)
|
|
38
35
|
end
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
36
|
+
rewritten['sub_queries'] = sub_queries
|
|
37
|
+
|
|
38
|
+
if verbose
|
|
39
|
+
puts "\nOriginal Query: #{user_query}"
|
|
40
|
+
puts "\nRewritten Query Analysis:"
|
|
41
|
+
puts " Clarified Intent: #{rewritten['clarified_intent']}"
|
|
42
|
+
puts " Query Type: #{rewritten['query_type']}"
|
|
43
|
+
puts " Context Needed: #{rewritten['context_needed']}"
|
|
44
|
+
puts "\nGenerated Sub-queries (#{rewritten['sub_queries'].length}):"
|
|
45
|
+
rewritten['sub_queries'].each_with_index do |sq, idx|
|
|
46
|
+
puts " #{idx + 1}. #{sq}"
|
|
47
|
+
end
|
|
48
|
+
if rewritten['key_terms'] && !rewritten['key_terms'].empty?
|
|
49
|
+
puts "\nKey Terms Identified:"
|
|
50
|
+
puts " #{rewritten['key_terms'].join(', ')}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
else
|
|
54
|
+
# Skip rewriting - use original query directly
|
|
55
|
+
rewritten = {
|
|
56
|
+
'clarified_intent' => user_query,
|
|
57
|
+
'query_type' => 'direct',
|
|
58
|
+
'context_needed' => 'general',
|
|
59
|
+
'sub_queries' => [user_query],
|
|
60
|
+
'key_terms' => []
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if verbose
|
|
64
|
+
puts "\n#{'-'*60}"
|
|
65
|
+
puts "STEP 1: Query Analysis (Rewriting Disabled)"
|
|
66
|
+
puts "-"*60
|
|
67
|
+
puts "\nUsing original query directly"
|
|
42
68
|
end
|
|
43
69
|
end
|
|
44
70
|
|
|
@@ -77,18 +103,25 @@ module Ragnar
|
|
|
77
103
|
puts "-"*60
|
|
78
104
|
end
|
|
79
105
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
106
|
+
if enable_reranking
|
|
107
|
+
reranked = rerank_documents(
|
|
108
|
+
query: user_query,
|
|
109
|
+
documents: candidates,
|
|
110
|
+
top_k: top_k * 2
|
|
111
|
+
)
|
|
112
|
+
else
|
|
113
|
+
# Use retrieval order (RRF scores) directly — often more reliable than
|
|
114
|
+
# small cross-encoder rerankers on domain-specific corpora
|
|
115
|
+
reranked = candidates
|
|
116
|
+
end
|
|
117
|
+
|
|
86
118
|
if verbose && reranked.any?
|
|
87
|
-
puts "\nTop Reranked Documents:"
|
|
119
|
+
puts "\nTop #{enable_reranking ? 'Reranked' : 'Retrieved'} Documents:"
|
|
88
120
|
reranked[0..2].each_with_index do |doc, idx|
|
|
89
121
|
full_text = (doc[:chunk_text] || doc[:text] || "").gsub(/\s+/, ' ')
|
|
90
122
|
puts " #{idx + 1}. [#{File.basename(doc[:file_path] || 'unknown')}]"
|
|
91
123
|
puts " Score: #{doc[:score]&.round(4) if doc[:score]}"
|
|
124
|
+
puts " Distance: #{doc[:distance]&.round(4) if doc[:distance]}"
|
|
92
125
|
puts " Full chunk (#{full_text.length} chars):"
|
|
93
126
|
puts " \"#{full_text}\""
|
|
94
127
|
puts ""
|
|
@@ -156,12 +189,12 @@ module Ragnar
|
|
|
156
189
|
query: user_query,
|
|
157
190
|
clarified: rewritten['clarified_intent'],
|
|
158
191
|
answer: response,
|
|
159
|
-
sources: context_docs.map { |d|
|
|
192
|
+
sources: context_docs.map { |d|
|
|
160
193
|
{
|
|
161
|
-
source_file: d[:file_path] || d[:source_file],
|
|
162
|
-
chunk_index: d[:chunk_index]
|
|
194
|
+
source_file: d[:file_path] || d[:source_file] || d["file_path"],
|
|
195
|
+
chunk_index: d[:chunk_index] || d["chunk_index"]
|
|
163
196
|
}
|
|
164
|
-
},
|
|
197
|
+
}.reject { |s| s[:source_file].nil? },
|
|
165
198
|
sub_queries: rewritten['sub_queries'],
|
|
166
199
|
confidence: calculate_confidence(reranked[0...top_k])
|
|
167
200
|
}
|
|
@@ -242,22 +275,43 @@ module Ragnar
|
|
|
242
275
|
k: k,
|
|
243
276
|
use_reduced: use_reduced
|
|
244
277
|
)
|
|
245
|
-
|
|
278
|
+
|
|
246
279
|
if verbose
|
|
247
|
-
puts "
|
|
280
|
+
puts " Vector search: #{vector_results.length} matches"
|
|
248
281
|
if vector_results.any?
|
|
249
282
|
best = vector_results.first
|
|
250
|
-
puts " Best match: [#{File.basename(best[:file_path] || 'unknown')}] (distance: #{best[:distance]&.round(3)})"
|
|
283
|
+
puts " Best vector match: [#{File.basename(best[:file_path] || 'unknown')}] (distance: #{best[:distance]&.round(3)})"
|
|
251
284
|
end
|
|
252
285
|
end
|
|
253
|
-
|
|
286
|
+
|
|
254
287
|
# Add query index for RRF
|
|
255
288
|
vector_results.each do |result|
|
|
256
289
|
result[:query_idx] = idx
|
|
257
290
|
result[:retrieval_method] = :vector
|
|
258
291
|
end
|
|
259
|
-
|
|
292
|
+
|
|
260
293
|
all_results.concat(vector_results)
|
|
294
|
+
|
|
295
|
+
# Full-text search for keyword matching (hybrid search)
|
|
296
|
+
begin
|
|
297
|
+
fts_results = @database.full_text_search(query, limit: k)
|
|
298
|
+
if verbose && fts_results.any?
|
|
299
|
+
puts " FTS: #{fts_results.length} matches"
|
|
300
|
+
best_fts = fts_results.first
|
|
301
|
+
puts " Best FTS match: [#{File.basename(best_fts[:file_path] || 'unknown')}]"
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
fts_results.each_with_index do |result, rank|
|
|
305
|
+
# Synthesize a distance from FTS rank (lower rank = better match)
|
|
306
|
+
result[:distance] = 0.1 + (rank * 0.05)
|
|
307
|
+
result[:query_idx] = idx
|
|
308
|
+
result[:retrieval_method] = :fts
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
all_results.concat(fts_results)
|
|
312
|
+
rescue => e
|
|
313
|
+
puts " FTS unavailable: #{e.message}" if verbose
|
|
314
|
+
end
|
|
261
315
|
end
|
|
262
316
|
|
|
263
317
|
if verbose
|
|
@@ -281,10 +335,18 @@ module Ragnar
|
|
|
281
335
|
|
|
282
336
|
results.each do |result|
|
|
283
337
|
doc_id = result[:id]
|
|
284
|
-
doc_scores[doc_id]
|
|
285
|
-
|
|
286
|
-
document
|
|
287
|
-
|
|
338
|
+
if doc_scores[doc_id]
|
|
339
|
+
# Prefer the document with more complete metadata
|
|
340
|
+
existing = doc_scores[doc_id][:document]
|
|
341
|
+
if result[:file_path] && !existing[:file_path]
|
|
342
|
+
doc_scores[doc_id][:document] = result
|
|
343
|
+
end
|
|
344
|
+
else
|
|
345
|
+
doc_scores[doc_id] = {
|
|
346
|
+
score: 0.0,
|
|
347
|
+
document: result
|
|
348
|
+
}
|
|
349
|
+
end
|
|
288
350
|
|
|
289
351
|
# RRF formula: 1 / (k + rank)
|
|
290
352
|
# Using distance as a proxy for rank (lower distance = better rank)
|
|
@@ -319,14 +381,14 @@ module Ragnar
|
|
|
319
381
|
|
|
320
382
|
# Initialize reranker if not already done
|
|
321
383
|
@reranker ||= Candle::Reranker.from_pretrained(
|
|
322
|
-
|
|
384
|
+
Config.instance.reranker_model
|
|
323
385
|
)
|
|
324
386
|
|
|
325
387
|
# Prepare document texts - use chunk_text field
|
|
326
388
|
texts = unique_docs.map { |doc| doc[:chunk_text] || doc[:text] || "" }
|
|
327
389
|
|
|
328
|
-
# Rerank -
|
|
329
|
-
reranked = @reranker.rerank(query, texts)
|
|
390
|
+
# Rerank - use raw logits (no sigmoid) for better score separation
|
|
391
|
+
reranked = @reranker.rerank(query, texts, apply_sigmoid: false)
|
|
330
392
|
|
|
331
393
|
# Map back to original documents with scores
|
|
332
394
|
reranked.map do |result|
|
|
@@ -343,46 +405,37 @@ module Ragnar
|
|
|
343
405
|
# In the future, we could fetch neighboring chunks for more context
|
|
344
406
|
context_size = case context_needed
|
|
345
407
|
when "extensive" then 5
|
|
346
|
-
when "moderate" then
|
|
347
|
-
else
|
|
408
|
+
when "moderate" then 4
|
|
409
|
+
else 3
|
|
348
410
|
end
|
|
349
411
|
|
|
350
412
|
documents.first(context_size)
|
|
351
413
|
end
|
|
352
414
|
|
|
353
415
|
def generate_response(query:, repacked_context:, query_type:)
|
|
354
|
-
#
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
416
|
+
# Create a fresh chat for each query to avoid conversation history bleed
|
|
417
|
+
chat = Config.instance.create_chat
|
|
418
|
+
chat.with_instructions(
|
|
419
|
+
"You are a helpful assistant. Answer questions based ONLY on the provided context. " \
|
|
420
|
+
"If the answer is not in the context, say \"I don't have enough information to answer that question.\" " \
|
|
421
|
+
"Be concise and direct. /no_think"
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
prompt = "Context:\n#{repacked_context}\n\nQuestion: #{query}"
|
|
425
|
+
response = chat.ask(prompt).content
|
|
426
|
+
# Strip <think>...</think> blocks that some models (e.g. Qwen3) include
|
|
427
|
+
strip_think_tags(response)
|
|
362
428
|
rescue => e
|
|
363
429
|
# Fallback to returning the repacked context
|
|
364
430
|
puts "Warning: LLM generation failed (#{e.message})"
|
|
365
431
|
"Based on the retrieved information:\n\n#{repacked_context[0..500]}..."
|
|
366
432
|
end
|
|
367
433
|
|
|
368
|
-
def
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
You are a helpful assistant. Answer questions based ONLY on the provided context.
|
|
372
|
-
If the answer is not in the context, say "I don't have enough information to answer that question."
|
|
373
|
-
</s>
|
|
374
|
-
<|user|>
|
|
375
|
-
Context:
|
|
376
|
-
#{context}
|
|
377
|
-
|
|
378
|
-
Question: #{query}
|
|
379
|
-
</s>
|
|
380
|
-
<|assistant|>
|
|
381
|
-
PROMPT
|
|
382
|
-
|
|
383
|
-
base_prompt
|
|
434
|
+
def strip_think_tags(text)
|
|
435
|
+
return text unless text
|
|
436
|
+
text.gsub(/<think>.*?<\/think>/m, '').strip
|
|
384
437
|
end
|
|
385
|
-
|
|
438
|
+
|
|
386
439
|
def calculate_confidence(documents)
|
|
387
440
|
return 0.0 if documents.empty?
|
|
388
441
|
|
|
@@ -3,11 +3,11 @@ module Ragnar
|
|
|
3
3
|
def initialize(llm_manager: nil)
|
|
4
4
|
@llm_manager = llm_manager || LLMManager.instance
|
|
5
5
|
end
|
|
6
|
-
|
|
6
|
+
|
|
7
7
|
def rewrite(query)
|
|
8
|
-
#
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
# Create a fresh chat for each rewrite to avoid conversation history bleed
|
|
9
|
+
chat = Config.instance.create_chat
|
|
10
|
+
|
|
11
11
|
# Define the JSON schema for structured output
|
|
12
12
|
schema = {
|
|
13
13
|
type: "object",
|
|
@@ -41,25 +41,28 @@ module Ragnar
|
|
|
41
41
|
},
|
|
42
42
|
required: ["clarified_intent", "query_type", "sub_queries", "key_terms", "context_needed"]
|
|
43
43
|
}
|
|
44
|
-
|
|
44
|
+
|
|
45
45
|
prompt = <<~PROMPT
|
|
46
46
|
Analyze the following user query and break it down for retrieval-augmented generation.
|
|
47
47
|
Focus on understanding the user's intent and creating effective sub-queries for searching.
|
|
48
|
-
|
|
48
|
+
|
|
49
49
|
User Query: #{query}
|
|
50
|
-
|
|
51
|
-
Provide a structured analysis that will help retrieve the most relevant documents.
|
|
50
|
+
|
|
51
|
+
Provide a structured analysis that will help retrieve the most relevant documents. /no_think
|
|
52
52
|
PROMPT
|
|
53
|
-
|
|
53
|
+
|
|
54
54
|
begin
|
|
55
|
-
|
|
56
|
-
result =
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
55
|
+
response = chat.with_schema(schema).ask(prompt)
|
|
56
|
+
result = response.content
|
|
57
|
+
|
|
58
|
+
# RubyLLM with_schema returns parsed content; handle both String and Hash
|
|
59
|
+
if result.is_a?(String)
|
|
60
|
+
JSON.parse(result)
|
|
61
|
+
elsif result.is_a?(Hash)
|
|
62
|
+
result.transform_keys(&:to_s)
|
|
63
|
+
else
|
|
64
|
+
result
|
|
65
|
+
end
|
|
63
66
|
rescue => e
|
|
64
67
|
# Fallback to simple rewriting if structured generation fails
|
|
65
68
|
{
|
|
@@ -72,4 +75,4 @@ module Ragnar
|
|
|
72
75
|
end
|
|
73
76
|
end
|
|
74
77
|
end
|
|
75
|
-
end
|
|
78
|
+
end
|