ваше сообщение коммита

This commit is contained in:
2025-07-03 21:54:00 +03:00
parent 11cf0ea353
commit 972553dbb1
21 changed files with 1435 additions and 176 deletions

View File

@@ -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 . .

View File

@@ -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({

View 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;

View File

@@ -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() {
// ... код инициализации ... // ... код инициализации ...

View File

@@ -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 {
console.log(`[RAG] No rows to upsert, skipping`);
}
// Фильтруем по тегам/продукту, если нужно // Поиск
let filtered = results.map(([doc, score]) => ({ ...doc.metadata, score })); let results = [];
if (rowsForUpsert.length > 0) {
results = await vectorSearch.search(tableId, userQuestion, 3);
console.log(`[RAG] Search completed, got ${results.length} results`);
} else {
console.log(`[RAG] No data in table, skipping search`);
}
// Фильтрация по тегам/продукту
let filtered = results;
if (userTags.length) { 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
};

View 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
};

View 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);
});
});

View 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}`);
}
});
});

View 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('Москва');
});
});

View File

@@ -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

View 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>

View File

@@ -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 {

View File

@@ -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
View 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
View File

@@ -0,0 +1,5 @@
__pycache__/
*.pyc
*.pkl
*.faiss
.env

7
vector-search/Dockerfile Normal file
View 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
View 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
View 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"}

View File

@@ -0,0 +1,5 @@
fastapi
uvicorn
faiss-cpu
requests
pydantic

1
vector-search/schemas.py Normal file
View File

@@ -0,0 +1 @@
# Здесь можно разместить расширенные схемы Pydantic для API

View 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)