simple-rag-zc 0.1.0 → 0.1.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d8b0204bb64f55c075ecb1287b983fda160b1ffcf7e552e346372ab7f95bb3b8
4
- data.tar.gz: 330043e72800a113dcc4df223dbfb978c26449ca2c2d477b13db82c7e5c2e743
3
+ metadata.gz: 906d584b90596bde4fef5efef3f82cceb300284705d6c33023f84dff903f4d2e
4
+ data.tar.gz: 8d1c292cefc14246e918e06d44cdef48bf31548fd5f1aeaa375b527ee4603458
5
5
  SHA512:
6
- metadata.gz: 074af0f36149c2e9d5c0b7cd0dacf369d1c21bb4f812bd3c63f4b41772b5a6cb05f3ff8f75f9ed3a5d28f5c9846adb45e11ec0d1b18b7ada77f3a845ca5a989f
7
- data.tar.gz: 6c34c79345703bc0cfb83bff5373b9d04a03bbba1c5549a96749b26986b2e49ba56d2e7dc63ba1bf749a0df01a9aec0ac66cb96f5ec03ed1c822a3c73a4379fc
6
+ metadata.gz: 94d4c13cd41807bf416882f8241f2ea103d00bacf6662b9b901c92a2f4e65463bdae64d204d955850fcfa9703f36ade459a5d23df837be430e4b7373fa70aa1a
7
+ data.tar.gz: 79f7b78fa5f363b0c5c430f6a4d2af78485cf68b533e26006ef1548a535397a5ffed7e8f19f4d7a6602de1f9ccfdfe73c0f50687fe279796b4ab53d271ad7123
data/README.md CHANGED
@@ -18,11 +18,11 @@ To release a new version to [RubyGems](https://rubygems.org), run:
18
18
 
19
19
  ```bash
20
20
  gem build simple-rag.gemspec
21
- gem push simple-rag-$(ruby -Ilib -e 'require "simple_rag/version"; puts SimpleRag::VERSION').gem
21
+ gem push simple-rag-zc-$(ruby -Ilib -e 'require "simple_rag/version"; puts SimpleRag::VERSION').gem
22
22
  ```
23
23
 
24
24
  Install the gem directly:
25
25
 
26
26
  ```bash
27
- gem install simple-rag
27
+ gem install simple-rag-zc
28
28
  ```
data/example_config.json CHANGED
@@ -15,7 +15,10 @@
15
15
  "reader": "text",
16
16
  "threshold": 0.3,
17
17
  "dir": "D:\\Studies\\tmp\\learning",
18
- "out": "D:\\Studies\\tmp\\learning-gpt1.dt"
18
+ "out": "D:\\Studies\\tmp\\learning\\learning.dt",
19
+ "nameMatch": "talks-*.md",
20
+ "url": "",
21
+ "searchDefault": false
19
22
  }
20
23
  ]
21
- }
24
+ }
data/exe/public/q.html CHANGED
@@ -114,7 +114,7 @@
114
114
  checkbox.type = 'checkbox';
115
115
  checkbox.id = item.name;
116
116
  checkbox.name = item.name;
117
- checkbox.checked = true;
117
+ checkbox.checked = !!item.searchDefault;
118
118
 
119
119
  const label = document.createElement('label');
120
120
  label.htmlFor = item.name;
@@ -228,75 +228,6 @@
228
228
  .catch(error => console.error('Error performing agent search:', error));
229
229
  }
230
230
 
231
- function performAgentSearch() {
232
- const query = searchInput.value;
233
- const configExperiment = configExperimentCheckbox.checked
234
- const checkedPaths = Array.from(pathsList.querySelectorAll('input[type="checkbox"]:checked'))
235
- .map(checkbox => checkbox.name);
236
-
237
- fetch('http://localhost:4567/q_plus', {
238
- method: 'POST',
239
- headers: {
240
- 'Content-Type': 'application/json',
241
- },
242
- body: JSON.stringify({
243
- q: query,
244
- paths: checkedPaths,
245
- experiment: configExperiment,
246
- })
247
- })
248
- .then(response => response.json())
249
- .then(resp => {
250
- responseContainer.innerHTML = '';
251
-
252
- if (!!resp.expanded) {
253
- const div = document.createElement('div');
254
- div.className = 'response-item';
255
- div.style.backgroundColor = textToLightColor("expanded");
256
- div.innerHTML = `<div><strong>Expanded Query:</strong> ${resp.expanded}</div>`;
257
- responseContainer.appendChild(div);
258
- }
259
-
260
- if (resp.variants && resp.variants.length > 0) {
261
- const div = document.createElement('div');
262
- div.className = 'response-item';
263
- div.style.backgroundColor = textToLightColor("variants");
264
- div.innerHTML = `
265
- <div><strong>Variants:</strong> ${resp.variants.join(', ')}</div>
266
- `;
267
- responseContainer.appendChild(div);
268
- }
269
-
270
- if (!!resp.eval) {
271
- const div = document.createElement('div');
272
- div.className = 'response-item';
273
- div.style.backgroundColor = textToLightColor("experiment");
274
- div.innerHTML = `
275
- <div class="markdown-content">${marked.parse(resp.eval)}</div>
276
- `;
277
- responseContainer.appendChild(div);
278
- }
279
-
280
- resp.data.forEach(item => {
281
- const div = document.createElement('div');
282
- div.className = 'response-item';
283
- div.style.backgroundColor = textToLightColor(item.lookup);
284
- div.dataset.note = item.text;
285
- div.innerHTML = `
286
- <div><strong>Path:</strong> <a href="${item.url}">${item.id}</a></div>
287
- <div><strong>Score:</strong> ${item.score}</div>
288
- <div class="markdown-content">${marked.parse(item.text)}</div>
289
- `;
290
- const btn = document.createElement('button');
291
- btn.className = 'discuss-button';
292
- btn.textContent = 'Discuss';
293
- btn.addEventListener('click', () => discussCard(div));
294
- div.appendChild(btn);
295
- responseContainer.appendChild(div);
296
- });
297
- })
298
- .catch(error => console.error('Error performing agent search:', error));
299
- }
300
231
 
301
232
  function textToLightColor(text) {
302
233
  // Generate a hash from the text
@@ -0,0 +1,136 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>Setup SimpleRag</title>
6
+ <style>
7
+ body { font-family: Arial, sans-serif; margin: 20px; }
8
+ .path-item { margin-bottom: 20px; padding: 15px; border: 1px solid #ccc; }
9
+ .path-item input[type="text"], .path-item select { width: 300px; margin-bottom: 10px; }
10
+ .path-item label { display: block; margin-bottom: 5px; }
11
+ </style>
12
+ </head>
13
+ <body>
14
+ <h1>Setup SimpleRag Config</h1>
15
+ <form id="config-form">
16
+ <h2>Paths</h2>
17
+ <div id="paths"></div>
18
+ <button type="button" onclick="addPath()">Add Path</button>
19
+ <h2>Chat</h2>
20
+ <label>Provider: <input id="chat_provider" value="openai"></label><br>
21
+ <label>URL: <input id="chat_url" value=""></label><br>
22
+ <label>Model: <input id="chat_model" value="gpt-3.5-turbo-16k"></label>
23
+ <h2>Embedding</h2>
24
+ <label>Provider: <input id="emb_provider" value="openai"></label><br>
25
+ <label>URL: <input id="emb_url" value=""></label><br>
26
+ <label>Model: <input id="emb_model" value="text-embedding-3-small"></label>
27
+ <br><br>
28
+ <button type="submit">Save</button>
29
+ </form>
30
+ <script>
31
+ let READERS = [];
32
+
33
+ function fillReaderSelect(select, value){
34
+ select.innerHTML = READERS.map(r=>`<option value="${r}">${r}</option>`).join('');
35
+ if(value){ select.value = value; }
36
+ }
37
+
38
+ function createPathDiv(p){
39
+ const idx = document.querySelectorAll('.path-item').length;
40
+ const div = document.createElement('div');
41
+ div.className = 'path-item';
42
+ div.innerHTML = `
43
+ <label>Dir: <input type="text" class="pdir" id="dir_${idx}" value="${p?.dir||''}">
44
+ <input type="file" webkitdirectory directory style="display:none" id="dirsel_${idx}">
45
+ <button type="button" onclick="document.getElementById('dirsel_${idx}').click()">Select Folder</button></label>
46
+ <label>Name: <input type="text" class="pname" value="${p?.name||''}"></label>
47
+ <label>Reader: <select class="preader"></select></label>
48
+ <label>Threshold: <input type="text" class="pthreshold" value="${p?.threshold||0.3}"></label>
49
+ <label>Out: <input type="text" class="pout" value="${p?.out||''}"></label>
50
+ <label>NameMatch: <input type="text" class="pnamematch" value="${p?.nameMatch||''}"></label>
51
+ <label>URL: <input type="text" class="purl" value="${p?.url||''}"></label>
52
+ <label>Search Default: <input type="checkbox" class="psearchdefault" ${p?.searchDefault?'checked':''}></label>
53
+ <button type="button" onclick="this.parentNode.remove()">Remove</button>
54
+ `;
55
+ const dirInput = div.querySelector('#dir_'+idx);
56
+ const nameInput = div.querySelector('.pname');
57
+ const outInput = div.querySelector('.pout');
58
+
59
+ function updateNameOut(){
60
+ if(!dirInput.value) return;
61
+ const parts = dirInput.value.replace(/\\/g,'/').split('/').filter(Boolean);
62
+ const name = parts[parts.length-1] || '';
63
+ nameInput.value = name;
64
+ outInput.value = dirInput.value.replace(/[/\\]$/, '') + '/' + name + '.dt';
65
+ }
66
+
67
+ div.querySelector('#dirsel_'+idx).addEventListener('change', function(){
68
+ if(this.files.length>0){
69
+ const rel = this.files[0].webkitRelativePath;
70
+ const dir = rel.split('/')[0];
71
+ dirInput.value = dir;
72
+ updateNameOut();
73
+ }
74
+ });
75
+ dirInput.addEventListener('change', updateNameOut);
76
+ fillReaderSelect(div.querySelector('.preader'), p?.reader||'text');
77
+ return div;
78
+ }
79
+
80
+ function addPath(p){
81
+ document.getElementById('paths').appendChild(createPathDiv(p));
82
+ }
83
+
84
+ function loadConfig(readers){
85
+ READERS = readers;
86
+ fetch('/config').then(r=>r.json()).then(cfg=>{
87
+ if(cfg.chat){
88
+ document.getElementById('chat_provider').value = cfg.chat.provider||'openai';
89
+ document.getElementById('chat_url').value = cfg.chat.url||'';
90
+ document.getElementById('chat_model').value = cfg.chat.model||'gpt-3.5-turbo-16k';
91
+ }
92
+ if(cfg.embedding){
93
+ document.getElementById('emb_provider').value = cfg.embedding.provider||'openai';
94
+ document.getElementById('emb_url').value = cfg.embedding.url||'';
95
+ document.getElementById('emb_model').value = cfg.embedding.model||'text-embedding-3-small';
96
+ }
97
+ if(cfg.paths && cfg.paths.length>0){
98
+ cfg.paths.forEach(p=>addPath(p));
99
+ }else{
100
+ addPath();
101
+ }
102
+ });
103
+ }
104
+
105
+ fetch('/readers').then(r=>r.json()).then(loadConfig);
106
+
107
+ document.getElementById('config-form').addEventListener('submit', function(e){
108
+ e.preventDefault();
109
+ const paths=[];
110
+ document.querySelectorAll('.path-item').forEach(div=>{
111
+ paths.push({
112
+ dir: div.querySelector('.pdir').value,
113
+ name: div.querySelector('.pname').value,
114
+ reader: div.querySelector('.preader').value,
115
+ threshold: parseFloat(div.querySelector('.pthreshold').value)||0,
116
+ out: div.querySelector('.pout').value,
117
+ nameMatch: div.querySelector('.pnamematch').value,
118
+ url: div.querySelector('.purl').value,
119
+ searchDefault: div.querySelector('.psearchdefault').checked
120
+ });
121
+ });
122
+ const config={
123
+ chat:{provider:document.getElementById('chat_provider').value,
124
+ url:document.getElementById('chat_url').value,
125
+ model:document.getElementById('chat_model').value},
126
+ embedding:{provider:document.getElementById('emb_provider').value,
127
+ url:document.getElementById('emb_url').value,
128
+ model:document.getElementById('emb_model').value},
129
+ paths:paths
130
+ };
131
+ fetch('/save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(config)})
132
+ .then(()=>alert('Saved'));
133
+ });
134
+ </script>
135
+ </body>
136
+ </html>
data/exe/run-index CHANGED
@@ -11,8 +11,7 @@ require "json"
11
11
  require "ostruct"
12
12
  require "digest"
13
13
 
14
- require_relative "../llm/openai"
15
- require_relative "../llm/embedding"
14
+ require_relative "../llm/llm"
16
15
  require_relative "../readers/reader"
17
16
 
18
17
  if ARGV.length != 1
data/exe/run-server CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  require "json"
11
11
  require "ostruct"
12
- require "sinatra"
12
+ require 'sinatra/base'
13
13
 
14
14
  require_relative "../server/retriever"
15
15
  require_relative "../server/synthesizer"
@@ -23,6 +23,7 @@ end
23
23
  config = JSON.parse(File.read(ARGV[0]))
24
24
  CONFIG = OpenStruct.new(config)
25
25
  CONFIG.paths = CONFIG.paths.map { |p| OpenStruct.new(p) }
26
+ CONFIG.paths.each { |p| p.searchDefault = !!p.searchDefault }
26
27
  CONFIG.path_map = {}
27
28
  CONFIG.paths.each { |p| CONFIG.path_map[p.name] = p }
28
29
 
@@ -32,33 +33,92 @@ if OPENAI_KEY.empty?
32
33
  exit 9
33
34
  end
34
35
 
35
- # list all the paths that can be searched
36
- get '/paths' do
37
- content_type :json
36
+ class SimpleRagServer < Sinatra::Application
37
+ # list all the paths that can be searched
38
+ get '/paths' do
39
+ content_type :json
38
40
 
39
- resp = []
40
- CONFIG.paths.each do |p|
41
- resp << { "name": p.name }
41
+ resp = []
42
+ CONFIG.paths.each do |p|
43
+ resp << { name: p.name, searchDefault: p.searchDefault }
44
+ end
45
+ resp.to_json
42
46
  end
43
- resp.to_json
44
- end
45
47
 
46
- # query within the paths
47
- post '/q' do
48
- content_type :json
48
+ # query within the paths
49
+ post '/q' do
50
+ content_type :json
51
+
52
+ data = JSON.parse(request.body.read)
49
53
 
50
- data = JSON.parse(request.body.read)
54
+ selected = data["paths"]
55
+ if !selected || selected.empty?
56
+ selected = CONFIG.paths.select { |p| p.searchDefault }.map(&:name)
57
+ selected = CONFIG.path_map.keys if selected.empty?
58
+ end
59
+ lookup_paths = selected.map { |name| CONFIG.path_map[name] }
60
+
61
+ topN = (data["topN"] || 20).to_i
62
+
63
+ q = data["q"]
64
+ entries = retrieve_by_embedding(lookup_paths, q)
65
+ if q.to_s.strip.length < 5 && q.to_s.split(/\s+/).length < 5
66
+ entries.concat(retrieve_by_text(lookup_paths, q))
67
+
68
+ unique = {}
69
+ entries.each do |e|
70
+ key = [e["path"], e["chunk"]]
71
+ if unique[key]
72
+ unique[key]["score"] = (unique[key]["score"] || 0) + (e["score"] || 0)
73
+ else
74
+ unique[key] = e
75
+ end
76
+ end
77
+
78
+ entries = unique.values
79
+ end
80
+ entries = entries.sort_by { |item| -item["score"] }.take(topN)
81
+
82
+ resp = {
83
+ data: [],
84
+ }
51
85
 
52
- lookup_paths = (data["paths"] || CONFIG.paths_map.keys).map do |name|
53
- CONFIG.path_map[name]
86
+ entries.each do |item|
87
+ resp[:data] << {
88
+ path: item["path"],
89
+ lookup: item["lookup"],
90
+ id: item["id"],
91
+ url: item["url"],
92
+ text: item["reader"].load.get_chunk(item["chunk"]),
93
+ score: item["score"],
94
+ }
95
+ end
96
+
97
+ resp.to_json
54
98
  end
55
99
 
56
- topN = (data["topN"] || 20).to_i
100
+ # agentic query - expand the query using LLM before searching
101
+ post '/q_plus' do
102
+ content_type :json
103
+
104
+ data = JSON.parse(request.body.read)
105
+
106
+ selected = data["paths"]
107
+ if !selected || selected.empty?
108
+ selected = CONFIG.paths.select { |p| p.searchDefault }.map(&:name)
109
+ selected = CONFIG.path_map.keys if selected.empty?
110
+ end
111
+ lookup_paths = selected.map { |name| CONFIG.path_map[name] }
112
+
113
+ topN = (data["topN"] || 20).to_i
57
114
 
58
- q = data["q"]
59
- entries = retrieve_by_embedding(lookup_paths, q)
60
- if q.to_s.strip.length < 5 && q.to_s.split(/\s+/).length < 5
61
- entries.concat(retrieve_by_text(lookup_paths, q))
115
+ expanded_q = expand_query(data["q"])
116
+ variants = expand_variants(data["q"])
117
+
118
+ entries = []
119
+ entries.concat(retrieve_by_embedding(lookup_paths, data["q"]))
120
+ entries.concat(retrieve_by_embedding(lookup_paths, expanded_q))
121
+ variants.each { |v| entries.concat(retrieve_by_text(lookup_paths, v)) }
62
122
 
63
123
  unique = {}
64
124
  entries.each do |e|
@@ -70,98 +130,49 @@ post '/q' do
70
130
  end
71
131
  end
72
132
 
73
- entries = unique.values
74
- end
75
- entries = entries.sort_by { |item| -item["score"] }.take(topN)
76
-
77
- resp = {
78
- data: [],
79
- }
80
-
81
- entries.each do |item|
82
- resp[:data] << {
83
- path: item["path"],
84
- lookup: item["lookup"],
85
- id: item["id"],
86
- url: item["url"],
87
- text: item["reader"].load.get_chunk(item["chunk"]),
88
- score: item["score"],
89
- }
90
- end
91
-
92
- resp.to_json
93
- end
133
+ ordered = unique.values.sort_by { |item| -item["score"] }.take(topN)
94
134
 
95
- # agentic query - expand the query using LLM before searching
96
- post '/q_plus' do
97
- content_type :json
135
+ resp = {
136
+ data: [],
137
+ expanded: expanded_q,
138
+ variants: variants,
139
+ }
98
140
 
99
- data = JSON.parse(request.body.read)
141
+ ordered.each do |item|
142
+ resp[:data] << {
143
+ path: item["path"],
144
+ lookup: item["lookup"],
145
+ id: item["id"],
146
+ url: item["url"],
147
+ text: item["reader"].load.get_chunk(item["chunk"]),
148
+ score: item["score"],
149
+ }
150
+ end
100
151
 
101
- lookup_paths = (data["paths"] || CONFIG.paths_map.keys).map do |name|
102
- CONFIG.path_map[name]
152
+ resp.to_json
103
153
  end
104
154
 
105
- topN = (data["topN"] || 20).to_i
155
+ # synthesize notes into a summary
156
+ post '/synthesize' do
157
+ content_type :json
106
158
 
107
- expanded_q = expand_query(data["q"])
108
- variants = expand_variants(data["q"])
159
+ data = JSON.parse(request.body.read)
109
160
 
110
- entries = []
111
- entries.concat(retrieve_by_embedding(lookup_paths, data["q"]))
112
- entries.concat(retrieve_by_embedding(lookup_paths, expanded_q))
113
- variants.each { |v| entries.concat(retrieve_by_text(lookup_paths, v)) }
114
-
115
- unique = {}
116
- entries.each do |e|
117
- key = [e["path"], e["chunk"]]
118
- if unique[key]
119
- unique[key]["score"] = (unique[key]["score"] || 0) + (e["score"] || 0)
120
- else
121
- unique[key] = e
122
- end
123
- end
161
+ summary = synthesize_notes(data["notes"])
124
162
 
125
- ordered = unique.values.sort_by { |item| -item["score"] }.take(topN)
126
-
127
- resp = {
128
- data: [],
129
- expanded: expanded_q,
130
- variants: variants,
131
- }
132
-
133
- ordered.each do |item|
134
- resp[:data] << {
135
- path: item["path"],
136
- lookup: item["lookup"],
137
- id: item["id"],
138
- url: item["url"],
139
- text: item["reader"].load.get_chunk(item["chunk"]),
140
- score: item["score"],
141
- }
163
+ { note: summary }.to_json
142
164
  end
143
165
 
144
- resp.to_json
145
- end
146
-
147
- # synthesize notes into a summary
148
- post '/synthesize' do
149
- content_type :json
166
+ # generate discussion for a single note
167
+ post '/discuss' do
168
+ content_type :json
150
169
 
151
- data = JSON.parse(request.body.read)
170
+ data = JSON.parse(request.body.read)
152
171
 
153
- summary = synthesize_notes(data["notes"])
172
+ discussion = discuss_note(data["note"])
154
173
 
155
- { note: summary }.to_json
174
+ { discussion: discussion }.to_json
175
+ end
156
176
  end
157
177
 
158
- # generate discussion for a single note
159
- post '/discuss' do
160
- content_type :json
161
-
162
- data = JSON.parse(request.body.read)
163
-
164
- discussion = discuss_note(data["note"])
165
-
166
- { discussion: discussion }.to_json
167
- end
178
+ SimpleRagServer.run!
data/exe/run-setup ADDED
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: utf-8
3
+
4
+ # Setup a config JSON interactively via a local web page
5
+ #
6
+ # Usage: run-setup config.json
7
+
8
+ require "json"
9
+ require 'sinatra/base'
10
+ require_relative '../readers/reader'
11
+
12
+ if ARGV.length != 1
13
+ STDOUT << "Invalid arguments received, need a config file\n"
14
+ exit 1
15
+ end
16
+
17
+ config_path = File.expand_path(ARGV[0])
18
+
19
+ class SetupServer < Sinatra::Base
20
+ set :bind, '0.0.0.0'
21
+ set :port, 4568
22
+ set :public_folder, File.expand_path('public', __dir__)
23
+ set :config_path, nil
24
+
25
+ get '/' do
26
+ send_file File.join(settings.public_folder, 'setup.html')
27
+ end
28
+
29
+ get '/readers' do
30
+ content_type :json
31
+ READERS.to_json
32
+ end
33
+
34
+ get '/config' do
35
+ content_type :json
36
+
37
+ if File.exist?(settings.config_path)
38
+ File.read(settings.config_path)
39
+ else
40
+ {}.to_json
41
+ end
42
+ end
43
+
44
+ post '/save' do
45
+ content_type :json
46
+
47
+ data = JSON.parse(request.body.read)
48
+ File.write(settings.config_path, JSON.pretty_generate(data))
49
+
50
+ { status: 'ok' }.to_json
51
+ end
52
+ end
53
+
54
+ SetupServer.set :config_path, config_path
55
+ SetupServer.run!
@@ -1,3 +1,3 @@
1
1
  module SimpleRag
2
- VERSION = "0.1.0"
2
+ VERSION = "0.1.2"
3
3
  end
data/lib/simple_rag.rb CHANGED
@@ -8,8 +8,7 @@ $LOAD_PATH.unshift File.expand_path("..", __dir__)
8
8
  module SimpleRag
9
9
  end
10
10
 
11
- require "llm/openai"
12
- require "llm/embedding"
11
+ require "llm/llm"
13
12
  require "readers/reader"
14
13
  require "server/retriever"
15
14
  require "server/synthesizer"
data/llm/embedding.rb CHANGED
File without changes
data/llm/http.rb CHANGED
File without changes
data/llm/llm.rb ADDED
@@ -0,0 +1,57 @@
1
+ require_relative "openai"
2
+ require_relative "ollama"
3
+
4
+ ROLE_SYSTEM = "system"
5
+ ROLE_USER = "user"
6
+ ROLE_ASSISTANT = "assistant"
7
+ NEXT_ROLE = ->(role) { role != ROLE_USER ? ROLE_USER : ROLE_ASSISTANT }
8
+
9
+ # Fetch configuration value with defaults
10
+ # Supports Hash or OpenStruct configuration objects
11
+
12
+ def cfg(section, key, default)
13
+ return default unless defined?(CONFIG)
14
+ sec = CONFIG.send(section) if CONFIG.respond_to?(section)
15
+ return default unless sec
16
+
17
+ if sec.is_a?(Hash)
18
+ sec.fetch(key, default)
19
+ elsif sec.respond_to?(key)
20
+ val = sec.send(key)
21
+ val.nil? ? default : val
22
+ else
23
+ default
24
+ end
25
+ end
26
+
27
+ # Route chat requests based on provider configuration
28
+
29
+ def chat(messages, opts = {})
30
+ provider = cfg(:chat, 'provider', 'openai').downcase
31
+ case provider
32
+ when 'ollama'
33
+ model = cfg(:chat, 'model', 'llama2')
34
+ url = cfg(:chat, 'url', 'http://localhost:11434/api/chat')
35
+ ollama_chat(messages, model, url, opts)
36
+ else
37
+ model = cfg(:chat, 'model', 'gpt-4.1-mini')
38
+ url = cfg(:chat, 'url', 'https://api.openai.com/v1/chat/completions')
39
+ openai_chat(messages, model, url, opts)
40
+ end
41
+ end
42
+
43
+ # Route embedding requests based on provider configuration
44
+
45
+ def embedding(txts, opts = {})
46
+ provider = cfg(:embedding, 'provider', 'openai').downcase
47
+ case provider
48
+ when 'ollama'
49
+ model = cfg(:embedding, 'model', 'nomic-embed-text')
50
+ url = cfg(:embedding, 'url', 'http://localhost:11434/api/embeddings')
51
+ ollama_embedding(txts, model, url, opts)
52
+ else
53
+ model = cfg(:embedding, 'model', 'text-embedding-3-small')
54
+ url = cfg(:embedding, 'url', 'https://api.openai.com/v1/embeddings')
55
+ openai_embedding(txts, model, url, opts)
56
+ end
57
+ end
data/llm/ollama.rb CHANGED
@@ -1,13 +1,12 @@
1
1
  require_relative "http"
2
2
 
3
- def embedding_ollama(txts, opts = {})
3
+ def ollama_embedding(txts, model, url, opts = {})
4
4
  data = {
5
- "model" => "nomic-embed-text",
5
+ "model" => model,
6
6
  "prompt" => txts
7
7
  }.merge(opts)
8
8
 
9
- uri = "http://localhost:11434/api/embeddings"
10
- response = http_post(uri, nil, data)
9
+ response = http_post(url, nil, data)
11
10
 
12
11
  if response.code != "200"
13
12
  STDOUT << "Embedding error: #{response}\n"
@@ -16,4 +15,25 @@ def embedding_ollama(txts, opts = {})
16
15
 
17
16
  result = JSON.parse(response.body)
18
17
  result["embedding"]
18
+ end
19
+
20
+ def ollama_chat(messages, model, url, opts = {})
21
+ data = {
22
+ "model" => model,
23
+ "messages" => messages
24
+ }.merge(opts)
25
+
26
+ response = http_post(url, nil, data)
27
+
28
+ if response.code != "200"
29
+ STDOUT << "Chat error: #{response}\n"
30
+ exit 1
31
+ end
32
+
33
+ result = JSON.parse(response.body)
34
+ if result.is_a?(Hash) && result["message"]
35
+ result["message"]["content"]
36
+ else
37
+ result["choices"][0]["message"]["content"]
38
+ end
19
39
  end
data/llm/openai.rb CHANGED
@@ -1,18 +1,12 @@
1
1
  require_relative "http"
2
2
 
3
- ROLE_SYSTEM = "system"
4
- ROLE_USER = "user"
5
- ROLE_ASSISTANT = "assistant"
6
- NEXT_ROLE = ->(role) { role != ROLE_USER ? ROLE_USER : ROLE_ASSISTANT }
7
-
8
- def chat(messages, opts = {})
3
+ def openai_chat(messages, model, url, opts = {})
9
4
  data = {
10
- "model" => "gpt-4o-mini",
5
+ "model" => model,
11
6
  "messages" => messages
12
7
  }.merge(opts)
13
8
 
14
- uri = "https://api.openai.com/v1/chat/completions"
15
- response = http_post(uri, OPENAI_KEY, data)
9
+ response = http_post(url, OPENAI_KEY, data)
16
10
 
17
11
  if response.code != "200"
18
12
  STDOUT << "Chat error: #{response}\n"
@@ -25,14 +19,13 @@ def chat(messages, opts = {})
25
19
  result["choices"][0]["message"]["content"]
26
20
  end
27
21
 
28
- def embedding(txts, opts = {})
22
+ def openai_embedding(txts, model, url, opts = {})
29
23
  data = {
30
- "model" => "text-embedding-3-small",
24
+ "model" => model,
31
25
  "input" => txts
32
26
  }.merge(opts)
33
27
 
34
- uri = "https://api.openai.com/v1/embeddings"
35
- response = http_post(uri, OPENAI_KEY, data)
28
+ response = http_post(url, OPENAI_KEY, data)
36
29
 
37
30
  if response.code != "200"
38
31
  STDOUT << "Embedding error: #{response.body}\n"
@@ -41,4 +34,4 @@ def embedding(txts, opts = {})
41
34
 
42
35
  result = JSON.parse(response.body)
43
36
  result["data"][0]["embedding"]
44
- end
37
+ end
File without changes
@@ -0,0 +1,69 @@
1
+ class JournalReader
2
+ SKIP_HEADINGS = ["\u7CBE\u529B", "\u611F\u6069"]
3
+
4
+ attr_accessor :file, :chunks
5
+
6
+ def initialize(file)
7
+ @file = file
8
+ @loaded = false
9
+ @chunks = []
10
+ end
11
+
12
+ def load
13
+ return self if @loaded
14
+
15
+ parse_journal
16
+
17
+ @loaded = true
18
+ self
19
+ end
20
+
21
+ def get_chunk(idx)
22
+ @chunks[idx || 0]
23
+ end
24
+
25
+ private
26
+
27
+ def parse_journal
28
+ started = false
29
+ heading = nil
30
+ lines = []
31
+
32
+ File.foreach(@file) do |line|
33
+ line = line.chomp
34
+ next if line.strip.empty?
35
+
36
+ if !started
37
+ next unless line.start_with?("## ")
38
+ started = true
39
+ heading = line[3..].strip
40
+ lines = [clean_line(line)]
41
+ next
42
+ end
43
+
44
+ if line.start_with?("## ")
45
+ push_chunk(heading, lines)
46
+ heading = line[3..].strip
47
+ lines = [clean_line(line)]
48
+ next
49
+ end
50
+
51
+ next if line.lstrip.start_with?("<")
52
+
53
+ lines << clean_line(line)
54
+ end
55
+
56
+ push_chunk(heading, lines) if started
57
+ end
58
+
59
+ def push_chunk(heading, lines)
60
+ return if SKIP_HEADINGS.any? { |k| heading.include?(k) }
61
+ return if lines.length < 3
62
+
63
+ @chunks << lines.join("\n")
64
+ end
65
+
66
+ def clean_line(line)
67
+ line.gsub(/\[([^\]]+)\]\(([^\)]+)\)/, '\\1')
68
+ end
69
+ end
data/readers/note.rb CHANGED
File without changes
data/readers/reader.rb CHANGED
@@ -1,12 +1,21 @@
1
+ READERS = %w[text note journal]
2
+
1
3
  def get_reader(name)
2
- case name.downcase
4
+ case name.to_s.downcase
3
5
  when "text"
4
6
  require_relative "text"
5
- return TextReader
7
+ TextReader
6
8
  when "note"
7
9
  require_relative "note"
8
- return NoteReader
10
+ NoteReader
11
+ when "journal"
12
+ require_relative "journal"
13
+ JournalReader
9
14
  else
10
- return nil
15
+ nil
11
16
  end
12
- end
17
+ end
18
+
19
+ def available_readers
20
+ READERS
21
+ end
data/readers/text.rb CHANGED
@@ -12,13 +12,26 @@ class TextReader
12
12
  return self if @loaded
13
13
 
14
14
  chunk = ""
15
+ in_frontmatter = false
15
16
  File.foreach(@file) do |line|
16
- if line.start_with?(/- .+:/) || line.start_with?(' - [[') # yaml like
17
+ stripped = line.strip
18
+
19
+ if in_frontmatter
20
+ if stripped == '---' || stripped == '...'
21
+ in_frontmatter = false
22
+ end
23
+ next
24
+ elsif stripped == '---'
25
+ in_frontmatter = true
26
+ next
27
+ end
28
+
29
+ if line.start_with?('- ') && line.include?(':') || line.start_with?(' - [[')
17
30
  next
18
- elsif line.start_with?('<') # html like
31
+ elsif line.start_with?('<')
19
32
  next
20
33
  else
21
- chunk << line unless line.strip.empty?
34
+ chunk << line unless stripped.empty?
22
35
  end
23
36
  end
24
37
 
data/server/cache.rb CHANGED
File without changes
data/server/discuss.rb CHANGED
@@ -3,7 +3,7 @@ You provide a short discussion of a note from multiple perspectives.
3
3
  Focus on explaining key concepts succinctly.
4
4
  PROMPT
5
5
 
6
- require_relative "../llm/openai"
6
+ require_relative "../llm/llm"
7
7
 
8
8
  # note: string
9
9
  # Returns discussion text
data/server/retriever.rb CHANGED
@@ -1,15 +1,13 @@
1
1
  require "pathname"
2
2
 
3
3
  require_relative "cache"
4
-
5
- require_relative "../llm/openai"
4
+ require_relative "../llm/llm"
6
5
  require_relative "../llm/embedding"
7
-
8
6
  require_relative "../readers/reader"
9
7
 
10
8
  AGENT_PROMPT = <<~PROMPT
11
- You expand a short search query so it is easier to retrieve related markdown
12
- documents. Return only the expanded query in a single line.
9
+ Expand the user input to a better search query so it is easier to retrieve related markdown
10
+ documents using embedding. Return only the expanded query in a single line.
13
11
  PROMPT
14
12
 
15
13
  def expand_query(q)
@@ -17,7 +15,11 @@ def expand_query(q)
17
15
  { role: ROLE_SYSTEM, content: AGENT_PROMPT },
18
16
  { role: ROLE_USER, content: q },
19
17
  ]
20
- chat(msgs).strip
18
+
19
+ query = chat(msgs).strip
20
+ STDOUT << "Expand query: #{query}\n"
21
+
22
+ query
21
23
  end
22
24
 
23
25
  def retrieve_by_embedding(lookup_paths, q)
@@ -78,8 +80,8 @@ def extract_url(file_path, url)
78
80
  end
79
81
 
80
82
  VARIANT_PROMPT = <<~PROMPT
81
- You generate a few alternative short search queries for exact text match.
82
- Return a JSON array of strings with three different variants.
83
+ Generate three alternative search keywords based on the user input to retrieve related markdown using exact keyword matches.
84
+ Return the search keywords in one CSV line.
83
85
  PROMPT
84
86
 
85
87
  def expand_variants(q)
@@ -87,7 +89,10 @@ def expand_variants(q)
87
89
  { role: ROLE_SYSTEM, content: VARIANT_PROMPT },
88
90
  { role: ROLE_USER, content: q },
89
91
  ]
90
- JSON.parse(chat(msgs)) rescue []
92
+
93
+ variants = chat(msgs).split(',')
94
+ STDOUT << "Expand variants: #{variants}\n"
95
+ variants
91
96
  end
92
97
 
93
98
  def retrieve_by_text(lookup_paths, q)
@@ -2,7 +2,7 @@ SUM_PROMPT = """You are an expert at combining notes.
2
2
  Given a collection of notes, synthesize them into a concise new note capturing the key points.
3
3
  """
4
4
 
5
- require_relative "../llm/openai"
5
+ require_relative "../llm/llm"
6
6
 
7
7
  # notes: array of strings
8
8
  # Returns summary text
data/storage/mem.rb CHANGED
File without changes
metadata CHANGED
@@ -1,11 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: simple-rag-zc
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Zhuochun
8
- autorequire:
8
+ autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
11
  date: 2025-06-07 00:00:00.000000000 Z
@@ -24,6 +24,20 @@ dependencies:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
26
  version: '4.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rackup
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.2'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.2'
27
41
  - !ruby/object:Gem::Dependency
28
42
  name: puma
29
43
  requirement: !ruby/object:Gem::Requirement
@@ -44,21 +58,26 @@ email:
44
58
  executables:
45
59
  - run-index
46
60
  - run-server
61
+ - run-setup
47
62
  extensions: []
48
63
  extra_rdoc_files: []
49
64
  files:
50
65
  - README.md
51
66
  - example_config.json
52
67
  - exe/public/q.html
68
+ - exe/public/setup.html
53
69
  - exe/run-index
54
70
  - exe/run-server
71
+ - exe/run-setup
55
72
  - lib/simple_rag.rb
56
73
  - lib/simple_rag/version.rb
57
74
  - llm/embedding.rb
58
75
  - llm/http.rb
76
+ - llm/llm.rb
59
77
  - llm/ollama.rb
60
78
  - llm/openai.rb
61
79
  - readers/check-reader.rb
80
+ - readers/journal.rb
62
81
  - readers/note.rb
63
82
  - readers/reader.rb
64
83
  - readers/text.rb
@@ -71,7 +90,7 @@ homepage: https://github.com/zhuochun/simple-rag
71
90
  licenses:
72
91
  - MIT
73
92
  metadata: {}
74
- post_install_message:
93
+ post_install_message:
75
94
  rdoc_options: []
76
95
  require_paths:
77
96
  - lib
@@ -86,8 +105,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
86
105
  - !ruby/object:Gem::Version
87
106
  version: '0'
88
107
  requirements: []
89
- rubygems_version: 3.3.7
90
- signing_key:
108
+ rubygems_version: 3.4.10
109
+ signing_key:
91
110
  specification_version: 4
92
111
  summary: RAG on Markdown Files
93
112
  test_files: []