From 972553dbb1f33f0630bd7a8b319f990bca09d670 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 3 Jul 2025 21:54:00 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B2=D0=B0=D1=88=D0=B5=20=D1=81=D0=BE=D0=BE?= =?UTF-8?q?=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=BC?= =?UTF-8?q?=D0=B8=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Dockerfile | 33 +- backend/app.js | 52 ++- backend/routes/monitoring.js | 38 ++ backend/services/ai-assistant.js | 23 ++ backend/services/ragService.js | 253 ++++++------ backend/services/vectorSearchClient.js | 69 ++++ backend/tests/ragService.test.js | 89 ++++ backend/tests/ragServiceFull.test.js | 163 ++++++++ backend/tests/vectorSearchClient.test.js | 35 ++ docker-compose.yml | 26 ++ .../ai-assistant/SystemMonitoring.vue | 380 ++++++++++++++++++ .../views/settings/AI/AiAssistantSettings.vue | 8 +- md/RAG_SEARCH_SETUP_AND_TEST.md | 61 ++- md/vector-search-service.md | 108 +++++ vector-search/.gitignore | 5 + vector-search/Dockerfile | 7 + vector-search/README.md | 25 ++ vector-search/app.py | 128 ++++++ vector-search/requirements.txt | 5 + vector-search/schemas.py | 1 + vector-search/vector_store.py | 102 +++++ 21 files changed, 1435 insertions(+), 176 deletions(-) create mode 100644 backend/routes/monitoring.js create mode 100644 backend/services/vectorSearchClient.js create mode 100644 backend/tests/ragService.test.js create mode 100644 backend/tests/ragServiceFull.test.js create mode 100644 backend/tests/vectorSearchClient.test.js create mode 100644 frontend/src/components/ai-assistant/SystemMonitoring.vue create mode 100644 md/vector-search-service.md create mode 100644 vector-search/.gitignore create mode 100644 vector-search/Dockerfile create mode 100644 vector-search/README.md create mode 100644 vector-search/app.py create mode 100644 vector-search/requirements.txt create mode 100644 vector-search/schemas.py create mode 100644 vector-search/vector_store.py diff --git a/backend/Dockerfile b/backend/Dockerfile index 491727b..403675d 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 . . diff --git a/backend/app.js b/backend/app.js index e37115a..9cf3245 100644 --- a/backend/app.js +++ b/backend/app.js @@ -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({ diff --git a/backend/routes/monitoring.js b/backend/routes/monitoring.js new file mode 100644 index 0000000..fbfe511 --- /dev/null +++ b/backend/routes/monitoring.js @@ -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; \ No newline at end of file diff --git a/backend/services/ai-assistant.js b/backend/services/ai-assistant.js index 05c6d32..39ea83e 100644 --- a/backend/services/ai-assistant.js +++ b/backend/services/ai-assistant.js @@ -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() { // ... код инициализации ... diff --git a/backend/services/ragService.js b/backend/services/ragService.js index f65fda2..0e0654f 100644 --- a/backend/services/ragService.js +++ b/backend/services/ragService.js @@ -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, embeddingsProvider = 'ollama', threshold = 0.3 }) { - console.log('[RAG] Используется провайдер эмбеддингов:', embeddingsProvider); +async function ragAnswer({ tableId, userQuestion, userTags = [], product = null, threshold = 0.3 }) { + console.log(`[RAG] ragAnswer called: tableId=${tableId}, userQuestion="${userQuestion}"`); + 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); - - // Ищем наиболее похожие вопросы (top-3) - const results = await vectorStore.similaritySearchVectorWithScore(userEmbedding, 3); - console.log('[RAG] Результаты поиска по векторам (score):', results.map(([doc, score]) => ({ ...doc.metadata, score }))); - - // Фильтруем по тегам/продукту, если нужно - let filtered = results.map(([doc, score]) => ({ ...doc.metadata, score })); + + console.log(`[RAG] Prepared ${rowsForUpsert.length} rows for upsert`); + console.log(`[RAG] First row:`, rowsForUpsert[0]); + + // 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 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 }; \ No newline at end of file +module.exports = { + ragAnswer, + getTableData, + generateLLMResponse +}; \ No newline at end of file diff --git a/backend/services/vectorSearchClient.js b/backend/services/vectorSearchClient.js new file mode 100644 index 0000000..38ac537 --- /dev/null +++ b/backend/services/vectorSearchClient.js @@ -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 +}; \ No newline at end of file diff --git a/backend/tests/ragService.test.js b/backend/tests/ragService.test.js new file mode 100644 index 0000000..f072c51 --- /dev/null +++ b/backend/tests/ragService.test.js @@ -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); + }); +}); \ No newline at end of file diff --git a/backend/tests/ragServiceFull.test.js b/backend/tests/ragServiceFull.test.js new file mode 100644 index 0000000..0934575 --- /dev/null +++ b/backend/tests/ragServiceFull.test.js @@ -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}`); + } + }); +}); \ No newline at end of file diff --git a/backend/tests/vectorSearchClient.test.js b/backend/tests/vectorSearchClient.test.js new file mode 100644 index 0000000..5f3256c --- /dev/null +++ b/backend/tests/vectorSearchClient.test.js @@ -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('Москва'); + }); +}); \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 9bc4d69..52de142 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/src/components/ai-assistant/SystemMonitoring.vue b/frontend/src/components/ai-assistant/SystemMonitoring.vue new file mode 100644 index 0000000..b9a7ff1 --- /dev/null +++ b/frontend/src/components/ai-assistant/SystemMonitoring.vue @@ -0,0 +1,380 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/settings/AI/AiAssistantSettings.vue b/frontend/src/views/settings/AI/AiAssistantSettings.vue index ed97993..5782157 100644 --- a/frontend/src/views/settings/AI/AiAssistantSettings.vue +++ b/frontend/src/views/settings/AI/AiAssistantSettings.vue @@ -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 { diff --git a/md/RAG_SEARCH_SETUP_AND_TEST.md b/md/RAG_SEARCH_SETUP_AND_TEST.md index b79e037..197ef9e 100644 --- a/md/RAG_SEARCH_SETUP_AND_TEST.md +++ b/md/RAG_SEARCH_SETUP_AND_TEST.md @@ -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 и перенос логики поиска. + **Если потребуется пример кода или помощь с конкретной реализацией — обращайтесь!** \ No newline at end of file diff --git a/md/vector-search-service.md b/md/vector-search-service.md new file mode 100644 index 0000000..e849fee --- /dev/null +++ b/md/vector-search-service.md @@ -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 +``` \ No newline at end of file diff --git a/vector-search/.gitignore b/vector-search/.gitignore new file mode 100644 index 0000000..9b7e7ab --- /dev/null +++ b/vector-search/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +*.pkl +*.faiss +.env \ No newline at end of file diff --git a/vector-search/Dockerfile b/vector-search/Dockerfile new file mode 100644 index 0000000..1e88369 --- /dev/null +++ b/vector-search/Dockerfile @@ -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"] \ No newline at end of file diff --git a/vector-search/README.md b/vector-search/README.md new file mode 100644 index 0000000..269fa26 --- /dev/null +++ b/vector-search/README.md @@ -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": {}}]}' +``` \ No newline at end of file diff --git a/vector-search/app.py b/vector-search/app.py new file mode 100644 index 0000000..8d52827 --- /dev/null +++ b/vector-search/app.py @@ -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"} \ No newline at end of file diff --git a/vector-search/requirements.txt b/vector-search/requirements.txt new file mode 100644 index 0000000..0478b8f --- /dev/null +++ b/vector-search/requirements.txt @@ -0,0 +1,5 @@ +fastapi +uvicorn +faiss-cpu +requests +pydantic \ No newline at end of file diff --git a/vector-search/schemas.py b/vector-search/schemas.py new file mode 100644 index 0000000..d70a263 --- /dev/null +++ b/vector-search/schemas.py @@ -0,0 +1 @@ +# Здесь можно разместить расширенные схемы Pydantic для API \ No newline at end of file diff --git a/vector-search/vector_store.py b/vector-search/vector_store.py new file mode 100644 index 0000000..e9736eb --- /dev/null +++ b/vector-search/vector_store.py @@ -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) \ No newline at end of file