ваше сообщение коммита
This commit is contained in:
@@ -1,21 +1,26 @@
|
||||
FROM node:20-bullseye
|
||||
FROM node:20-bookworm as nodebase
|
||||
|
||||
FROM ubuntu:24.04
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Устанавливаем зависимости, включая Python для node-gyp
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python3 make g++ cmake openssl libssl-dev \
|
||||
ca-certificates curl gnupg lsb-release
|
||||
ca-certificates curl gnupg lsb-release \
|
||||
build-essential python3-dev libc6-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN mkdir -p /etc/apt/keyrings && \
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
# Устанавливаем Node.js 20 и yarn
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
|
||||
apt-get install -y nodejs && \
|
||||
npm install -g yarn
|
||||
|
||||
RUN echo \
|
||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \
|
||||
$(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
# Копируем libnode.so.109 из node:20-bookworm
|
||||
COPY --from=nodebase /usr/lib/x86_64-linux-gnu/libnode.so.109 /usr/lib/x86_64-linux-gnu/libnode.so.109
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
# Устанавливаем node-gyp глобально
|
||||
RUN npm install -g node-gyp
|
||||
|
||||
# Копируем package.json и yarn.lock для установки зависимостей
|
||||
COPY package.json yarn.lock ./
|
||||
@@ -23,6 +28,16 @@ COPY package.json yarn.lock ./
|
||||
# Устанавливаем зависимости
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
# ПРИНУДИТЕЛЬНО пересобираем hnswlib-node из исходников
|
||||
RUN echo "Пересобираем hnswlib-node из исходников..." && \
|
||||
cd node_modules/hnswlib-node && \
|
||||
rm -rf build/ && \
|
||||
node-gyp rebuild --verbose
|
||||
|
||||
# Проверяем версию libstdc++ и наличие нужного символа
|
||||
RUN echo "Версия libstdc++:" && strings /lib/x86_64-linux-gnu/libstdc++.so.6 | grep GLIBCXX | tail -5
|
||||
RUN echo "Проверка GLIBCXX_3.4.32:" && strings /lib/x86_64-linux-gnu/libstdc++.so.6 | grep GLIBCXX_3.4.32 || echo "GLIBCXX_3.4.32 not found"
|
||||
|
||||
# Копируем остальные файлы проекта
|
||||
COPY . .
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ const tagsInitRoutes = require('./routes/tagsInit');
|
||||
const tagsRoutes = require('./routes/tags');
|
||||
const ragRoutes = require('./routes/rag'); // Новый роут для RAG-ассистента
|
||||
const cloudflareRoutes = require('./routes/cloudflare');
|
||||
const monitoringRoutes = require('./routes/monitoring');
|
||||
|
||||
// Проверка и создание директорий для хранения данных контрактов
|
||||
const ensureDirectoriesExist = () => {
|
||||
@@ -192,6 +193,7 @@ app.use('/api/tags', tagsRoutes);
|
||||
app.use('/api/identities', identitiesRoutes);
|
||||
app.use('/api/rag', ragRoutes); // Подключаем роут
|
||||
app.use('/api/cloudflare', cloudflareRoutes);
|
||||
app.use('/api/monitoring', monitoringRoutes);
|
||||
|
||||
const nonceStore = new Map(); // или любая другая реализация хранилища nonce
|
||||
|
||||
@@ -221,18 +223,48 @@ app.use(errorHandler);
|
||||
// Эндпоинт для проверки состояния
|
||||
app.get('/api/health', async (req, res) => {
|
||||
try {
|
||||
// Проверяем подключение к БД
|
||||
await db.getQuery('SELECT NOW()');
|
||||
|
||||
// Проверяем AI сервис
|
||||
const aiStatus = await aiAssistant.checkHealth();
|
||||
|
||||
res.json({
|
||||
const healthStatus = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
database: 'connected',
|
||||
ai: aiStatus,
|
||||
});
|
||||
services: {}
|
||||
};
|
||||
|
||||
// Проверяем подключение к БД
|
||||
try {
|
||||
await db.query('SELECT NOW()');
|
||||
healthStatus.services.database = { status: 'ok' };
|
||||
} catch (error) {
|
||||
healthStatus.services.database = { status: 'error', error: error.message };
|
||||
healthStatus.status = 'error';
|
||||
}
|
||||
|
||||
// Проверяем AI сервис
|
||||
try {
|
||||
const aiStatus = await aiAssistant.checkHealth();
|
||||
healthStatus.services.ai = aiStatus;
|
||||
if (aiStatus.status === 'error') {
|
||||
healthStatus.status = 'error';
|
||||
}
|
||||
} catch (error) {
|
||||
healthStatus.services.ai = { status: 'error', error: error.message };
|
||||
healthStatus.status = 'error';
|
||||
}
|
||||
|
||||
// Проверяем Vector Search сервис
|
||||
try {
|
||||
const vectorSearchClient = require('./services/vectorSearchClient');
|
||||
const vectorStatus = await vectorSearchClient.health();
|
||||
healthStatus.services.vectorSearch = vectorStatus;
|
||||
if (vectorStatus.status === 'error') {
|
||||
healthStatus.status = 'error';
|
||||
}
|
||||
} catch (error) {
|
||||
healthStatus.services.vectorSearch = { status: 'error', error: error.message };
|
||||
healthStatus.status = 'error';
|
||||
}
|
||||
|
||||
const statusCode = healthStatus.status === 'ok' ? 200 : 503;
|
||||
res.status(statusCode).json(healthStatus);
|
||||
} catch (error) {
|
||||
logger.error('Health check failed:', error);
|
||||
res.status(500).json({
|
||||
|
||||
38
backend/routes/monitoring.js
Normal file
38
backend/routes/monitoring.js
Normal file
@@ -0,0 +1,38 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const axios = require('axios');
|
||||
const db = require('../db');
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const results = {};
|
||||
// Backend
|
||||
results.backend = { status: 'ok' };
|
||||
|
||||
// Vector Search
|
||||
try {
|
||||
const vs = await axios.get(process.env.VECTOR_SEARCH_URL || 'http://vector-search:8001/health', { timeout: 2000 });
|
||||
results.vectorSearch = { status: 'ok', ...vs.data };
|
||||
} catch (e) {
|
||||
results.vectorSearch = { status: 'error', error: e.message };
|
||||
}
|
||||
|
||||
// Ollama
|
||||
try {
|
||||
const ollama = await axios.get(process.env.OLLAMA_BASE_URL ? process.env.OLLAMA_BASE_URL + '/api/tags' : 'http://ollama:11434/api/tags', { timeout: 2000 });
|
||||
results.ollama = { status: 'ok', models: ollama.data.models?.length || 0 };
|
||||
} catch (e) {
|
||||
results.ollama = { status: 'error', error: e.message };
|
||||
}
|
||||
|
||||
// PostgreSQL
|
||||
try {
|
||||
await db.query('SELECT 1');
|
||||
results.postgres = { status: 'ok' };
|
||||
} catch (e) {
|
||||
results.postgres = { status: 'error', error: e.message };
|
||||
}
|
||||
|
||||
res.json({ status: 'ok', services: results, timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -138,6 +138,29 @@ class AIAssistant {
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка здоровья AI сервиса
|
||||
async checkHealth() {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/tags`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama API returned ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return {
|
||||
status: 'ok',
|
||||
models: data.models?.length || 0,
|
||||
baseUrl: this.baseUrl
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('AI health check failed:', error);
|
||||
return {
|
||||
status: 'error',
|
||||
error: error.message,
|
||||
baseUrl: this.baseUrl
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Добавляем методы из vectorStore.js
|
||||
async initVectorStore() {
|
||||
// ... код инициализации ...
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
const { HNSWLib } = require('@langchain/community/vectorstores/hnswlib');
|
||||
const db = require('../db');
|
||||
const { ChatOllama } = require('@langchain/ollama');
|
||||
const { OllamaEmbeddings } = require('@langchain/ollama');
|
||||
const vectorSearch = require('./vectorSearchClient');
|
||||
const { getProviderSettings } = require('./aiProviderSettingsService');
|
||||
const { OpenAIEmbeddings } = require('@langchain/openai');
|
||||
|
||||
console.log('[RAG] ragService.js loaded');
|
||||
|
||||
async function getTableData(tableId) {
|
||||
const columns = (await db.getQuery()('SELECT * FROM user_columns WHERE table_id = $1', [tableId])).rows;
|
||||
console.log('RAG getTableData: columns:', columns);
|
||||
const rows = (await db.getQuery()('SELECT * FROM user_rows WHERE table_id = $1', [tableId])).rows;
|
||||
const cellValues = (await db.getQuery()('SELECT * FROM user_cell_values WHERE row_id IN (SELECT id FROM user_rows WHERE table_id = $1)', [tableId])).rows;
|
||||
const columns = (await db.query('SELECT * FROM user_columns WHERE table_id = $1', [tableId])).rows;
|
||||
const rows = (await db.query('SELECT * FROM user_rows WHERE table_id = $1', [tableId])).rows;
|
||||
const cellValues = (await db.query('SELECT * FROM user_cell_values WHERE row_id IN (SELECT id FROM user_rows WHERE table_id = $1)', [tableId])).rows;
|
||||
|
||||
const getColId = purpose => columns.find(col => col.options?.purpose === purpose)?.id;
|
||||
const questionColId = getColId('question');
|
||||
@@ -35,147 +31,142 @@ async function getTableData(tableId) {
|
||||
date: cells.find(c => c.column_id === dateColId)?.value,
|
||||
};
|
||||
});
|
||||
const questions = data.map(row => row.question);
|
||||
console.log('RAG getTableData: questions:', questions);
|
||||
if (!questions.length) {
|
||||
console.warn('RAG getTableData: questions array is empty! Проверьте структуру колонок и наличие данных.');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function getEmbeddingsProvider(providerName = 'ollama') {
|
||||
const settings = await getProviderSettings(providerName);
|
||||
if (!settings) throw new Error('Embeddings provider settings not found');
|
||||
switch (providerName) {
|
||||
case 'openai':
|
||||
return new OpenAIEmbeddings({
|
||||
apiKey: settings.api_key,
|
||||
baseURL: settings.base_url,
|
||||
model: settings.selected_model || undefined,
|
||||
});
|
||||
case 'ollama': {
|
||||
// Fallback: если не задан base_url, пробуем env, host.docker.internal, localhost
|
||||
let baseUrl = settings.base_url;
|
||||
if (!baseUrl) {
|
||||
baseUrl = process.env.OLLAMA_BASE_URL;
|
||||
}
|
||||
if (!baseUrl) {
|
||||
// Если в Docker — используем host.docker.internal
|
||||
baseUrl = 'http://host.docker.internal:11434';
|
||||
}
|
||||
// Если всё равно нет — последний fallback
|
||||
if (!baseUrl) {
|
||||
baseUrl = 'http://localhost:11434';
|
||||
}
|
||||
return new OllamaEmbeddings({
|
||||
model: settings.embedding_model || process.env.OLLAMA_EMBED_MODEL || 'mxbai-embed-large',
|
||||
baseUrl,
|
||||
});
|
||||
}
|
||||
// case 'gemini':
|
||||
// return new GeminiEmbeddings({ apiKey: settings.api_key });
|
||||
// Добавьте другие провайдеры по аналогии
|
||||
default:
|
||||
throw new Error('Unknown embeddings provider: ' + providerName);
|
||||
}
|
||||
}
|
||||
async function ragAnswer({ tableId, userQuestion, userTags = [], product = null, threshold = 0.3 }) {
|
||||
console.log(`[RAG] ragAnswer called: tableId=${tableId}, userQuestion="${userQuestion}"`);
|
||||
|
||||
async function ragAnswer({ tableId, userQuestion, userTags = [], product = null, embeddingsProvider = 'ollama', threshold = 0.3 }) {
|
||||
console.log('[RAG] Используется провайдер эмбеддингов:', embeddingsProvider);
|
||||
const data = await getTableData(tableId);
|
||||
// Триммируем вопросы для чистоты сравнения
|
||||
console.log(`[RAG] Got ${data.length} rows from database`);
|
||||
|
||||
const questions = data.map(row => row.question && typeof row.question === 'string' ? row.question.trim() : row.question);
|
||||
|
||||
// Получаем embeddings-инстанс динамически
|
||||
const embeddingsInstance = await getEmbeddingsProvider(embeddingsProvider);
|
||||
|
||||
// Получаем embedding для всех вопросов
|
||||
const embeddings = await embeddingsInstance.embedDocuments(questions);
|
||||
console.log('Questions embedding length:', embeddings[0]?.length, 'Total questions:', questions.length);
|
||||
|
||||
// Получаем embedding для вопроса пользователя (trim)
|
||||
const userQuestionTrimmed = userQuestion && typeof userQuestion === 'string' ? userQuestion.trim() : userQuestion;
|
||||
const [userEmbedding] = await embeddingsInstance.embedDocuments([userQuestionTrimmed]);
|
||||
console.log('User embedding length:', userEmbedding?.length, 'User question:', userQuestionTrimmed);
|
||||
|
||||
// Явно сравниваем embeddings (отладка)
|
||||
console.log('[RAG] Embedding сравнение:');
|
||||
embeddings.forEach((emb, idx) => {
|
||||
const dot = emb.reduce((sum, v, i) => sum + v * userEmbedding[i], 0);
|
||||
console.log(` [${idx}] dot-product: ${dot} | question: "${questions[idx]}"`);
|
||||
});
|
||||
|
||||
// Создаём массив метаданных для каждого вопроса
|
||||
const metadatas = data.map(row => ({
|
||||
id: row.id,
|
||||
answer: row.answer,
|
||||
userTags: row.userTags,
|
||||
context: row.context,
|
||||
product: row.product,
|
||||
priority: row.priority,
|
||||
date: row.date,
|
||||
const rowsForUpsert = data.map(row => ({
|
||||
row_id: row.id,
|
||||
text: row.question,
|
||||
metadata: {
|
||||
answer: row.answer || null,
|
||||
userTags: row.userTags || null,
|
||||
context: row.context || null,
|
||||
product: row.product || null,
|
||||
priority: row.priority || null,
|
||||
date: row.date || null
|
||||
}
|
||||
}));
|
||||
|
||||
// Создаём векторное хранилище
|
||||
const vectorStore = await HNSWLib.fromTexts(questions, metadatas, embeddingsInstance);
|
||||
console.log(`[RAG] Prepared ${rowsForUpsert.length} rows for upsert`);
|
||||
console.log(`[RAG] First row:`, rowsForUpsert[0]);
|
||||
|
||||
// Ищем наиболее похожие вопросы (top-3)
|
||||
const results = await vectorStore.similaritySearchVectorWithScore(userEmbedding, 3);
|
||||
console.log('[RAG] Результаты поиска по векторам (score):', results.map(([doc, score]) => ({ ...doc.metadata, score })));
|
||||
// Upsert все вопросы в индекс (можно оптимизировать по изменению)
|
||||
if (rowsForUpsert.length > 0) {
|
||||
await vectorSearch.upsert(tableId, rowsForUpsert);
|
||||
console.log(`[RAG] Upsert completed`);
|
||||
} else {
|
||||
console.log(`[RAG] No rows to upsert, skipping`);
|
||||
}
|
||||
|
||||
// Фильтруем по тегам/продукту, если нужно
|
||||
let filtered = results.map(([doc, score]) => ({ ...doc.metadata, score }));
|
||||
// Поиск
|
||||
let results = [];
|
||||
if (rowsForUpsert.length > 0) {
|
||||
results = await vectorSearch.search(tableId, userQuestion, 3);
|
||||
console.log(`[RAG] Search completed, got ${results.length} results`);
|
||||
} else {
|
||||
console.log(`[RAG] No data in table, skipping search`);
|
||||
}
|
||||
|
||||
// Фильтрация по тегам/продукту
|
||||
let filtered = results;
|
||||
if (userTags.length) {
|
||||
filtered = filtered.filter(row => row.userTags && userTags.some(tag => row.userTags.includes(tag)));
|
||||
filtered = filtered.filter(row => row.metadata.userTags && userTags.some(tag => row.metadata.userTags.includes(tag)));
|
||||
}
|
||||
if (product) {
|
||||
filtered = filtered.filter(row => row.product === product);
|
||||
filtered = filtered.filter(row => row.metadata.product === product);
|
||||
}
|
||||
console.log('[RAG] Отфильтрованные результаты:', filtered);
|
||||
|
||||
// Берём лучший результат с учётом порога
|
||||
const best = filtered.find(row => row.score >= threshold);
|
||||
console.log(`[RAG] Выбранный ответ (порог ${threshold}):`, best);
|
||||
console.log(`[RAG] Best result:`, best);
|
||||
|
||||
// Формируем ответ
|
||||
return {
|
||||
answer: best?.answer,
|
||||
context: best?.context,
|
||||
product: best?.product,
|
||||
priority: best?.priority,
|
||||
date: best?.date,
|
||||
answer: best?.metadata?.answer,
|
||||
context: best?.metadata?.context,
|
||||
product: best?.metadata?.product,
|
||||
priority: best?.metadata?.priority,
|
||||
date: best?.metadata?.date,
|
||||
score: best?.score,
|
||||
};
|
||||
}
|
||||
|
||||
async function generateLLMResponse({ userQuestion, context, clarifyingAnswer, objectionAnswer, answer, systemPrompt, userTags, product, priority, date, rules, history, model, language }) {
|
||||
// Подставляем значения в шаблон промта
|
||||
let prompt = (systemPrompt || '')
|
||||
.replace('{context}', context || '')
|
||||
.replace('{clarifyingAnswer}', clarifyingAnswer || '')
|
||||
.replace('{objectionAnswer}', objectionAnswer || '')
|
||||
.replace('{answer}', answer || '')
|
||||
.replace('{question}', userQuestion || '')
|
||||
.replace('{userTags}', userTags || '')
|
||||
.replace('{product}', product || '')
|
||||
.replace('{priority}', priority || '')
|
||||
.replace('{date}', date || '')
|
||||
.replace('{rules}', rules || '')
|
||||
.replace('{history}', history || '')
|
||||
.replace('{model}', model || '')
|
||||
.replace('{language}', language || '');
|
||||
|
||||
const chat = new ChatOllama({
|
||||
baseUrl: process.env.OLLAMA_BASE_URL || 'http://localhost:11434',
|
||||
model: process.env.OLLAMA_MODEL || 'qwen2.5',
|
||||
system: prompt,
|
||||
temperature: 0.7,
|
||||
maxTokens: 1000,
|
||||
timeout: 30000,
|
||||
async function generateLLMResponse({
|
||||
userQuestion,
|
||||
context,
|
||||
clarifyingAnswer,
|
||||
objectionAnswer,
|
||||
answer,
|
||||
systemPrompt,
|
||||
userTags,
|
||||
product,
|
||||
priority,
|
||||
date,
|
||||
rules,
|
||||
history,
|
||||
model,
|
||||
language
|
||||
}) {
|
||||
console.log(`[RAG] generateLLMResponse called with:`, {
|
||||
userQuestion,
|
||||
context,
|
||||
answer,
|
||||
systemPrompt,
|
||||
userTags,
|
||||
product,
|
||||
priority,
|
||||
date,
|
||||
model,
|
||||
language
|
||||
});
|
||||
|
||||
const response = await chat.invoke(`Вопрос пользователя: ${userQuestion}`);
|
||||
return response.content;
|
||||
try {
|
||||
const aiAssistant = require('./ai-assistant');
|
||||
|
||||
// Формируем промпт для LLM
|
||||
let prompt = userQuestion;
|
||||
|
||||
if (context) {
|
||||
prompt += `\n\nКонтекст: ${context}`;
|
||||
}
|
||||
|
||||
if (answer) {
|
||||
prompt += `\n\nНайденный ответ: ${answer}`;
|
||||
}
|
||||
|
||||
if (userTags) {
|
||||
prompt += `\n\nТеги: ${userTags}`;
|
||||
}
|
||||
|
||||
if (product) {
|
||||
prompt += `\n\nПродукт: ${product}`;
|
||||
}
|
||||
|
||||
// Получаем ответ от AI
|
||||
const llmResponse = await aiAssistant.getResponse(
|
||||
prompt,
|
||||
language || 'auto',
|
||||
history,
|
||||
systemPrompt,
|
||||
rules
|
||||
);
|
||||
|
||||
console.log(`[RAG] LLM response generated:`, llmResponse);
|
||||
return llmResponse;
|
||||
} catch (error) {
|
||||
console.error(`[RAG] Error generating LLM response:`, error);
|
||||
return 'Извините, произошла ошибка при генерации ответа.';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ragAnswer, generateLLMResponse };
|
||||
module.exports = {
|
||||
ragAnswer,
|
||||
getTableData,
|
||||
generateLLMResponse
|
||||
};
|
||||
69
backend/services/vectorSearchClient.js
Normal file
69
backend/services/vectorSearchClient.js
Normal file
@@ -0,0 +1,69 @@
|
||||
const axios = require('axios');
|
||||
|
||||
const VECTOR_SEARCH_URL = process.env.VECTOR_SEARCH_URL || 'http://vector-search:8001';
|
||||
|
||||
async function upsert(tableId, rows) {
|
||||
const res = await axios.post(`${VECTOR_SEARCH_URL}/upsert`, {
|
||||
table_id: String(tableId),
|
||||
rows: rows.map(r => ({
|
||||
row_id: String(r.row_id),
|
||||
text: r.text,
|
||||
metadata: r.metadata || {}
|
||||
}))
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async function search(tableId, query, topK = 3) {
|
||||
const res = await axios.post(`${VECTOR_SEARCH_URL}/search`, {
|
||||
table_id: String(tableId),
|
||||
query,
|
||||
top_k: topK
|
||||
});
|
||||
return res.data.results;
|
||||
}
|
||||
|
||||
async function remove(tableId, rowIds) {
|
||||
const res = await axios.post(`${VECTOR_SEARCH_URL}/delete`, {
|
||||
table_id: String(tableId),
|
||||
row_ids: rowIds.map(id => String(id))
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async function rebuild(tableId, rows) {
|
||||
const res = await axios.post(`${VECTOR_SEARCH_URL}/rebuild`, {
|
||||
table_id: String(tableId),
|
||||
rows: rows.map(r => ({
|
||||
row_id: String(r.row_id),
|
||||
text: r.text,
|
||||
metadata: r.metadata || {}
|
||||
}))
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async function health() {
|
||||
try {
|
||||
const res = await axios.get(`${VECTOR_SEARCH_URL}/health`, { timeout: 5000 });
|
||||
return {
|
||||
status: 'ok',
|
||||
url: VECTOR_SEARCH_URL,
|
||||
response: res.data
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'error',
|
||||
url: VECTOR_SEARCH_URL,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
upsert,
|
||||
search,
|
||||
remove,
|
||||
rebuild,
|
||||
health
|
||||
};
|
||||
89
backend/tests/ragService.test.js
Normal file
89
backend/tests/ragService.test.js
Normal file
@@ -0,0 +1,89 @@
|
||||
// Принудительно устанавливаем URL для Docker-сети
|
||||
process.env.VECTOR_SEARCH_URL = 'http://vector-search:8001';
|
||||
|
||||
const vectorSearch = require('../services/vectorSearchClient');
|
||||
|
||||
const TEST_TABLE_ID = 'test_table_rag';
|
||||
|
||||
const rows = [
|
||||
{ row_id: '1', text: 'Что такое RAG?', metadata: { answer: 'Retrieval Augmented Generation', userTags: ['ai', 'ml'], product: 'A' } },
|
||||
{ row_id: '2', text: 'Что такое FAISS?', metadata: { answer: 'Facebook AI Similarity Search', userTags: ['ai', 'search'], product: 'B' } },
|
||||
{ row_id: '3', text: 'Что такое Ollama?', metadata: { answer: 'Локальный inference LLM', userTags: ['llm'], product: 'A' } },
|
||||
];
|
||||
|
||||
describe('vectorSearchClient integration (vector-search)', () => {
|
||||
before(async () => {
|
||||
console.log('Загружаем тестовые данные...');
|
||||
console.log('VECTOR_SEARCH_URL:', process.env.VECTOR_SEARCH_URL);
|
||||
await vectorSearch.rebuild(TEST_TABLE_ID, rows);
|
||||
console.log('Тестовые данные загружены');
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
console.log('Очищаем тестовые данные...');
|
||||
await vectorSearch.remove(TEST_TABLE_ID, rows.map(r => r.row_id));
|
||||
console.log('Тестовые данные очищены');
|
||||
});
|
||||
|
||||
it('Поиск без фильтрации', async () => {
|
||||
const results = await vectorSearch.search(TEST_TABLE_ID, 'Что такое RAG?', 1);
|
||||
console.log('Результаты поиска:', results);
|
||||
if (!results || results.length === 0) throw new Error('Нет результатов поиска');
|
||||
if (results[0].metadata.answer !== 'Retrieval Augmented Generation') {
|
||||
throw new Error(`Ответ не совпадает: ${results[0].metadata.answer}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('Поиск с фильтрацией по тегу (должен найти FAISS)', async () => {
|
||||
const results = await vectorSearch.search(TEST_TABLE_ID, 'Что такое FAISS?', 3);
|
||||
console.log('Результаты поиска FAISS:', results);
|
||||
if (!results || results.length === 0) throw new Error('Нет результатов поиска');
|
||||
|
||||
// Фильтруем по тегу 'search'
|
||||
const filtered = results.filter(r => r.metadata.userTags && r.metadata.userTags.includes('search'));
|
||||
if (filtered.length === 0) throw new Error('Нет результатов с тегом search');
|
||||
if (filtered[0].metadata.answer !== 'Facebook AI Similarity Search') {
|
||||
throw new Error(`Ответ не совпадает: ${filtered[0].metadata.answer}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('Поиск с фильтрацией по продукту (должен найти Ollama)', async () => {
|
||||
const results = await vectorSearch.search(TEST_TABLE_ID, 'Что такое Ollama?', 3);
|
||||
console.log('Результаты поиска Ollama:', results);
|
||||
if (!results || results.length === 0) throw new Error('Нет результатов поиска');
|
||||
|
||||
// Фильтруем по продукту 'A'
|
||||
const filtered = results.filter(r => r.metadata.product === 'A');
|
||||
if (filtered.length === 0) throw new Error('Нет результатов с продуктом A');
|
||||
if (filtered[0].metadata.answer !== 'Локальный inference LLM') {
|
||||
throw new Error(`Ответ не совпадает: ${filtered[0].metadata.answer}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('Комбинированная фильтрация (тег+продукт)', async () => {
|
||||
const results = await vectorSearch.search(TEST_TABLE_ID, 'Что такое RAG?', 3);
|
||||
console.log('Результаты поиска RAG:', results);
|
||||
if (!results || results.length === 0) throw new Error('Нет результатов поиска');
|
||||
|
||||
// Фильтруем по тегу 'ai' и продукту 'A'
|
||||
const filtered = results.filter(r =>
|
||||
r.metadata.userTags && r.metadata.userTags.includes('ai') &&
|
||||
r.metadata.product === 'A'
|
||||
);
|
||||
if (filtered.length === 0) throw new Error('Нет результатов с тегом ai и продуктом A');
|
||||
if (filtered[0].metadata.answer !== 'Retrieval Augmented Generation') {
|
||||
throw new Error(`Ответ не совпадает: ${filtered[0].metadata.answer}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('Проверка порога score', async () => {
|
||||
const results = await vectorSearch.search(TEST_TABLE_ID, 'Что такое Ollama?', 3);
|
||||
console.log('Результаты поиска с порогом:', results);
|
||||
if (!results || results.length === 0) throw new Error('Нет результатов поиска');
|
||||
|
||||
// Проверяем, что есть результаты с хорошим score (близкие к 0)
|
||||
const goodScoreResults = results.filter(r => Math.abs(r.score) < 10);
|
||||
if (goodScoreResults.length === 0) throw new Error('Нет результатов с хорошим score');
|
||||
console.log('Результаты с хорошим score:', goodScoreResults.length);
|
||||
});
|
||||
});
|
||||
163
backend/tests/ragServiceFull.test.js
Normal file
163
backend/tests/ragServiceFull.test.js
Normal file
@@ -0,0 +1,163 @@
|
||||
// Временно устанавливаем URL для локального тестирования
|
||||
process.env.VECTOR_SEARCH_URL = 'http://localhost:8001';
|
||||
|
||||
const ragService = require('../services/ragService');
|
||||
const db = require('../db');
|
||||
|
||||
const TEST_TABLE_ID = 999999; // Используем числовой ID
|
||||
|
||||
describe('ragService full integration (DB + vector-search)', () => {
|
||||
before(async () => {
|
||||
console.log('Создаем тестовую таблицу и данные...');
|
||||
|
||||
// Создаем тестовую таблицу
|
||||
await db.getQuery()(`
|
||||
INSERT INTO user_tables (id, name, description)
|
||||
VALUES ($1, 'Test RAG Table', 'Test table for RAG integration')
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`, [TEST_TABLE_ID]);
|
||||
|
||||
// Создаем колонки
|
||||
const columns = [
|
||||
{ id: 'col_question', name: 'Question', type: 'text', purpose: 'question' },
|
||||
{ id: 'col_answer', name: 'Answer', type: 'text', purpose: 'answer' },
|
||||
{ id: 'col_tags', name: 'Tags', type: 'text', purpose: 'userTags' },
|
||||
{ id: 'col_product', name: 'Product', type: 'text', purpose: 'product' }
|
||||
];
|
||||
|
||||
for (const col of columns) {
|
||||
await db.getQuery()(`
|
||||
INSERT INTO user_columns (id, table_id, name, type, options)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`, [col.id, TEST_TABLE_ID, col.name, col.type, JSON.stringify({ purpose: col.purpose })]);
|
||||
}
|
||||
|
||||
// Создаем строки
|
||||
const rows = [
|
||||
{ id: 'row_1', question: 'Что такое RAG?', answer: 'Retrieval Augmented Generation', tags: 'ai,ml', product: 'A' },
|
||||
{ id: 'row_2', question: 'Что такое FAISS?', answer: 'Facebook AI Similarity Search', tags: 'ai,search', product: 'B' },
|
||||
{ id: 'row_3', question: 'Что такое Ollama?', answer: 'Локальный inference LLM', tags: 'llm', product: 'A' }
|
||||
];
|
||||
|
||||
for (const row of rows) {
|
||||
// Создаем строку
|
||||
await db.getQuery()(`
|
||||
INSERT INTO user_rows (id, table_id, name)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`, [row.id, TEST_TABLE_ID, row.question]);
|
||||
|
||||
// Создаем значения ячеек
|
||||
await db.getQuery()(`
|
||||
INSERT INTO user_cell_values (row_id, column_id, value)
|
||||
VALUES ($1, $2, $3), ($1, $4, $5), ($1, $6, $7), ($1, $8, $9)
|
||||
ON CONFLICT (row_id, column_id) DO UPDATE SET value = EXCLUDED.value
|
||||
`, [
|
||||
row.id, 'col_question', row.question,
|
||||
row.id, 'col_answer', row.answer,
|
||||
row.id, 'col_tags', row.tags,
|
||||
row.id, 'col_product', row.product
|
||||
]);
|
||||
}
|
||||
|
||||
console.log('Тестовые данные созданы');
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
console.log('Очищаем тестовые данные...');
|
||||
|
||||
// Удаляем значения ячеек
|
||||
await db.getQuery()(`
|
||||
DELETE FROM user_cell_values
|
||||
WHERE row_id IN (SELECT id FROM user_rows WHERE table_id = $1)
|
||||
`, [TEST_TABLE_ID]);
|
||||
|
||||
// Удаляем строки
|
||||
await db.getQuery()(`
|
||||
DELETE FROM user_rows WHERE table_id = $1
|
||||
`, [TEST_TABLE_ID]);
|
||||
|
||||
// Удаляем колонки
|
||||
await db.getQuery()(`
|
||||
DELETE FROM user_columns WHERE table_id = $1
|
||||
`, [TEST_TABLE_ID]);
|
||||
|
||||
// Удаляем таблицу
|
||||
await db.getQuery()(`
|
||||
DELETE FROM user_tables WHERE id = $1
|
||||
`, [TEST_TABLE_ID]);
|
||||
|
||||
console.log('Тестовые данные очищены');
|
||||
});
|
||||
|
||||
it('Полная интеграция: поиск без фильтрации', async () => {
|
||||
const result = await ragService.ragAnswer({
|
||||
tableId: TEST_TABLE_ID,
|
||||
userQuestion: 'Что такое RAG?'
|
||||
});
|
||||
|
||||
console.log('Результат RAG:', result);
|
||||
if (!result) throw new Error('Нет результата');
|
||||
if (result.answer !== 'Retrieval Augmented Generation') {
|
||||
throw new Error(`Ответ не совпадает: ${result.answer}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('Полная интеграция: фильтрация по тегу', async () => {
|
||||
const result = await ragService.ragAnswer({
|
||||
tableId: TEST_TABLE_ID,
|
||||
userQuestion: 'Что такое FAISS?',
|
||||
userTags: ['search']
|
||||
});
|
||||
|
||||
console.log('Результат с фильтром по тегу:', result);
|
||||
if (!result) throw new Error('Нет результата');
|
||||
if (result.answer !== 'Facebook AI Similarity Search') {
|
||||
throw new Error(`Ответ не совпадает: ${result.answer}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('Полная интеграция: фильтрация по продукту', async () => {
|
||||
const result = await ragService.ragAnswer({
|
||||
tableId: TEST_TABLE_ID,
|
||||
userQuestion: 'Что такое Ollama?',
|
||||
product: 'A'
|
||||
});
|
||||
|
||||
console.log('Результат с фильтром по продукту:', result);
|
||||
if (!result) throw new Error('Нет результата');
|
||||
if (result.answer !== 'Локальный inference LLM') {
|
||||
throw new Error(`Ответ не совпадает: ${result.answer}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('Полная интеграция: комбинированная фильтрация', async () => {
|
||||
const result = await ragService.ragAnswer({
|
||||
tableId: TEST_TABLE_ID,
|
||||
userQuestion: 'Что такое RAG?',
|
||||
userTags: ['ai'],
|
||||
product: 'A'
|
||||
});
|
||||
|
||||
console.log('Результат с комбинированной фильтрацией:', result);
|
||||
if (!result) throw new Error('Нет результата');
|
||||
if (result.answer !== 'Retrieval Augmented Generation') {
|
||||
throw new Error(`Ответ не совпадает: ${result.answer}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('Полная интеграция: проверка порога score', async () => {
|
||||
const result = await ragService.ragAnswer({
|
||||
tableId: TEST_TABLE_ID,
|
||||
userQuestion: 'Что такое Ollama?',
|
||||
threshold: 0.95
|
||||
});
|
||||
|
||||
console.log('Результат с высоким порогом:', result);
|
||||
// С высоким порогом может не быть результата, это нормально
|
||||
if (result && result.score < 0.95) {
|
||||
throw new Error(`Score слишком низкий: ${result.score}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
35
backend/tests/vectorSearchClient.test.js
Normal file
35
backend/tests/vectorSearchClient.test.js
Normal file
@@ -0,0 +1,35 @@
|
||||
const vectorSearch = require('../services/vectorSearchClient');
|
||||
|
||||
const TEST_TABLE_ID = 'test_table_1';
|
||||
|
||||
const rows = [
|
||||
{ row_id: '1', text: 'Как тебя зовут?', metadata: { answer: 'Алексей' } },
|
||||
{ row_id: '2', text: 'Где ты живёшь?', metadata: { answer: 'Москва' } }
|
||||
];
|
||||
|
||||
describe('Vector Search Service Integration', () => {
|
||||
afterAll(async () => {
|
||||
// Очистить тестовые данные
|
||||
await vectorSearch.remove(TEST_TABLE_ID, rows.map(r => r.row_id));
|
||||
});
|
||||
|
||||
test('Upsert and search', async () => {
|
||||
await vectorSearch.upsert(TEST_TABLE_ID, rows);
|
||||
const results = await vectorSearch.search(TEST_TABLE_ID, 'Как зовут?', 1);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].metadata.answer).toBe('Алексей');
|
||||
});
|
||||
|
||||
test('Delete', async () => {
|
||||
await vectorSearch.remove(TEST_TABLE_ID, ['1']);
|
||||
const results = await vectorSearch.search(TEST_TABLE_ID, 'Как зовут?', 1);
|
||||
expect(results.length === 0 || results[0].metadata.answer !== 'Алексей').toBe(true);
|
||||
});
|
||||
|
||||
test('Rebuild', async () => {
|
||||
await vectorSearch.rebuild(TEST_TABLE_ID, [rows[1]]);
|
||||
const results = await vectorSearch.search(TEST_TABLE_ID, 'Где ты живёшь?', 1);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].metadata.answer).toBe('Москва');
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,28 @@ services:
|
||||
ports:
|
||||
- '11434:11434'
|
||||
command: serve
|
||||
vector-search:
|
||||
build:
|
||||
context: ./vector-search
|
||||
dockerfile: Dockerfile
|
||||
container_name: dapp-vector-search
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
depends_on:
|
||||
ollama:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- ./vector-search:/app
|
||||
- vector_search_data:/app/data
|
||||
environment:
|
||||
- OLLAMA_BASE_URL=http://ollama:11434
|
||||
- OLLAMA_EMBED_MODEL=${OLLAMA_EMBEDDINGS_MODEL:-mxbai-embed-large:latest}
|
||||
ports:
|
||||
- '8001:8001'
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
@@ -54,6 +76,8 @@ services:
|
||||
condition: service_healthy
|
||||
ollama:
|
||||
condition: service_started
|
||||
vector-search:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- ./frontend/dist:/app/frontend_dist:ro
|
||||
@@ -73,6 +97,7 @@ services:
|
||||
- OLLAMA_MODEL=${OLLAMA_MODEL:-qwen2.5:7b}
|
||||
- OLLAMA_EMBEDDINGS_MODEL=${OLLAMA_EMBEDDINGS_MODEL:-qwen2.5:7b}
|
||||
- FRONTEND_URL=http://localhost:5173
|
||||
- VECTOR_SEARCH_URL=http://vector-search:8001
|
||||
ports:
|
||||
- '8000:8000'
|
||||
extra_hosts:
|
||||
@@ -127,3 +152,4 @@ volumes:
|
||||
ollama_data: null
|
||||
backend_node_modules: null
|
||||
frontend_node_modules: null
|
||||
vector_search_data: null
|
||||
|
||||
380
frontend/src/components/ai-assistant/SystemMonitoring.vue
Normal file
380
frontend/src/components/ai-assistant/SystemMonitoring.vue
Normal file
@@ -0,0 +1,380 @@
|
||||
<template>
|
||||
<div class="system-monitoring">
|
||||
<h3>🔍 Мониторинг системы</h3>
|
||||
|
||||
<div class="monitoring-controls">
|
||||
<button @click="refreshStatus" :disabled="loading" class="refresh-btn">
|
||||
{{ loading ? '🔄 Обновление...' : '🔄 Обновить статус' }}
|
||||
</button>
|
||||
<span class="last-update">
|
||||
Последнее обновление: {{ lastUpdate }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="status-grid">
|
||||
<div
|
||||
v-for="service in serviceList"
|
||||
:key="service.key"
|
||||
:class="['status-card', getStatusClass(service.status)]"
|
||||
>
|
||||
<h4>
|
||||
<span :class="['status-indicator', getStatusClass(service.status)]"></span>
|
||||
{{ service.label }}
|
||||
</h4>
|
||||
<div class="details">
|
||||
<div class="status-text">
|
||||
Статус: {{ getStatusText(service.status) }}
|
||||
</div>
|
||||
<div class="service-details" v-if="service.details">
|
||||
{{ service.details }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rag-test-section">
|
||||
<h4>🧠 Тест RAG-функциональности</h4>
|
||||
<div class="rag-test-controls">
|
||||
<input
|
||||
v-model="ragQuestion"
|
||||
placeholder="Введите вопрос"
|
||||
class="rag-input"
|
||||
/>
|
||||
<button @click="testRAG" :disabled="ragTesting" class="rag-test-btn">
|
||||
{{ ragTesting ? 'Тестирование...' : 'Тестировать RAG' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="ragResult" :class="['rag-result', getRagResultClass()]">
|
||||
<div v-if="ragResult.success">
|
||||
<strong>✅ Успешно!</strong><br>
|
||||
Вопрос: "{{ ragQuestion }}"<br>
|
||||
Ответ: "{{ ragResult.answer || 'Нет ответа' }}"<br>
|
||||
Score: {{ ragResult.score || 'N/A' }}
|
||||
</div>
|
||||
<div v-else>
|
||||
<strong>❌ Ошибка!</strong><br>
|
||||
{{ ragResult.error }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue';
|
||||
import axios from 'axios';
|
||||
|
||||
const loading = ref(false);
|
||||
const lastUpdate = ref('никогда');
|
||||
const ragQuestion = ref('вопрос 1');
|
||||
const ragTesting = ref(false);
|
||||
const ragResult = ref(null);
|
||||
const monitoringData = ref(null);
|
||||
|
||||
const serviceLabels = {
|
||||
backend: 'Backend',
|
||||
vectorSearch: 'Vector Search',
|
||||
ollama: 'Ollama',
|
||||
postgres: 'PostgreSQL',
|
||||
};
|
||||
|
||||
const serviceList = computed(() => {
|
||||
if (!monitoringData.value) return [];
|
||||
return Object.entries(monitoringData.value.services).map(([key, val]) => ({
|
||||
key,
|
||||
label: serviceLabels[key] || key,
|
||||
status: val.status,
|
||||
details: val.status === 'ok'
|
||||
? (key === 'ollama' && val.models !== undefined ? `Доступно моделей: ${val.models}` : 'Работает')
|
||||
: val.error || 'Ошибка',
|
||||
}));
|
||||
});
|
||||
|
||||
const getStatusClass = (status) => {
|
||||
switch (status) {
|
||||
case 'ok':
|
||||
case 'healthy': return 'healthy';
|
||||
case 'warning': return 'warning';
|
||||
case 'error': return 'error';
|
||||
default: return 'unknown';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status) => {
|
||||
switch (status) {
|
||||
case 'ok':
|
||||
case 'healthy': return 'Работает';
|
||||
case 'warning': return 'Предупреждение';
|
||||
case 'error': return 'Ошибка';
|
||||
default: return 'Неизвестно';
|
||||
}
|
||||
};
|
||||
|
||||
const getRagResultClass = () => {
|
||||
if (!ragResult.value) return '';
|
||||
return ragResult.value.success ? 'success' : 'error';
|
||||
};
|
||||
|
||||
const refreshStatus = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await axios.get('/api/monitoring');
|
||||
monitoringData.value = response.data;
|
||||
lastUpdate.value = new Date().toLocaleString('ru-RU');
|
||||
} catch (e) {
|
||||
monitoringData.value = null;
|
||||
lastUpdate.value = 'Ошибка';
|
||||
}
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const testRAG = async () => {
|
||||
if (!ragQuestion.value.trim()) return;
|
||||
ragTesting.value = true;
|
||||
ragResult.value = null;
|
||||
try {
|
||||
const response = await axios.post('/api/rag/answer', {
|
||||
tableId: 28,
|
||||
question: ragQuestion.value,
|
||||
userTags: [],
|
||||
product: null
|
||||
});
|
||||
ragResult.value = {
|
||||
success: true,
|
||||
answer: response.data.answer,
|
||||
score: response.data.score,
|
||||
llmResponse: response.data.llmResponse
|
||||
};
|
||||
} catch (error) {
|
||||
ragResult.value = {
|
||||
success: false,
|
||||
error: error.response?.data?.error || error.message || 'Неизвестная ошибка'
|
||||
};
|
||||
}
|
||||
ragTesting.value = false;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
refreshStatus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.system-monitoring {
|
||||
background: #f8f9fa;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-top: 20px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.system-monitoring h3 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #333;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.monitoring-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
background: #2196F3;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.refresh-btn:hover:not(:disabled) {
|
||||
background: #1976D2;
|
||||
}
|
||||
|
||||
.refresh-btn:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.last-update {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
border-left: 4px solid #ddd;
|
||||
}
|
||||
|
||||
.status-card.healthy {
|
||||
border-left-color: #4CAF50;
|
||||
}
|
||||
|
||||
.status-card.warning {
|
||||
border-left-color: #FF9800;
|
||||
}
|
||||
|
||||
.status-card.error {
|
||||
border-left-color: #f44336;
|
||||
}
|
||||
|
||||
.status-card.unknown {
|
||||
border-left-color: #9e9e9e;
|
||||
}
|
||||
|
||||
.status-card h4 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #333;
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.status-indicator.healthy {
|
||||
background-color: #4CAF50;
|
||||
}
|
||||
|
||||
.status-indicator.warning {
|
||||
background-color: #FF9800;
|
||||
}
|
||||
|
||||
.status-indicator.error {
|
||||
background-color: #f44336;
|
||||
}
|
||||
|
||||
.status-indicator.unknown {
|
||||
background-color: #9e9e9e;
|
||||
}
|
||||
|
||||
.details {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-weight: 500;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.service-details {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.service-info {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
margin-bottom: 3px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rag-test-section {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.rag-test-section h4 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #333;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.rag-test-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rag-input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.rag-test-btn {
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.rag-test-btn:hover:not(:disabled) {
|
||||
background: #45a049;
|
||||
}
|
||||
|
||||
.rag-test-btn:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.rag-result {
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
border-left: 4px solid #ddd;
|
||||
}
|
||||
|
||||
.rag-result.success {
|
||||
background: #e8f5e8;
|
||||
border-left-color: #4CAF50;
|
||||
}
|
||||
|
||||
.rag-result.error {
|
||||
background: #ffebee;
|
||||
border-left-color: #f44336;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.status-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.monitoring-controls {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.rag-test-controls {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rag-input {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -152,12 +152,10 @@ async function onRuleEditorClose(updated) {
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 32px rgba(0,0,0,0.12);
|
||||
padding: 32px 24px 24px 24px;
|
||||
width: 100vw;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
max-width: 1200px;
|
||||
margin: 32px auto 0 auto;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.close-btn {
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
# План настройки и тестирования поиска по таблице RAG
|
||||
|
||||
## 1. Подготовка таблицы RAG
|
||||
- Убедиться, что таблица RAG создана и содержит пары "вопрос-ответ".
|
||||
- Добавить несколько тестовых записей через UI или напрямую в базу данных.
|
||||
- [x] Убедиться, что таблица RAG создана и содержит пары "вопрос-ответ".
|
||||
- [x] Добавить несколько тестовых записей через UI или напрямую в базу данных.
|
||||
|
||||
## 2. Настройка провайдера эмбеддингов
|
||||
- В настройках ассистента выбрать нужного провайдера (OpenAI, Ollama и др.).
|
||||
- Ввести API-ключ и Base URL (например, для OpenAI: https://api.openai.com/v1).
|
||||
- Сохранить настройки.
|
||||
- [x] В настройках ассистента выбрать нужного провайдера (OpenAI, Ollama и др.).
|
||||
- [x] Ввести API-ключ и Base URL (например, для OpenAI: https://api.openai.com/v1).
|
||||
- [x] Сохранить настройки.
|
||||
|
||||
## 3. Проверка настроек ассистента
|
||||
- Убедиться, что выбран актуальный ID таблицы RAG.
|
||||
- Проверить выбранного провайдера эмбеддингов.
|
||||
- Установить порог релевантности (например, 0.95).
|
||||
- [x] Убедиться, что выбран актуальный ID таблицы RAG.
|
||||
- [x] Проверить выбранного провайдера эмбеддингов.
|
||||
- [x] Установить порог релевантности (например, 0.95).
|
||||
|
||||
## 4. Проверка backend-логики
|
||||
- Проверить, что в backend (например, в ragService.js) реализован поиск по RAG с использованием выбранного провайдера эмбеддингов.
|
||||
- Убедиться, что используется актуальный ID таблицы и динамический выбор провайдера.
|
||||
- Проверить возможность изменения порога релевантности.
|
||||
- [x] Проверить, что в backend (например, в ragService.js) реализован поиск по RAG с использованием выбранного провайдера эмбеддингов.
|
||||
- [x] Убедиться, что используется актуальный ID таблицы и динамический выбор провайдера.
|
||||
- [x] Проверить возможность изменения порога релевантности.
|
||||
|
||||
## 5. Тестирование через UI
|
||||
- Отправить ассистенту вопрос, который есть в RAG-таблице — убедиться, что ответ возвращается из базы.
|
||||
- Отправить вопрос, которого нет в таблице — убедиться, что ассистент либо не отвечает, либо использует LLM (по настройкам).
|
||||
- [ ] Отправить ассистенту вопрос, который есть в RAG-таблице — убедиться, что ответ возвращается из базы.
|
||||
- [ ] Отправить вопрос, которого нет в таблице — убедиться, что ассистент либо не отвечает, либо использует LLM (по настройкам).
|
||||
|
||||
## 6. Проверка логов backend
|
||||
- Проверить логи на наличие сообщений о поиске по RAG, найденных совпадениях и выбранном провайдере эмбеддингов.
|
||||
- В случае ошибок — проанализировать и устранить их.
|
||||
- [ ] Проверить логи на наличие сообщений о поиске по RAG, найденных совпадениях и выбранном провайдере эмбеддингов.
|
||||
- [ ] В случае ошибок — проанализировать и устранить их.
|
||||
|
||||
## 7. Тестирование через API (опционально)
|
||||
- Использовать Postman/curl для отправки запросов напрямую к backend.
|
||||
- Пример запроса:
|
||||
- [ ] Использовать Postman/curl для отправки запросов напрямую к backend.
|
||||
- [ ] Пример запроса:
|
||||
```http
|
||||
POST /api/chat/message
|
||||
{
|
||||
@@ -39,13 +39,32 @@
|
||||
```
|
||||
|
||||
## 8. Автоматизация тестирования (по желанию)
|
||||
- Написать автотесты (например, на Mocha/Jest), которые будут отправлять вопросы и сверять ответы с ожидаемыми из RAG.
|
||||
- [ ] Написать автотесты (например, на Mocha/Jest), которые будут отправлять вопросы и сверять ответы с ожидаемыми из RAG.
|
||||
|
||||
## 9. Рекомендации
|
||||
- Для тестов использовать уникальные, простые вопросы и ответы.
|
||||
- После каждого изменения настроек проводить тестовые запросы.
|
||||
- Добавить в UI индикатор источника ответа (из базы или сгенерирован).
|
||||
- [ ] Для тестов использовать уникальные, простые вопросы и ответы.
|
||||
- [ ] После каждого изменения настроек проводить тестовые запросы.
|
||||
- [ ] Добавить в UI индикатор источника ответа (из базы или сгенерирован).
|
||||
|
||||
---
|
||||
|
||||
## Этапы внедрения сервиса векторного поиска (под ключ)
|
||||
|
||||
- [x] 1. Проектирование и создание структуры Python-сервиса (FastAPI + FAISS)
|
||||
- [x] 2. Реализация REST API: /upsert, /search, /delete, /rebuild, /health
|
||||
- [x] 3. Интеграция с Ollama для генерации эмбеддингов
|
||||
- [x] 4. Dockerfile и docker-compose для сервиса
|
||||
- [ ] 5. Интеграция Node.js backend с новым сервисом (HTTP-клиент)
|
||||
- [ ] 6. Перенос логики поиска из ragService.js на новый сервис
|
||||
- [ ] 7. Тестирование интеграции (ручное и через API)
|
||||
- [ ] 8. Документация по запуску и использованию
|
||||
- [ ] 9. Финальное тестирование через UI и API
|
||||
- [ ] 10. Передача проекта заказчику
|
||||
|
||||
---
|
||||
|
||||
**Прогресс:**
|
||||
- Сервис векторного поиска реализован, поддерживает кэширование, интеграцию с Ollama, все основные REST API.
|
||||
- Следующий этап — интеграция с Node.js backend и перенос логики поиска.
|
||||
|
||||
**Если потребуется пример кода или помощь с конкретной реализацией — обращайтесь!**
|
||||
108
md/vector-search-service.md
Normal file
108
md/vector-search-service.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Техническое задание: Векторный сервис поиска по таблице
|
||||
|
||||
## Цель
|
||||
Реализовать отдельный микросервис для векторного поиска по данным из таблицы в базе данных. Сервис должен предоставлять REST API для добавления, поиска и обновления векторных представлений (эмбеддингов) строк таблицы.
|
||||
|
||||
## Язык и стек
|
||||
- Язык: Python 3.10+
|
||||
- Векторный движок: FAISS
|
||||
- API: FastAPI
|
||||
- Хранение индекса: на диске (persistency)
|
||||
- Docker-образ для деплоя
|
||||
|
||||
## API сервиса
|
||||
|
||||
### 1. Добавление/обновление записей
|
||||
- **POST /upsert**
|
||||
- Тело запроса:
|
||||
```json
|
||||
{
|
||||
"table_id": "string", // идентификатор таблицы
|
||||
"rows": [
|
||||
{
|
||||
"row_id": "string", // идентификатор строки
|
||||
"text": "string", // текст для эмбеддинга
|
||||
"metadata": { ... } // любые дополнительные поля
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
- Ответ: `{ "success": true }`
|
||||
|
||||
### 2. Поиск похожих записей
|
||||
- **POST /search**
|
||||
- Тело запроса:
|
||||
```json
|
||||
{
|
||||
"table_id": "string",
|
||||
"query": "string", // текст запроса
|
||||
"top_k": 3 // количество результатов
|
||||
}
|
||||
```
|
||||
- Ответ:
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"row_id": "string",
|
||||
"score": float,
|
||||
"metadata": { ... }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Удаление записей
|
||||
- **POST /delete**
|
||||
- Тело запроса:
|
||||
```json
|
||||
{
|
||||
"table_id": "string",
|
||||
"row_ids": ["string", ...]
|
||||
}
|
||||
```
|
||||
- Ответ: `{ "success": true }`
|
||||
|
||||
### 4. Пересоздание индекса (опционально)
|
||||
- **POST /rebuild**
|
||||
- Тело запроса:
|
||||
```json
|
||||
{
|
||||
"table_id": "string"
|
||||
}
|
||||
```
|
||||
- Ответ: `{ "success": true }`
|
||||
|
||||
## Требования к эмбеддингам
|
||||
- Для генерации эмбеддингов сервис использует Ollama (через HTTP API, модель mxbai-embed-large или аналогичную).
|
||||
- Эмбеддинги кэшируются локально для ускорения поиска.
|
||||
|
||||
## Требования к интеграции
|
||||
- Сервис не хранит бизнес-логику, только индексы и метаданные.
|
||||
- Node.js backend обращается к сервису по HTTP (localhost или через docker-compose).
|
||||
- Все операции атомарны, сервис устойчив к сбоям.
|
||||
|
||||
## Безопасность
|
||||
- Сервис доступен только во внутренней сети (docker-compose).
|
||||
- Нет публичного доступа извне.
|
||||
|
||||
## Мониторинг и логирование
|
||||
- Логирование всех запросов и ошибок.
|
||||
- Healthcheck endpoint: **GET /health** (ответ: `{ "status": "ok" }`)
|
||||
|
||||
## Docker
|
||||
- Сервис должен запускаться как отдельный контейнер.
|
||||
- Все зависимости описаны в requirements.txt.
|
||||
|
||||
## Пример docker-compose.yml (фрагмент)
|
||||
```yaml
|
||||
services:
|
||||
vector-search:
|
||||
build: ./vector-search
|
||||
ports:
|
||||
- "8001:8001"
|
||||
environment:
|
||||
- OLLAMA_BASE_URL=http://ollama:11434
|
||||
depends_on:
|
||||
- ollama
|
||||
```
|
||||
5
vector-search/.gitignore
vendored
Normal file
5
vector-search/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pkl
|
||||
*.faiss
|
||||
.env
|
||||
7
vector-search/Dockerfile
Normal file
7
vector-search/Dockerfile
Normal file
@@ -0,0 +1,7 @@
|
||||
FROM python:3.10-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY . .
|
||||
EXPOSE 8001
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||
25
vector-search/README.md
Normal file
25
vector-search/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Vector Search Service
|
||||
|
||||
## Запуск локально
|
||||
```
|
||||
pip install -r requirements.txt
|
||||
uvicorn app:app --reload --host 0.0.0.0 --port 8001
|
||||
```
|
||||
|
||||
## Запуск в Docker
|
||||
```
|
||||
docker build -t vector-search .
|
||||
docker run -p 8001:8001 vector-search
|
||||
```
|
||||
|
||||
## Эндпоинты
|
||||
- POST /upsert — добавить/обновить строки
|
||||
- POST /search — поиск похожих
|
||||
- POST /delete — удалить строки
|
||||
- POST /rebuild — пересоздать индекс
|
||||
- GET /health — проверка статуса
|
||||
|
||||
## Пример запроса /upsert
|
||||
```
|
||||
curl -X POST http://localhost:8001/upsert -H "Content-Type: application/json" -d '{"table_id": "t1", "rows": [{"row_id": "1", "text": "Пример", "metadata": {}}]}'
|
||||
```
|
||||
128
vector-search/app.py
Normal file
128
vector-search/app.py
Normal file
@@ -0,0 +1,128 @@
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Dict, Any
|
||||
import os
|
||||
import requests
|
||||
from vector_store import VectorStore
|
||||
|
||||
OLLAMA_BASE_URL = os.getenv('OLLAMA_BASE_URL', 'http://ollama:11434')
|
||||
EMBED_MODEL = os.getenv('OLLAMA_EMBED_MODEL', 'mxbai-embed-large')
|
||||
|
||||
app = FastAPI()
|
||||
store = VectorStore()
|
||||
|
||||
class UpsertRow(BaseModel):
|
||||
row_id: str
|
||||
text: str
|
||||
metadata: Dict[str, Any] = {}
|
||||
|
||||
class UpsertRequest(BaseModel):
|
||||
table_id: str
|
||||
rows: List[UpsertRow]
|
||||
|
||||
class SearchRequest(BaseModel):
|
||||
table_id: str
|
||||
query: str
|
||||
top_k: int = 3
|
||||
|
||||
class SearchResult(BaseModel):
|
||||
row_id: str
|
||||
score: float
|
||||
metadata: Dict[str, Any] = {}
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
results: List[SearchResult]
|
||||
|
||||
class DeleteRequest(BaseModel):
|
||||
table_id: str
|
||||
row_ids: List[str]
|
||||
|
||||
class RebuildRequest(BaseModel):
|
||||
table_id: str
|
||||
rows: List[UpsertRow]
|
||||
|
||||
# --- Ollama embedding ---
|
||||
def get_embedding(text: str) -> list:
|
||||
print(f"[DEBUG] Getting embedding for text: '{text[:50]}...' (length: {len(text)})")
|
||||
print(f"[DEBUG] Using OLLAMA_BASE_URL: {OLLAMA_BASE_URL}")
|
||||
print(f"[DEBUG] Using EMBED_MODEL: {EMBED_MODEL}")
|
||||
|
||||
try:
|
||||
resp = requests.post(f"{OLLAMA_BASE_URL}/api/embeddings", json={
|
||||
"model": EMBED_MODEL,
|
||||
"prompt": text
|
||||
}, timeout=30)
|
||||
print(f"[DEBUG] Ollama response status: {resp.status_code}")
|
||||
|
||||
if not resp.ok:
|
||||
print(f"[ERROR] Ollama API error: {resp.status_code} - {resp.text}")
|
||||
raise HTTPException(status_code=resp.status_code, detail=f"Ollama API error: {resp.text}")
|
||||
|
||||
data = resp.json()
|
||||
print(f"[DEBUG] Ollama response keys: {list(data.keys())}")
|
||||
|
||||
if 'embedding' in data:
|
||||
print(f"[DEBUG] Found embedding in data['embedding'], length: {len(data['embedding'])}")
|
||||
return data['embedding']
|
||||
if 'data' in data and isinstance(data['data'], list) and 'embedding' in data['data'][0]:
|
||||
print(f"[DEBUG] Found embedding in data['data'][0]['embedding'], length: {len(data['data'][0]['embedding'])}")
|
||||
return data['data'][0]['embedding']
|
||||
|
||||
print(f"[ERROR] No embedding found in response: {data}")
|
||||
raise ValueError('No embedding in Ollama response')
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"[ERROR] Request exception: {e}")
|
||||
raise HTTPException(status_code=422, detail=f"Failed to connect to Ollama: {e}")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Unexpected error: {e}")
|
||||
raise HTTPException(status_code=422, detail=f"Failed to get embedding: {e}")
|
||||
|
||||
@app.post("/upsert")
|
||||
def upsert_rows(req: UpsertRequest):
|
||||
print(f"[DEBUG] Upsert request: table_id={req.table_id}, rows_count={len(req.rows)}")
|
||||
rows_to_upsert = []
|
||||
for i, row in enumerate(req.rows):
|
||||
print(f"[DEBUG] Processing row {i}: row_id={row.row_id}, text_length={len(row.text)}")
|
||||
try:
|
||||
emb = get_embedding(row.text)
|
||||
print(f"[DEBUG] Got embedding for row {i}: length={len(emb)}")
|
||||
rows_to_upsert.append({
|
||||
'row_id': row.row_id,
|
||||
'embedding': emb,
|
||||
'metadata': row.metadata
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Failed to get embedding for row {i}: {e}")
|
||||
raise HTTPException(status_code=422, detail=f"Failed to get embedding: {e}")
|
||||
|
||||
print(f"[DEBUG] Upserting {len(rows_to_upsert)} rows to store")
|
||||
store.upsert(req.table_id, rows_to_upsert)
|
||||
return {"success": True}
|
||||
|
||||
@app.post("/search", response_model=SearchResponse)
|
||||
def search(req: SearchRequest):
|
||||
emb = get_embedding(req.query)
|
||||
results = store.search(req.table_id, emb, req.top_k)
|
||||
return {"results": results}
|
||||
|
||||
@app.post("/delete")
|
||||
def delete(req: DeleteRequest):
|
||||
store.delete(req.table_id, req.row_ids)
|
||||
return {"success": True}
|
||||
|
||||
@app.post("/rebuild")
|
||||
def rebuild(req: RebuildRequest):
|
||||
rows_to_upsert = []
|
||||
for row in req.rows:
|
||||
emb = get_embedding(row.text)
|
||||
rows_to_upsert.append({
|
||||
'row_id': row.row_id,
|
||||
'embedding': emb,
|
||||
'metadata': row.metadata
|
||||
})
|
||||
store.rebuild(req.table_id, rows_to_upsert)
|
||||
return {"success": True}
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
5
vector-search/requirements.txt
Normal file
5
vector-search/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
faiss-cpu
|
||||
requests
|
||||
pydantic
|
||||
1
vector-search/schemas.py
Normal file
1
vector-search/schemas.py
Normal file
@@ -0,0 +1 @@
|
||||
# Здесь можно разместить расширенные схемы Pydantic для API
|
||||
102
vector-search/vector_store.py
Normal file
102
vector-search/vector_store.py
Normal file
@@ -0,0 +1,102 @@
|
||||
# Заглушка для работы с FAISS
|
||||
|
||||
import os
|
||||
import pickle
|
||||
import faiss
|
||||
import numpy as np
|
||||
from typing import List, Dict, Any
|
||||
|
||||
INDEX_DIR = os.path.join(os.path.dirname(__file__), 'indexes')
|
||||
|
||||
class VectorStore:
|
||||
def __init__(self):
|
||||
os.makedirs(INDEX_DIR, exist_ok=True)
|
||||
self.index_cache = {} # table_id: (faiss_index, meta)
|
||||
|
||||
def _index_path(self, table_id):
|
||||
return os.path.join(INDEX_DIR, f'table_{table_id}.faiss')
|
||||
def _meta_path(self, table_id):
|
||||
return os.path.join(INDEX_DIR, f'table_{table_id}_meta.pkl')
|
||||
|
||||
def load(self, table_id):
|
||||
idx_path = self._index_path(table_id)
|
||||
meta_path = self._meta_path(table_id)
|
||||
if os.path.exists(idx_path) and os.path.exists(meta_path):
|
||||
index = faiss.read_index(idx_path)
|
||||
with open(meta_path, 'rb') as f:
|
||||
meta = pickle.load(f)
|
||||
self.index_cache[table_id] = (index, meta)
|
||||
return index, meta
|
||||
return None, None
|
||||
|
||||
def save(self, table_id, index, meta):
|
||||
faiss.write_index(index, self._index_path(table_id))
|
||||
with open(self._meta_path(table_id), 'wb') as f:
|
||||
pickle.dump(meta, f)
|
||||
self.index_cache[table_id] = (index, meta)
|
||||
|
||||
def upsert(self, table_id, rows: List[Dict]):
|
||||
# rows: [{row_id, embedding, metadata}]
|
||||
index, meta = self.load(table_id)
|
||||
if index is None:
|
||||
dim = len(rows[0]['embedding'])
|
||||
index = faiss.IndexFlatL2(dim)
|
||||
meta = []
|
||||
# Удаляем дубликаты row_id
|
||||
existing_ids = {m['row_id'] for m in meta}
|
||||
new_rows = [r for r in rows if r['row_id'] not in existing_ids]
|
||||
if not new_rows:
|
||||
return
|
||||
vectors = np.array([r['embedding'] for r in new_rows]).astype('float32')
|
||||
index.add(vectors)
|
||||
meta.extend(new_rows)
|
||||
self.save(table_id, index, meta)
|
||||
|
||||
def search(self, table_id, query_embedding, top_k=3):
|
||||
index, meta = self.load(table_id)
|
||||
if index is None or not meta:
|
||||
return []
|
||||
query = np.array([query_embedding]).astype('float32')
|
||||
D, I = index.search(query, top_k)
|
||||
results = []
|
||||
for idx, dist in zip(I[0], D[0]):
|
||||
if idx < 0 or idx >= len(meta):
|
||||
continue
|
||||
m = meta[idx]
|
||||
results.append({
|
||||
'row_id': m['row_id'],
|
||||
'score': float(-dist), # FAISS: чем меньше dist, тем ближе
|
||||
'metadata': m['metadata']
|
||||
})
|
||||
return results
|
||||
|
||||
def delete(self, table_id, row_ids: List[str]):
|
||||
index, meta = self.load(table_id)
|
||||
if index is None or not meta:
|
||||
return
|
||||
# FAISS не поддерживает удаление, пересоздаём индекс
|
||||
new_meta = [m for m in meta if m['row_id'] not in row_ids]
|
||||
if not new_meta:
|
||||
# Удаляем файлы
|
||||
try:
|
||||
os.remove(self._index_path(table_id))
|
||||
os.remove(self._meta_path(table_id))
|
||||
except Exception:
|
||||
pass
|
||||
self.index_cache.pop(table_id, None)
|
||||
return
|
||||
dim = len(new_meta[0]['embedding'])
|
||||
new_index = faiss.IndexFlatL2(dim)
|
||||
vectors = np.array([m['embedding'] for m in new_meta]).astype('float32')
|
||||
new_index.add(vectors)
|
||||
self.save(table_id, new_index, new_meta)
|
||||
|
||||
def rebuild(self, table_id, rows: List[Dict]):
|
||||
# rows: [{row_id, embedding, metadata}]
|
||||
if not rows:
|
||||
return
|
||||
dim = len(rows[0]['embedding'])
|
||||
index = faiss.IndexFlatL2(dim)
|
||||
vectors = np.array([r['embedding'] for r in rows]).astype('float32')
|
||||
index.add(vectors)
|
||||
self.save(table_id, index, rows)
|
||||
Reference in New Issue
Block a user