feat: новая функция

This commit is contained in:
2025-11-06 16:24:50 +03:00
parent b3620b264b
commit 714a3f55c7
34 changed files with 5436 additions and 2433 deletions

View File

@@ -0,0 +1,828 @@
/**
* Copyright (c) 2024-2025 Тарабанов Александр Викторович
* All rights reserved.
*
* This software is proprietary and confidential.
* Unauthorized copying, modification, or distribution is prohibited.
*
* For licensing inquiries: info@hb3-accelerator.com
* Website: https://hb3-accelerator.com
* GitHub: https://github.com/VC-HB3-Accelerator
*/
const vectorSearch = require('./vectorSearchClient');
const ragService = require('./ragService');
const aiConfigService = require('./aiConfigService');
const userContextService = require('./userContextService');
const encryptedDb = require('./encryptedDatabaseService');
const db = require('../db');
const logger = require('../utils/logger');
const DOCUMENT_SNIPPET_LENGTH = 350;
function resolveDocumentIdFromResult(result) {
if (!result) {
return null;
}
const metadata = result.metadata || {};
const candidates = [metadata.doc_id, metadata.parent_doc_id];
for (const value of candidates) {
const parsed = parseInt(value, 10);
if (!Number.isNaN(parsed)) {
return parsed;
}
}
if (typeof result.row_id === 'string') {
const match = result.row_id.match(/^(\d+)(?:_chunk_\d+)?$/);
if (match) {
const parsed = parseInt(match[1], 10);
if (!Number.isNaN(parsed)) {
return parsed;
}
}
}
if (typeof result.row_id === 'number' && Number.isFinite(result.row_id)) {
return result.row_id;
}
return null;
}
function extractPlainText(content, format = 'text') {
if (!content || typeof content !== 'string') {
return '';
}
let plain = content;
if (format === 'html' || /<[^>]+>/.test(content)) {
plain = plain
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, ' ')
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, ' ')
.replace(/<[^>]+>/g, ' ');
}
plain = plain
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/\s+/g, ' ')
.trim();
return plain;
}
function buildSnippet(text, maxLength = DOCUMENT_SNIPPET_LENGTH) {
if (!text || typeof text !== 'string') {
return '';
}
const normalized = text.replace(/\s+/g, ' ').trim();
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, maxLength)}...`;
}
/**
* Сервис для параллельного поиска в таблицах и документах
* с комбинацией нескольких методов анализа
*/
class MultiSourceSearchService {
constructor() {
this.searchMethods = {
semantic: 'semantic',
keyword: 'keyword',
hybrid: 'hybrid'
};
}
/**
* Основной метод поиска в нескольких источниках параллельно
* @param {Object} options - Опции поиска
* @param {string} options.query - Поисковый запрос
* @param {Array<number>} options.tableIds - ID таблиц для поиска
* @param {boolean} options.searchInDocuments - Поиск в документах (legal_docs)
* @param {string} options.searchMethod - Метод поиска: 'semantic', 'keyword', 'hybrid'
* @param {number} options.userId - ID пользователя (для фильтрации по тегам)
* @param {number} options.maxResultsPerSource - Максимум результатов из каждого источника
* @param {number} options.totalMaxResults - Максимум результатов всего
* @returns {Promise<Object>} Объединенные результаты поиска
*/
async search({
query,
tableIds = [],
searchInDocuments = true,
searchMethod = 'hybrid',
userId = null,
maxResultsPerSource = 10,
totalMaxResults = 20
}) {
logger.info(`[MultiSourceSearch] Поиск: query="${query}", tableIds=${tableIds.join(',')}, searchInDocuments=${searchInDocuments}, method=${searchMethod}`);
try {
// Загружаем настройки RAG
const ragConfig = await aiConfigService.getRAGConfig();
const finalMaxResults = maxResultsPerSource || ragConfig.maxResults || 10;
// Параллельно запускаем поиск в разных источниках
const searchPromises = [];
// 1. Поиск в таблицах (user_tables)
if (tableIds && tableIds.length > 0) {
for (const tableId of tableIds) {
searchPromises.push(
this.searchInTable({
tableId,
query,
searchMethod,
userId,
maxResults: finalMaxResults
}).catch(err => {
logger.error(`[MultiSourceSearch] Ошибка поиска в таблице ${tableId}:`, err.message);
return { source: 'table', tableId, results: [], error: err.message };
})
);
}
}
// 2. Поиск в документах (legal_docs)
if (searchInDocuments) {
searchPromises.push(
this.searchInDocuments({
query,
searchMethod,
maxResults: finalMaxResults
}).catch(err => {
logger.error(`[MultiSourceSearch] Ошибка поиска в документах:`, err.message);
return { source: 'documents', results: [], error: err.message };
})
);
}
// Ждем результаты из всех источников
logger.info(`[MultiSourceSearch] Ожидание результатов из ${searchPromises.length} источников...`);
const promiseStartTime = Date.now();
const searchResults = await Promise.all(searchPromises);
const promiseDuration = Date.now() - promiseStartTime;
logger.info(`[MultiSourceSearch] Получены результаты из всех источников за ${promiseDuration}ms, всего: ${searchResults.length}`);
// Объединяем результаты
logger.info(`[MultiSourceSearch] Объединение результатов...`);
const mergedResults = this.mergeResults(searchResults, {
totalMaxResults,
searchMethod
});
logger.info(`[MultiSourceSearch] Найдено результатов: ${mergedResults.results.length} из ${searchResults.length} источников`);
return mergedResults;
} catch (error) {
logger.error(`[MultiSourceSearch] Ошибка поиска:`, error);
throw error;
}
}
/**
* Поиск в конкретной таблице
* @param {Object} options - Опции поиска
* @returns {Promise<Object>} Результаты поиска
*/
async searchInTable({
tableId,
query,
searchMethod,
userId,
maxResults
}) {
logger.info(`[MultiSourceSearch] Поиск в таблице ${tableId}, метод: ${searchMethod}`);
const startTime = Date.now();
let result;
switch (searchMethod) {
case this.searchMethods.semantic:
result = await this.semanticSearchInTable(tableId, query, userId, maxResults);
break;
case this.searchMethods.keyword:
result = await this.keywordSearchInTable(tableId, query, userId, maxResults);
break;
case this.searchMethods.hybrid:
default:
result = await this.hybridSearchInTable(tableId, query, userId, maxResults);
break;
}
const duration = Date.now() - startTime;
logger.info(`[MultiSourceSearch] Поиск в таблице ${tableId} завершен за ${duration}ms, найдено: ${result?.results?.length || 0} результатов`);
return result;
}
/**
* Семантический поиск в таблице (векторный)
*/
async semanticSearchInTable(tableId, query, userId, maxResults) {
try {
// Используем векторный поиск напрямую для получения нескольких результатов
const vectorResults = await vectorSearch.search(tableId, query, maxResults);
// Получаем данные таблицы для формирования полных результатов
const encryptionUtils = require('../utils/encryptionUtils');
const encryptionKey = encryptionUtils.getEncryptionKey();
const db = require('../db');
// Фильтруем по тегам пользователя, если указан
let filteredRowIds = null;
if (userId) {
// Используем логику из ragService для фильтрации
const userTagIds = await userContextService.getUserTags(userId);
if (userTagIds && userTagIds.length > 0) {
const columns = await encryptedDb.getData('user_columns', { table_id: tableId });
const tagsColumn = columns.find(col =>
col.options?.purpose === 'userTags' &&
(col.type === 'multiselect-relation' || col.type === 'relation')
);
if (tagsColumn) {
const result = await db.getQuery()(`
SELECT DISTINCT from_row_id
FROM user_table_relations
WHERE column_id = $1
AND to_row_id = ANY($2)
`, [tagsColumn.id, userTagIds]);
filteredRowIds = result.rows.map(row => row.from_row_id);
}
}
}
// Формируем результаты
const results = [];
for (const vectorResult of vectorResults) {
const rowId = parseInt(vectorResult.row_id);
// Пропускаем, если фильтрация по тегам и строка не подходит
if (filteredRowIds !== null && filteredRowIds.length > 0 && !filteredRowIds.includes(rowId)) {
continue;
}
results.push({
source: 'table',
sourceId: tableId,
rowId: rowId,
text: vectorResult.metadata?.answer || vectorResult.metadata?.text || '',
context: vectorResult.metadata?.context || '',
score: vectorResult.score || 0,
metadata: {
answer: vectorResult.metadata?.answer,
context: vectorResult.metadata?.context,
product: vectorResult.metadata?.product,
priority: vectorResult.metadata?.priority,
date: vectorResult.metadata?.date,
userTags: vectorResult.metadata?.userTags
}
});
}
return {
source: 'table',
tableId,
method: 'semantic',
results,
count: results.length
};
} catch (error) {
logger.error(`[MultiSourceSearch] Ошибка семантического поиска в таблице ${tableId}:`, error);
return {
source: 'table',
tableId,
method: 'semantic',
results: [],
count: 0,
error: error.message
};
}
}
/**
* Поиск по ключевым словам в таблице
*/
async keywordSearchInTable(tableId, query, userId, maxResults) {
// Извлекаем ключевые слова из запроса
const keywords = this.extractKeywords(query);
if (keywords.length === 0) {
return {
source: 'table',
tableId,
method: 'keyword',
results: [],
count: 0
};
}
// Используем RAG сервис для получения данных таблицы
const encryptedDb = require('./encryptedDatabaseService');
const encryptionUtils = require('../utils/encryptionUtils');
const encryptionKey = encryptionUtils.getEncryptionKey();
const db = require('../db');
// Получаем строки таблицы
const rowsResult = await db.getQuery()(
'SELECT id FROM user_rows WHERE table_id = $1',
[tableId]
);
const rows = rowsResult.rows;
// Фильтруем по тегам пользователя, если указан
let filteredRowIds = rows.map(r => r.id);
if (userId) {
const userTagIds = await userContextService.getUserTags(userId);
if (userTagIds && userTagIds.length > 0) {
// Получаем строки с тегами пользователя через user_table_relations
const columns = await encryptedDb.getData('user_columns', { table_id: tableId });
const tagsColumn = columns.find(col =>
col.options?.purpose === 'userTags' &&
(col.type === 'multiselect-relation' || col.type === 'relation')
);
if (tagsColumn) {
const filteredRowsResult = await db.getQuery()(
`SELECT DISTINCT from_row_id as id
FROM user_table_relations
WHERE column_id = $1
AND to_row_id = ANY($2)`,
[tagsColumn.id, userTagIds]
);
filteredRowIds = filteredRowsResult.rows.map(r => r.id);
}
}
}
// Получаем данные ячеек для каждой строки
const results = [];
for (const rowId of filteredRowIds) {
const cellsResult = await db.getQuery()(
`SELECT
uc.id as column_id,
decrypt_text(uc.name_encrypted, $1) as column_name,
decrypt_text(ucv.value_encrypted, $1) as value
FROM user_cell_values ucv
JOIN user_columns uc ON uc.id = ucv.column_id
WHERE ucv.row_id = $2 AND uc.table_id = $3`,
[encryptionKey, rowId, tableId]
);
const cells = cellsResult.rows;
// Формируем текст строки из всех ячеек
const rowText = cells
.map(cell => `${cell.column_name}: ${cell.value}`)
.join(' ');
// Вычисляем совпадение по ключевым словам
const matchScore = this.calculateKeywordMatch(rowText, keywords);
if (matchScore > 0) {
// Ищем столбец с ответом (purpose: 'answer')
// Получаем столбцы для проверки purpose
const columns = await encryptedDb.getData('user_columns', { table_id: tableId });
const answerColumn = columns.find(col => col.options?.purpose === 'answer');
const answerColumnId = answerColumn ? answerColumn.id : null;
const answerCell = answerColumnId
? cells.find(c => c.column_id === answerColumnId)
: cells.find(c => {
// Fallback: ищем по названию
return c.column_name && (
c.column_name.toLowerCase().includes('ответ') ||
c.column_name.toLowerCase().includes('answer')
);
});
const questionColumn = columns.find(col => col.options?.purpose === 'question');
const questionColumnId = questionColumn ? questionColumn.id : null;
const questionCell = questionColumnId
? cells.find(c => c.column_id === questionColumnId)
: null;
results.push({
source: 'table',
sourceId: tableId,
rowId: rowId,
text: answerCell ? answerCell.value : rowText,
context: questionCell ? questionCell.value : rowText,
score: matchScore,
metadata: {
method: 'keyword',
keywords: keywords,
rowData: cells.reduce((acc, cell) => {
acc[cell.column_name] = cell.value;
return acc;
}, {})
}
});
}
}
// Сортируем по релевантности и берем топ-N
results.sort((a, b) => b.score - a.score);
const topResults = results.slice(0, maxResults);
return {
source: 'table',
tableId,
method: 'keyword',
results: topResults,
count: topResults.length
};
}
/**
* Гибридный поиск в таблице (семантический + ключевые слова)
*/
async hybridSearchInTable(tableId, query, userId, maxResults) {
// Параллельно выполняем оба типа поиска
const [semanticResults, keywordResults] = await Promise.all([
this.semanticSearchInTable(tableId, query, userId, maxResults * 2),
this.keywordSearchInTable(tableId, query, userId, maxResults * 2)
]);
// Объединяем результаты с весами
const semanticWeight = 0.7;
const keywordWeight = 0.3;
const combined = this.combineSearchResults(
semanticResults.results,
keywordResults.results,
semanticWeight,
keywordWeight
);
// Сортируем и берем топ-N
combined.sort((a, b) => b.combinedScore - a.combinedScore);
const topResults = combined.slice(0, maxResults);
return {
source: 'table',
tableId,
method: 'hybrid',
results: topResults.map(r => ({
...r,
score: r.combinedScore
})),
count: topResults.length,
semanticCount: semanticResults.count,
keywordCount: keywordResults.count
};
}
/**
* Поиск в документах (legal_docs)
*/
async searchInDocuments({
query,
searchMethod,
maxResults
}) {
logger.info(`[MultiSourceSearch] Поиск в документах, метод: ${searchMethod}`);
const startTime = Date.now();
const tableId = 'legal_docs';
try {
// Векторный поиск в документах
const vectorResults = await vectorSearch.search(tableId, query, maxResults * 2);
const documentIds = new Set();
for (const result of vectorResults) {
const docId = resolveDocumentIdFromResult(result);
if (docId !== null) {
documentIds.add(docId);
}
}
const documentSnippets = new Map();
if (documentIds.size > 0) {
const idsArray = Array.from(documentIds);
try {
const queryFn = db.getQuery();
const { rows } = await queryFn(
`SELECT id, content, format FROM admin_pages_simple WHERE id = ANY($1::int[])`,
[idsArray]
);
for (const row of rows) {
const snippet = buildSnippet(extractPlainText(row.content, row.format));
documentSnippets.set(String(row.id), snippet);
}
} catch (dbError) {
logger.warn(`[MultiSourceSearch] Не удалось загрузить содержимое документов: ${dbError.message}`);
}
}
// Формируем результаты
const results = vectorResults.map(result => {
const metadata = result.metadata || {};
const docId = resolveDocumentIdFromResult(result);
const docKey = docId !== null ? String(docId) : null;
const chunkText = buildSnippet(result.text || metadata.content || metadata.text || '');
const fallbackText = docKey ? documentSnippets.get(docKey) : '';
const finalText = chunkText || fallbackText || '';
const contextValue = metadata.title || metadata.section || '';
return {
source: 'document',
sourceId: tableId,
rowId: result.row_id,
text: finalText,
context: contextValue,
score: result.score || 0,
metadata: {
doc_id: metadata.doc_id || docId,
title: metadata.title,
url: metadata.url,
format: metadata.format,
visibility: metadata.visibility,
section: metadata.section,
chunk_index: metadata.chunk_index,
snippetSource: chunkText ? 'chunk' : (fallbackText ? 'document' : 'unknown')
}
};
});
// Если гибридный поиск, добавляем поиск по ключевым словам
if (searchMethod === this.searchMethods.hybrid) {
const keywordResults = await this.keywordSearchInDocuments(query, maxResults);
// Объединяем результаты
const combined = this.combineSearchResults(
results,
keywordResults,
0.7, // вес для семантического
0.3 // вес для ключевых слов
);
combined.sort((a, b) => b.combinedScore - a.combinedScore);
const topResults = combined.slice(0, maxResults);
return {
source: 'documents',
method: 'hybrid',
results: topResults.map(r => ({
...r,
score: r.combinedScore
})),
count: topResults.length
};
}
// Сортируем по релевантности
results.sort((a, b) => b.score - a.score);
const topResults = results.slice(0, maxResults);
const duration = Date.now() - startTime;
logger.info(`[MultiSourceSearch] Поиск в документах завершен за ${duration}ms, найдено: ${topResults.length} результатов`);
return {
source: 'documents',
method: searchMethod,
results: topResults,
count: topResults.length
};
} catch (error) {
const duration = Date.now() - startTime;
logger.error(`[MultiSourceSearch] Ошибка поиска в документах за ${duration}ms:`, error);
return {
source: 'documents',
method: searchMethod,
results: [],
count: 0,
error: error.message
};
}
}
/**
* Поиск по ключевым словам в документах
*/
async keywordSearchInDocuments(query, maxResults) {
// Для поиска по ключевым словам в документах нужно получить все документы
// и фильтровать по ключевым словам. Это может быть медленно для больших объемов.
// В будущем можно добавить индекс для ключевых слов.
const keywords = this.extractKeywords(query);
// Пока возвращаем пустой результат, т.к. для документов векторный поиск обычно достаточно
// Для полноценной реализации нужно добавить индекс ключевых слов
return [];
}
/**
* Объединение результатов из разных источников
*/
mergeResults(searchResults, options = {}) {
const { totalMaxResults = 20, searchMethod = 'hybrid' } = options;
const allResults = [];
// Собираем все результаты из всех источников
for (const searchResult of searchResults) {
if (searchResult.results && searchResult.results.length > 0) {
allResults.push(...searchResult.results.map(result => ({
...result,
sourceType: searchResult.source,
sourceId: searchResult.tableId || searchResult.sourceId
})));
}
}
// Удаляем дубликаты (по rowId и sourceType)
const uniqueResults = this.removeDuplicates(allResults);
// Сортируем по релевантности
uniqueResults.sort((a, b) => {
// Приоритет: таблицы > документы (можно настроить)
const sourcePriority = {
table: 1.0,
document: 0.9
};
const priorityA = sourcePriority[a.sourceType] || 0.8;
const priorityB = sourcePriority[b.sourceType] || 0.8;
// Комбинируем релевантность и приоритет источника
const scoreA = (a.score || 0) * priorityA;
const scoreB = (b.score || 0) * priorityB;
return scoreB - scoreA;
});
// Берем топ-N результатов
const topResults = uniqueResults.slice(0, totalMaxResults);
// Группируем по источникам для статистики
const sourcesStats = {};
for (const result of topResults) {
const sourceKey = `${result.sourceType}_${result.sourceId || 'unknown'}`;
if (!sourcesStats[sourceKey]) {
sourcesStats[sourceKey] = {
source: result.sourceType,
sourceId: result.sourceId,
count: 0
};
}
sourcesStats[sourceKey].count++;
}
return {
results: topResults,
totalCount: topResults.length,
sourcesCount: searchResults.length,
sourcesStats: Object.values(sourcesStats),
searchMethod
};
}
/**
* Объединение результатов семантического и ключевого поиска
*/
combineSearchResults(semanticResults, keywordResults, semanticWeight, keywordWeight) {
const combined = new Map();
// Нормализуем скоры для семантического поиска
const normalizedSemantic = this.normalizeScores(semanticResults);
// Нормализуем скоры для поиска по ключевым словам
const normalizedKeyword = this.normalizeScores(keywordResults);
// Добавляем результаты семантического поиска
normalizedSemantic.forEach(result => {
const key = `${result.source}_${result.sourceId}_${result.rowId || 'unknown'}`;
combined.set(key, {
...result,
semanticScore: result.score,
keywordScore: 0,
combinedScore: result.score * semanticWeight
});
});
// Добавляем результаты поиска по ключевым словам
normalizedKeyword.forEach(result => {
const key = `${result.source}_${result.sourceId}_${result.rowId || 'unknown'}`;
const existing = combined.get(key);
if (existing) {
// Объединяем скоры
existing.keywordScore = result.score;
existing.combinedScore = (existing.semanticScore * semanticWeight) + (result.score * keywordWeight);
} else {
// Новый результат
combined.set(key, {
...result,
semanticScore: 0,
keywordScore: result.score,
combinedScore: result.score * keywordWeight
});
}
});
return Array.from(combined.values());
}
/**
* Нормализация скоров (0-1)
*/
normalizeScores(results) {
if (results.length === 0) return [];
const scores = results.map(r => Math.abs(r.score || 0));
const maxScore = Math.max(...scores);
const minScore = Math.min(...scores);
const range = maxScore - minScore || 1;
return results.map(result => ({
...result,
score: range > 0 ? (Math.abs(result.score || 0) - minScore) / range : 0.5
}));
}
/**
* Извлечение ключевых слов из запроса
*/
extractKeywords(query) {
if (!query || typeof query !== 'string') return [];
// Удаляем стоп-слова
const stopWords = new Set([
'как', 'что', 'где', 'когда', 'почему', 'кто', 'куда', 'откуда',
'для', 'при', 'над', 'под', 'перед', 'после', 'через',
'и', 'или', 'но', 'а', 'да', 'нет', 'не',
'в', 'на', 'с', 'со', 'из', 'к', 'от', 'до', 'по', 'о', 'об', 'обо',
'это', 'этот', 'эта', 'эти', 'этот', 'тот', 'та', 'те', 'то',
'быть', 'есть', 'был', 'была', 'было', 'были'
]);
// Разбиваем на слова (сохраняем кириллицу и латиницу)
const words = query
.toLowerCase()
.replace(/[^\w\s\u0400-\u04FF]/g, ' ') // \u0400-\u04FF - диапазон кириллицы
.split(/\s+/)
.filter(word => word.length > 2 && !stopWords.has(word));
return words;
}
/**
* Расчет совпадения по ключевым словам
*/
calculateKeywordMatch(text, keywords) {
if (!text || !keywords || keywords.length === 0) return 0;
const textLower = text.toLowerCase();
let matchCount = 0;
for (const keyword of keywords) {
if (textLower.includes(keyword.toLowerCase())) {
matchCount++;
}
}
// Возвращаем процент совпадения
return keywords.length > 0 ? matchCount / keywords.length : 0;
}
/**
* Удаление дубликатов из результатов
*/
removeDuplicates(results) {
const seen = new Set();
const unique = [];
for (const result of results) {
const key = `${result.sourceType}_${result.sourceId}_${result.rowId || 'unknown'}`;
if (!seen.has(key)) {
seen.add(key);
unique.push(result);
}
}
return unique;
}
}
// Создаем singleton экземпляр
const multiSourceSearchService = new MultiSourceSearchService();
module.exports = multiSourceSearchService;