feat: новая функция
This commit is contained in:
828
backend/services/multiSourceSearchService.js
Normal file
828
backend/services/multiSourceSearchService.js
Normal 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(/ /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;
|
||||
|
||||
Reference in New Issue
Block a user