829 lines
29 KiB
JavaScript
829 lines
29 KiB
JavaScript
/**
|
||
* 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(/ /gi, ' ')
|
||
.replace(/&/gi, '&')
|
||
.replace(/"/gi, '"')
|
||
.replace(/'/gi, "'")
|
||
.replace(/</gi, '<')
|
||
.replace(/>/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;
|
||
|