Files
DLE/backend/services/multiSourceSearchService.js
2026-03-01 22:03:48 +03:00

829 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Copyright (c) 2024-2026 Тарабанов Александр Викторович
* 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;