feat: add RAG (Retrieval-Augmented Generation) for AI Doctor

New files:
- tools/rag_build_index.sh: build-time ingestion pipeline
  Chunks docs/knowledge-base/*.md into 2000-char chunks with 200-char overlap
  Produces rag_index.json (39 chunks, ~74KB) for runtime embedding
- tachyon/files/usr/lib/diagnostics/rag.uc: runtime RAG module
  Embeds chunks via same LLM provider API (OpenAI-compatible /v1/embeddings)
  Cosine similarity search, top-3 retrieval, 24h cache in /tmp
  Graceful fallback if embedding API unavailable
- tachyon/files/usr/lib/tachyon/rag_index.json: pre-built chunk index

Modified files:
- runtime.uc: integrated RAG into ai_doctor() function
  Retrieves relevant docs based on user query or diagnostic context
  Injects 'Relevant Documentation' section into LLM prompt
- renderAdvancedSettings.ts: added 'Enable RAG' toggle in AI Doctor settings
- tachyon config: added enable_rag option (default '1')
- build.sh: added rag_build_index.sh step

Architecture:
- Provider-agnostic: uses same API key/provider as LLM for embeddings
- Embedding model: text-embedding-3-small (OpenAI), nomic-embed-text (Ollama),
  local-model (LMStudio), deepseek-embedding (DeepSeek)
- Cache: embeddings cached in /tmp/tachyon_rag_embeddings.json (24h TTL)
- Fallback: if embedding fails, AI Doctor works without RAG (as before)
- 39 chunks from 14 docs, ~66K chars, cosine search <1ms

623 tests pass (101 backend + 522 frontend)
This commit is contained in:
Dushnilin
2026-08-20 10:27:56 +03:00
parent 1137e96b0d
commit 421a4fd796
7 changed files with 425 additions and 0 deletions
+3
View File
@@ -1070,6 +1070,9 @@ main() {
[[ -x "$ipkg_build_bin" ]] || { echo "ipkg-build not found at $ipkg_build_bin" >&2; exit 1; }
[[ -x "$apk_bin" ]] || { echo "apk host tool not found at $apk_bin" >&2; exit 1; }
echo "Building RAG index..." >&2
bash "$ROOT_DIR/tools/rag_build_index.sh" "$ROOT_DIR/docs/knowledge-base" "$ROOT_DIR/tachyon/files/usr/lib/tachyon" || true
build_backend_root "$backend_root"
build_app_root "$app_root"
build_i18n_root "$i18n_root" "$po2lmo_bin"
@@ -20,6 +20,7 @@ interface AdvancedSettingsState {
aiDoctorLang: string;
aiDoctorApiKey: string;
aiDoctorCustomUrl: string;
enableRag: boolean;
aiWatchdog: {
proxyHealthEnabled: boolean;
proxyHealthInterval: string;
@@ -57,6 +58,7 @@ let _state: AdvancedSettingsState = {
aiDoctorLang: 'ru',
aiDoctorApiKey: '',
aiDoctorCustomUrl: '',
enableRag: true,
aiWatchdog: {
proxyHealthEnabled: true,
proxyHealthInterval: '30',
@@ -163,6 +165,8 @@ export async function loadAdvancedSettingsState() {
aiDoctorCustomUrl:
((settingsSec as unknown as Record<string, unknown>)
?.ai_doctor_custom_url as string) || '',
enableRag:
(settingsSec as unknown as Record<string, unknown>)?.enable_rag !== '0',
aiWatchdog: {
proxyHealthEnabled: settingsSec?.ai_proxy_health_enabled !== '0',
proxyHealthInterval:
@@ -327,6 +331,10 @@ async function saveAiWatchdogSettings() {
'set',
`${TACHYON_UCI_PACKAGE}.settings.ai_doctor_custom_url=${_state.aiDoctorCustomUrl}`,
],
[
'set',
`${TACHYON_UCI_PACKAGE}.settings.enable_rag=${_state.enableRag ? '1' : '0'}`,
],
[
'set',
`${TACHYON_UCI_PACKAGE}.settings.ai_proxy_health_enabled=${ai.proxyHealthEnabled ? '1' : '0'}`,
@@ -747,6 +755,20 @@ function renderAiWatchdogSection(state: AdvancedSettingsState) {
E('span', {}, _('Enable AI Doctor (LLM Integration)')),
]),
]),
E('div', { class: 'tachyon_adv__row' }, [
E('label', { class: 'tachyon_adv__toggle' }, [
E('input', {
type: 'checkbox',
checked: state.enableRag,
onchange: (e: Event) => {
const enabled = (e.target as HTMLInputElement).checked;
_state = { ..._state, enableRag: enabled };
rerender();
},
}),
E('span', {}, _('Enable RAG (Knowledge Base Retrieval)')),
]),
]),
state.enableAiDoctor
? E('div', { class: 'tachyon_adv__subrows' }, [
E('div', { class: 'tachyon_adv__row' }, [
+1
View File
@@ -68,6 +68,7 @@ config settings 'settings'
option ai_adaptive_intervals_enabled '1'
option ai_anomaly_detection_enabled '1'
option ai_anomaly_reconnect_threshold '10'
option enable_rag '1'
#config section 'subscription_example'
+258
View File
@@ -0,0 +1,258 @@
let fs = require("fs");
let common = require("core.common");
let command_from_args = common.command_from_args;
let rag_index = null;
let rag_embed_cache = null;
let INDEX_BUILTIN = "/usr/lib/tachyon/rag_index.json";
let INDEX_CUSTOM = "/etc/tachyon/rag_index.json";
let EMBED_CACHE = "/tmp/tachyon_rag_embeddings.json";
function rag_index_path() {
if (fs.stat(INDEX_CUSTOM))
return INDEX_CUSTOM;
return INDEX_BUILTIN;
}
function load_raw_index() {
if (rag_index)
return rag_index;
let path = rag_index_path();
let raw = common.read_json_file(path);
if (!raw || type(raw.chunks) != "array")
return null;
rag_index = raw;
return rag_index;
}
function load_embedding_cache() {
if (rag_embed_cache)
return rag_embed_cache;
let raw = common.read_json_file(EMBED_CACHE);
if (raw && type(raw.embeddings) == "array" && length(raw.embeddings) > 0) {
rag_embed_cache = raw;
return rag_embed_cache;
}
return null;
}
function save_embedding_cache(embeddings, model) {
let data = {
model: model,
timestamp: time(),
embeddings: embeddings
};
common.write_json_file(EMBED_CACHE, data);
}
function get_api_url(provider, api_key, custom_url) {
provider = lc(trim(as_string(provider)));
if (provider == "openai")
return "https://api.openai.com/v1/embeddings";
if (provider == "anthropic" || provider == "claude")
return null;
if (provider == "deepseek")
return "https://api.deepseek.com/v1/embeddings";
if (provider == "openrouter")
return "https://openrouter.ai/api/v1/embeddings";
if (provider == "ollama") {
if (custom_url != "")
return custom_url + "/api/embeddings";
return "http://192.168.1.100:11434/api/embeddings";
}
if (provider == "lmstudio") {
if (custom_url != "")
return custom_url + "/v1/embeddings";
return "http://192.168.1.100:1234/v1/embeddings";
}
if (provider == "custom" && custom_url != "") {
if (index(custom_url, "/embeddings") >= 0)
return custom_url;
return custom_url + "/v1/embeddings";
}
return "https://api.openai.com/v1/embeddings";
}
function get_embedding_model(provider, model_override) {
provider = lc(trim(as_string(provider)));
if (model_override != "")
return model_override;
if (provider == "openai")
return "text-embedding-3-small";
if (provider == "deepseek")
return "deepseek-embedding";
if (provider == "openrouter")
return "openai/text-embedding-3-small";
if (provider == "ollama")
return "nomic-embed-text";
if (provider == "lmstudio")
return "local-model";
return "text-embedding-3-small";
}
function call_embedding_api(api_url, api_key, model, texts) {
let payload = {
model: model,
input: texts
};
let payload_path = "/tmp/rag_embed_payload.json";
common.write_json_file(payload_path, payload);
let curl_args = [
"curl", "-s", "-X", "POST",
"-H", "Content-Type: application/json",
"-H", "Authorization: Bearer " + api_key,
"--connect-timeout", "10",
"-m", "30",
"-d", "@" + payload_path,
api_url
];
let result = common.command_capture(command_from_args(curl_args));
common.remove_file(payload_path);
if (result.status != 0 || result.output == "")
return null;
let data = null;
try { data = json(result.output); } catch(e) {}
if (!data)
return null;
if (type(data.data) == "array" && length(data.data) > 0) {
let embeddings = [];
for (let i = 0; i < length(data.data); i++)
push(data.data[i].embedding, embeddings);
return embeddings;
}
if (type(data.embeddings) == "array" && length(data.embeddings) > 0)
return data.embeddings;
return null;
}
function cosine_similarity(a, b) {
let len = length(a);
if (len == 0 || length(b) != len)
return 0;
let dot = 0, norm_a = 0, norm_b = 0;
for (let i = 0; i < len; i++) {
dot += a[i] * b[i];
norm_a += a[i] * a[i];
norm_b += b[i] * b[i];
}
let denom = sqrt(norm_a) * sqrt(norm_b);
if (denom == 0)
return 0;
return dot / denom;
}
function ensure_embeddings(index, provider, api_key, custom_url, model_override) {
let cached = load_embedding_cache();
let model = get_embedding_model(provider, model_override);
if (cached && length(cached.embeddings) == length(index.chunks) && cached.model == model) {
for (let i = 0; i < length(index.chunks); i++)
index.chunks[i].embedding = cached.embeddings[i];
return true;
}
let api_url = get_api_url(provider, api_key, custom_url);
if (!api_url)
return false;
let texts = [];
for (let i = 0; i < length(index.chunks); i++)
push(index.chunks[i].text, texts);
let embeddings = call_embedding_api(api_url, api_key, model, texts);
if (!embeddings || length(embeddings) != length(texts))
return false;
for (let i = 0; i < length(index.chunks); i++)
index.chunks[i].embedding = embeddings[i];
save_embedding_cache(embeddings, model);
return true;
}
function embed_query(query, provider, api_key, custom_url, model_override) {
let api_url = get_api_url(provider, api_key, custom_url);
if (!api_url)
return null;
let model = get_embedding_model(provider, model_override);
let embeddings = call_embedding_api(api_url, api_key, model, [query]);
if (!embeddings || length(embeddings) == 0)
return null;
return embeddings[0];
}
function search(query_embedding, index, top_k) {
let scored = [];
for (let i = 0; i < length(index.chunks); i++) {
let chunk = index.chunks[i];
if (length(chunk.embedding) == 0)
continue;
let score = cosine_similarity(query_embedding, chunk.embedding);
push({ chunk: chunk, score: score }, scored);
}
sort(scored, function(a, b) { return b.score - a.score; });
let limit = top_k || 3;
if (length(scored) > limit)
scored = scored.slice(0, limit);
return scored;
}
function retrieve(query, provider, api_key, custom_url, model_override, top_k) {
let idx = load_raw_index();
if (!idx)
return "";
if (!ensure_embeddings(idx, provider, api_key, custom_url, model_override))
return "";
let query_emb = embed_query(query, provider, api_key, custom_url, model_override);
if (!query_emb)
return "";
let results = search(query_emb, idx, top_k || 3);
let parts = [];
for (let i = 0; i < length(results); i++) {
let r = results[i];
if (r.score < 0.3)
continue;
push(sprintf("[Source: %s — %s] (relevance: %.0f%%)\n%s",
r.chunk.file, r.chunk.heading, r.score * 100, r.chunk.text), parts);
}
return join("\n\n---\n\n", parts);
}
function invalidate_cache() {
common.remove_file(EMBED_CACHE);
rag_embed_cache = null;
rag_index = null;
}
function status() {
let idx = load_raw_index();
let cached = load_embedding_cache();
return {
index_path: rag_index_path(),
total_chunks: idx ? length(idx.chunks) : 0,
embeddings_ready: cached ? length(cached.embeddings) : 0,
embeddings_model: cached ? cached.model : "none",
cache_age_hours: cached ? int((time() - cached.timestamp) / 3600) : -1
};
}
return {
retrieve: retrieve,
invalidate_cache: invalidate_cache,
status: status,
load_raw_index: load_raw_index
};
@@ -6,6 +6,7 @@ let core_ip = require("core.ip");
let uci_core = require("core.uci");
let runtime_dns = require("singbox.dns");
let common = require("core.common");
let rag = require("diagnostics.rag");
const CONFIG_NAME = getenv("TACHYON_CONFIG_NAME") || constants.TACHYON_CONFIG_NAME || "tachyon";
const LIB_DIR = getenv("TACHYON_LIB") || "/usr/lib/tachyon";
@@ -3968,6 +3969,14 @@ function ai_doctor(user_query) {
watchdog_info, verify_info, log_snippet
);
let rag_context = "";
if (cfg.enable_rag == "1") {
let rag_query = user_query || substr(sys_context, 0, 500);
rag_context = rag.retrieve(rag_query, prov, cfg.ai_doctor_api_key || "", cfg.ai_doctor_custom_url || "", model_override);
}
if (rag_context != "")
sys_context += "\n\nRelevant Documentation:\n" + rag_context;
let lang = lc(trim(cfg.ai_doctor_lang || "ru"));
let prompt = "";
if (lang == "en") {
File diff suppressed because one or more lines are too long
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
DOCS_DIR="${1:-$ROOT_DIR/docs/knowledge-base}"
OUTPUT_DIR="${2:-$ROOT_DIR/tachyon/files/usr/lib/tachyon}"
OUTPUT_FILE="$OUTPUT_DIR/rag_index.json"
CHUNK_SIZE="${CHUNK_SIZE:-2000}"
CHUNK_OVERLAP="${CHUNK_OVERLAP:-200}"
mkdir -p "$OUTPUT_DIR"
python3 - "$DOCS_DIR" "$OUTPUT_FILE" "$CHUNK_SIZE" "$CHUNK_OVERLAP" <<'PYTHON_SCRIPT'
import sys
import os
import json
import re
docs_dir = sys.argv[1]
output_file = sys.argv[2]
chunk_size = int(sys.argv[3])
chunk_overlap = int(sys.argv[4])
def extract_headings(text):
headings = []
for line in text.split('\n'):
m = re.match(r'^(#{1,4})\s+(.*)', line)
if m:
headings.append((len(m.group(1)), m.group(2).strip()))
return headings
def get_section_heading(headings, pos, text):
best = ""
current_pos = 0
for level, title in headings:
idx = text.find(title, current_pos)
if idx == -1:
continue
if idx <= pos:
best = title
current_pos = idx + len(title)
return best
def chunk_text(text, file_name, chunk_size, chunk_overlap):
chunks = []
lines = text.split('\n')
current_chunk = []
current_len = 0
last_heading = ""
for line in lines:
m = re.match(r'^(#{1,4})\s+(.*)', line)
if m:
last_heading = m.group(2).strip()
current_chunk.append(line)
current_len += len(line) + 1
if current_len >= chunk_size:
chunk_text_str = '\n'.join(current_chunk).strip()
if chunk_text_str:
chunks.append({
'text': chunk_text_str,
'file': file_name,
'heading': last_heading
})
overlap_lines = []
overlap_len = 0
for prev_line in reversed(current_chunk):
overlap_lines.insert(0, prev_line)
overlap_len += len(prev_line) + 1
if overlap_len >= chunk_overlap:
break
current_chunk = overlap_lines
current_len = overlap_len
if current_chunk:
chunk_text_str = '\n'.join(current_chunk).strip()
if chunk_text_str:
chunks.append({
'text': chunk_text_str,
'file': file_name,
'heading': last_heading
})
return chunks
all_chunks = []
md_files = sorted([f for f in os.listdir(docs_dir) if f.endswith('.md')])
for fname in md_files:
fpath = os.path.join(docs_dir, fname)
with open(fpath, 'r', encoding='utf-8') as f:
content = f.read()
file_chunks = chunk_text(content, fname, chunk_size, chunk_overlap)
all_chunks.extend(file_chunks)
print(f" {fname}: {len(file_chunks)} chunks", file=sys.stderr)
index = {
'version': 1,
'chunk_size': chunk_size,
'chunk_overlap': chunk_overlap,
'total_chunks': len(all_chunks),
'files': md_files,
'chunks': [
{
'id': i,
'file': c['file'],
'heading': c['heading'],
'text': c['text'],
'embedding': []
}
for i, c in enumerate(all_chunks)
]
}
with open(output_file, 'w', encoding='utf-8') as f:
json.dump(index, f, ensure_ascii=False, indent=None)
total_chars = sum(len(c['text']) for c in all_chunks)
print(f"\nRAG index: {len(all_chunks)} chunks, {total_chars} chars -> {output_file}", file=sys.stderr)
PYTHON_SCRIPT
echo "RAG index built: $OUTPUT_FILE"