ваше сообщение коммита
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
|
WORKDIR /app
|
||||||
|
|
||||||
# Устанавливаем зависимости, включая Python для node-gyp
|
# Устанавливаем зависимости, включая Python для node-gyp
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
python3 make g++ cmake openssl libssl-dev \
|
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 && \
|
# Устанавливаем Node.js 20 и yarn
|
||||||
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
|
||||||
|
apt-get install -y nodejs && \
|
||||||
|
npm install -g yarn
|
||||||
|
|
||||||
RUN echo \
|
# Копируем libnode.so.109 из node:20-bookworm
|
||||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \
|
COPY --from=nodebase /usr/lib/x86_64-linux-gnu/libnode.so.109 /usr/lib/x86_64-linux-gnu/libnode.so.109
|
||||||
$(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y \
|
# Устанавливаем node-gyp глобально
|
||||||
docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
RUN npm install -g node-gyp
|
||||||
|
|
||||||
# Копируем package.json и yarn.lock для установки зависимостей
|
# Копируем package.json и yarn.lock для установки зависимостей
|
||||||
COPY package.json yarn.lock ./
|
COPY package.json yarn.lock ./
|
||||||
@@ -23,6 +28,16 @@ COPY package.json yarn.lock ./
|
|||||||
# Устанавливаем зависимости
|
# Устанавливаем зависимости
|
||||||
RUN yarn install --frozen-lockfile
|
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 . .
|
COPY . .
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const tagsInitRoutes = require('./routes/tagsInit');
|
|||||||
const tagsRoutes = require('./routes/tags');
|
const tagsRoutes = require('./routes/tags');
|
||||||
const ragRoutes = require('./routes/rag'); // Новый роут для RAG-ассистента
|
const ragRoutes = require('./routes/rag'); // Новый роут для RAG-ассистента
|
||||||
const cloudflareRoutes = require('./routes/cloudflare');
|
const cloudflareRoutes = require('./routes/cloudflare');
|
||||||
|
const monitoringRoutes = require('./routes/monitoring');
|
||||||
|
|
||||||
// Проверка и создание директорий для хранения данных контрактов
|
// Проверка и создание директорий для хранения данных контрактов
|
||||||
const ensureDirectoriesExist = () => {
|
const ensureDirectoriesExist = () => {
|
||||||
@@ -192,6 +193,7 @@ app.use('/api/tags', tagsRoutes);
|
|||||||
app.use('/api/identities', identitiesRoutes);
|
app.use('/api/identities', identitiesRoutes);
|
||||||
app.use('/api/rag', ragRoutes); // Подключаем роут
|
app.use('/api/rag', ragRoutes); // Подключаем роут
|
||||||
app.use('/api/cloudflare', cloudflareRoutes);
|
app.use('/api/cloudflare', cloudflareRoutes);
|
||||||
|
app.use('/api/monitoring', monitoringRoutes);
|
||||||
|
|
||||||
const nonceStore = new Map(); // или любая другая реализация хранилища nonce
|
const nonceStore = new Map(); // или любая другая реализация хранилища nonce
|
||||||
|
|
||||||
@@ -221,18 +223,48 @@ app.use(errorHandler);
|
|||||||
// Эндпоинт для проверки состояния
|
// Эндпоинт для проверки состояния
|
||||||
app.get('/api/health', async (req, res) => {
|
app.get('/api/health', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Проверяем подключение к БД
|
const healthStatus = {
|
||||||
await db.getQuery('SELECT NOW()');
|
|
||||||
|
|
||||||
// Проверяем AI сервис
|
|
||||||
const aiStatus = await aiAssistant.checkHealth();
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
database: 'connected',
|
services: {}
|
||||||
ai: aiStatus,
|
};
|
||||||
});
|
|
||||||
|
// Проверяем подключение к БД
|
||||||
|
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) {
|
} catch (error) {
|
||||||
logger.error('Health check failed:', error);
|
logger.error('Health check failed:', error);
|
||||||
res.status(500).json({
|
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
|
// Добавляем методы из vectorStore.js
|
||||||
async initVectorStore() {
|
async initVectorStore() {
|
||||||
// ... код инициализации ...
|
// ... код инициализации ...
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
const { HNSWLib } = require('@langchain/community/vectorstores/hnswlib');
|
|
||||||
const db = require('../db');
|
const db = require('../db');
|
||||||
const { ChatOllama } = require('@langchain/ollama');
|
const vectorSearch = require('./vectorSearchClient');
|
||||||
const { OllamaEmbeddings } = require('@langchain/ollama');
|
|
||||||
const { getProviderSettings } = require('./aiProviderSettingsService');
|
const { getProviderSettings } = require('./aiProviderSettingsService');
|
||||||
const { OpenAIEmbeddings } = require('@langchain/openai');
|
|
||||||
|
|
||||||
console.log('[RAG] ragService.js loaded');
|
console.log('[RAG] ragService.js loaded');
|
||||||
|
|
||||||
async function getTableData(tableId) {
|
async function getTableData(tableId) {
|
||||||
const columns = (await db.getQuery()('SELECT * FROM user_columns WHERE table_id = $1', [tableId])).rows;
|
const columns = (await db.query('SELECT * FROM user_columns WHERE table_id = $1', [tableId])).rows;
|
||||||
console.log('RAG getTableData: columns:', columns);
|
const rows = (await db.query('SELECT * FROM user_rows WHERE table_id = $1', [tableId])).rows;
|
||||||
const rows = (await db.getQuery()('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 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 getColId = purpose => columns.find(col => col.options?.purpose === purpose)?.id;
|
const getColId = purpose => columns.find(col => col.options?.purpose === purpose)?.id;
|
||||||
const questionColId = getColId('question');
|
const questionColId = getColId('question');
|
||||||
@@ -35,147 +31,142 @@ async function getTableData(tableId) {
|
|||||||
date: cells.find(c => c.column_id === dateColId)?.value,
|
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;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getEmbeddingsProvider(providerName = 'ollama') {
|
async function ragAnswer({ tableId, userQuestion, userTags = [], product = null, threshold = 0.3 }) {
|
||||||
const settings = await getProviderSettings(providerName);
|
console.log(`[RAG] ragAnswer called: tableId=${tableId}, userQuestion="${userQuestion}"`);
|
||||||
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, embeddingsProvider = 'ollama', threshold = 0.3 }) {
|
|
||||||
console.log('[RAG] Используется провайдер эмбеддингов:', embeddingsProvider);
|
|
||||||
const data = await getTableData(tableId);
|
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);
|
const questions = data.map(row => row.question && typeof row.question === 'string' ? row.question.trim() : row.question);
|
||||||
|
const rowsForUpsert = data.map(row => ({
|
||||||
// Получаем embeddings-инстанс динамически
|
row_id: row.id,
|
||||||
const embeddingsInstance = await getEmbeddingsProvider(embeddingsProvider);
|
text: row.question,
|
||||||
|
metadata: {
|
||||||
// Получаем embedding для всех вопросов
|
answer: row.answer || null,
|
||||||
const embeddings = await embeddingsInstance.embedDocuments(questions);
|
userTags: row.userTags || null,
|
||||||
console.log('Questions embedding length:', embeddings[0]?.length, 'Total questions:', questions.length);
|
context: row.context || null,
|
||||||
|
product: row.product || null,
|
||||||
// Получаем embedding для вопроса пользователя (trim)
|
priority: row.priority || null,
|
||||||
const userQuestionTrimmed = userQuestion && typeof userQuestion === 'string' ? userQuestion.trim() : userQuestion;
|
date: row.date || null
|
||||||
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,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Создаём векторное хранилище
|
console.log(`[RAG] Prepared ${rowsForUpsert.length} rows for upsert`);
|
||||||
const vectorStore = await HNSWLib.fromTexts(questions, metadatas, embeddingsInstance);
|
console.log(`[RAG] First row:`, rowsForUpsert[0]);
|
||||||
|
|
||||||
// Ищем наиболее похожие вопросы (top-3)
|
// Upsert все вопросы в индекс (можно оптимизировать по изменению)
|
||||||
const results = await vectorStore.similaritySearchVectorWithScore(userEmbedding, 3);
|
if (rowsForUpsert.length > 0) {
|
||||||
console.log('[RAG] Результаты поиска по векторам (score):', results.map(([doc, score]) => ({ ...doc.metadata, score })));
|
await vectorSearch.upsert(tableId, rowsForUpsert);
|
||||||
|
console.log(`[RAG] Upsert completed`);
|
||||||
// Фильтруем по тегам/продукту, если нужно
|
} else {
|
||||||
let filtered = results.map(([doc, score]) => ({ ...doc.metadata, score }));
|
console.log(`[RAG] No rows to upsert, skipping`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поиск
|
||||||
|
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) {
|
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) {
|
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);
|
const best = filtered.find(row => row.score >= threshold);
|
||||||
console.log(`[RAG] Выбранный ответ (порог ${threshold}):`, best);
|
console.log(`[RAG] Best result:`, best);
|
||||||
|
|
||||||
// Формируем ответ
|
|
||||||
return {
|
return {
|
||||||
answer: best?.answer,
|
answer: best?.metadata?.answer,
|
||||||
context: best?.context,
|
context: best?.metadata?.context,
|
||||||
product: best?.product,
|
product: best?.metadata?.product,
|
||||||
priority: best?.priority,
|
priority: best?.metadata?.priority,
|
||||||
date: best?.date,
|
date: best?.metadata?.date,
|
||||||
score: best?.score,
|
score: best?.score,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateLLMResponse({ userQuestion, context, clarifyingAnswer, objectionAnswer, answer, systemPrompt, userTags, product, priority, date, rules, history, model, language }) {
|
async function generateLLMResponse({
|
||||||
// Подставляем значения в шаблон промта
|
userQuestion,
|
||||||
let prompt = (systemPrompt || '')
|
context,
|
||||||
.replace('{context}', context || '')
|
clarifyingAnswer,
|
||||||
.replace('{clarifyingAnswer}', clarifyingAnswer || '')
|
objectionAnswer,
|
||||||
.replace('{objectionAnswer}', objectionAnswer || '')
|
answer,
|
||||||
.replace('{answer}', answer || '')
|
systemPrompt,
|
||||||
.replace('{question}', userQuestion || '')
|
userTags,
|
||||||
.replace('{userTags}', userTags || '')
|
product,
|
||||||
.replace('{product}', product || '')
|
priority,
|
||||||
.replace('{priority}', priority || '')
|
date,
|
||||||
.replace('{date}', date || '')
|
rules,
|
||||||
.replace('{rules}', rules || '')
|
history,
|
||||||
.replace('{history}', history || '')
|
model,
|
||||||
.replace('{model}', model || '')
|
language
|
||||||
.replace('{language}', language || '');
|
}) {
|
||||||
|
console.log(`[RAG] generateLLMResponse called with:`, {
|
||||||
const chat = new ChatOllama({
|
userQuestion,
|
||||||
baseUrl: process.env.OLLAMA_BASE_URL || 'http://localhost:11434',
|
context,
|
||||||
model: process.env.OLLAMA_MODEL || 'qwen2.5',
|
answer,
|
||||||
system: prompt,
|
systemPrompt,
|
||||||
temperature: 0.7,
|
userTags,
|
||||||
maxTokens: 1000,
|
product,
|
||||||
timeout: 30000,
|
priority,
|
||||||
|
date,
|
||||||
|
model,
|
||||||
|
language
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await chat.invoke(`Вопрос пользователя: ${userQuestion}`);
|
try {
|
||||||
return response.content;
|
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:
|
ports:
|
||||||
- '11434:11434'
|
- '11434:11434'
|
||||||
command: serve
|
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:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
@@ -54,6 +76,8 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ollama:
|
ollama:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
vector-search:
|
||||||
|
condition: service_started
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- ./frontend/dist:/app/frontend_dist:ro
|
- ./frontend/dist:/app/frontend_dist:ro
|
||||||
@@ -73,6 +97,7 @@ services:
|
|||||||
- OLLAMA_MODEL=${OLLAMA_MODEL:-qwen2.5:7b}
|
- OLLAMA_MODEL=${OLLAMA_MODEL:-qwen2.5:7b}
|
||||||
- OLLAMA_EMBEDDINGS_MODEL=${OLLAMA_EMBEDDINGS_MODEL:-qwen2.5:7b}
|
- OLLAMA_EMBEDDINGS_MODEL=${OLLAMA_EMBEDDINGS_MODEL:-qwen2.5:7b}
|
||||||
- FRONTEND_URL=http://localhost:5173
|
- FRONTEND_URL=http://localhost:5173
|
||||||
|
- VECTOR_SEARCH_URL=http://vector-search:8001
|
||||||
ports:
|
ports:
|
||||||
- '8000:8000'
|
- '8000:8000'
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
@@ -127,3 +152,4 @@ volumes:
|
|||||||
ollama_data: null
|
ollama_data: null
|
||||||
backend_node_modules: null
|
backend_node_modules: null
|
||||||
frontend_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;
|
border-radius: 16px;
|
||||||
box-shadow: 0 4px 32px rgba(0,0,0,0.12);
|
box-shadow: 0 4px 32px rgba(0,0,0,0.12);
|
||||||
padding: 32px 24px 24px 24px;
|
padding: 32px 24px 24px 24px;
|
||||||
width: 100vw;
|
max-width: 1200px;
|
||||||
max-width: none;
|
margin: 32px auto 0 auto;
|
||||||
margin: 0;
|
width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
.close-btn {
|
.close-btn {
|
||||||
|
|||||||
@@ -1,35 +1,35 @@
|
|||||||
# План настройки и тестирования поиска по таблице RAG
|
# План настройки и тестирования поиска по таблице RAG
|
||||||
|
|
||||||
## 1. Подготовка таблицы RAG
|
## 1. Подготовка таблицы RAG
|
||||||
- Убедиться, что таблица RAG создана и содержит пары "вопрос-ответ".
|
- [x] Убедиться, что таблица RAG создана и содержит пары "вопрос-ответ".
|
||||||
- Добавить несколько тестовых записей через UI или напрямую в базу данных.
|
- [x] Добавить несколько тестовых записей через UI или напрямую в базу данных.
|
||||||
|
|
||||||
## 2. Настройка провайдера эмбеддингов
|
## 2. Настройка провайдера эмбеддингов
|
||||||
- В настройках ассистента выбрать нужного провайдера (OpenAI, Ollama и др.).
|
- [x] В настройках ассистента выбрать нужного провайдера (OpenAI, Ollama и др.).
|
||||||
- Ввести API-ключ и Base URL (например, для OpenAI: https://api.openai.com/v1).
|
- [x] Ввести API-ключ и Base URL (например, для OpenAI: https://api.openai.com/v1).
|
||||||
- Сохранить настройки.
|
- [x] Сохранить настройки.
|
||||||
|
|
||||||
## 3. Проверка настроек ассистента
|
## 3. Проверка настроек ассистента
|
||||||
- Убедиться, что выбран актуальный ID таблицы RAG.
|
- [x] Убедиться, что выбран актуальный ID таблицы RAG.
|
||||||
- Проверить выбранного провайдера эмбеддингов.
|
- [x] Проверить выбранного провайдера эмбеддингов.
|
||||||
- Установить порог релевантности (например, 0.95).
|
- [x] Установить порог релевантности (например, 0.95).
|
||||||
|
|
||||||
## 4. Проверка backend-логики
|
## 4. Проверка backend-логики
|
||||||
- Проверить, что в backend (например, в ragService.js) реализован поиск по RAG с использованием выбранного провайдера эмбеддингов.
|
- [x] Проверить, что в backend (например, в ragService.js) реализован поиск по RAG с использованием выбранного провайдера эмбеддингов.
|
||||||
- Убедиться, что используется актуальный ID таблицы и динамический выбор провайдера.
|
- [x] Убедиться, что используется актуальный ID таблицы и динамический выбор провайдера.
|
||||||
- Проверить возможность изменения порога релевантности.
|
- [x] Проверить возможность изменения порога релевантности.
|
||||||
|
|
||||||
## 5. Тестирование через UI
|
## 5. Тестирование через UI
|
||||||
- Отправить ассистенту вопрос, который есть в RAG-таблице — убедиться, что ответ возвращается из базы.
|
- [ ] Отправить ассистенту вопрос, который есть в RAG-таблице — убедиться, что ответ возвращается из базы.
|
||||||
- Отправить вопрос, которого нет в таблице — убедиться, что ассистент либо не отвечает, либо использует LLM (по настройкам).
|
- [ ] Отправить вопрос, которого нет в таблице — убедиться, что ассистент либо не отвечает, либо использует LLM (по настройкам).
|
||||||
|
|
||||||
## 6. Проверка логов backend
|
## 6. Проверка логов backend
|
||||||
- Проверить логи на наличие сообщений о поиске по RAG, найденных совпадениях и выбранном провайдере эмбеддингов.
|
- [ ] Проверить логи на наличие сообщений о поиске по RAG, найденных совпадениях и выбранном провайдере эмбеддингов.
|
||||||
- В случае ошибок — проанализировать и устранить их.
|
- [ ] В случае ошибок — проанализировать и устранить их.
|
||||||
|
|
||||||
## 7. Тестирование через API (опционально)
|
## 7. Тестирование через API (опционально)
|
||||||
- Использовать Postman/curl для отправки запросов напрямую к backend.
|
- [ ] Использовать Postman/curl для отправки запросов напрямую к backend.
|
||||||
- Пример запроса:
|
- [ ] Пример запроса:
|
||||||
```http
|
```http
|
||||||
POST /api/chat/message
|
POST /api/chat/message
|
||||||
{
|
{
|
||||||
@@ -39,13 +39,32 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
## 8. Автоматизация тестирования (по желанию)
|
## 8. Автоматизация тестирования (по желанию)
|
||||||
- Написать автотесты (например, на Mocha/Jest), которые будут отправлять вопросы и сверять ответы с ожидаемыми из RAG.
|
- [ ] Написать автотесты (например, на Mocha/Jest), которые будут отправлять вопросы и сверять ответы с ожидаемыми из RAG.
|
||||||
|
|
||||||
## 9. Рекомендации
|
## 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