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:
@@ -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' }, [
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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"
|
||||
Reference in New Issue
Block a user