@xiaohui-wang/mcpadvisor
Version:
MCP Advisor & Installation - Find the right MCP server for your needs
484 lines (483 loc) • 21.3 kB
JavaScript
/**
* Search service for MCP servers
* Implements the search functionality with extensibility for different providers
*/
import { CompassSearchProvider } from './search/CompassSearchProvider.js';
import { GetMcpSearchProvider } from './search/GetMcpSearchProvider.js';
import { MeilisearchSearchProvider } from './search/MeilisearchSearchProvider.js';
import { OfflineSearchProvider } from './search/OfflineSearchProvider.js';
import logger from '../utils/logger.js';
/**
* 提供者优先级配置
*/
const PROVIDER_PRIORITIES = {
OfflineSearchProvider: 10, // 离线提供者优先级最高
GetMcpSearchProvider: 5,
CompassSearchProvider: 8,
MeilisearchSearchProvider: 9,
};
/**
* 默认搜索选项
*/
const DEFAULT_SEARCH_OPTIONS = {
limit: 5,
minSimilarity: 0.5,
};
/**
* 默认离线模式配置
*/
const DEFAULT_OFFLINE_CONFIG = {
enabled: true,
minSimilarity: 0.3,
};
/**
* Search service that can use multiple search providers
*/
export class SearchService {
providers;
/**
* Combine structured params to plain query string for legacy providers
*/
/**
* 离线搜索提供者
* 用于在网络不可用时提供兜底推荐
*/
offlineProvider;
/**
* 离线模式配置
*/
offlineConfig;
/**
* Create a new search service with the specified providers
* @param providers - Array of search providers to use
* @param offlineConfig - 离线模式配置,默认启用
*/
constructor(providers = [], offlineConfig = {}) {
this.providers = providers;
// 合并离线模式配置
this.offlineConfig = {
...DEFAULT_OFFLINE_CONFIG,
...offlineConfig,
};
// 如果启用了离线模式,初始化离线搜索提供者
if (this.offlineConfig.enabled) {
this.initOfflineProvider();
}
logger.info(`SearchService initialized with ${providers.length} providers`, {
providerCount: providers.length,
offlineMode: this.offlineConfig.enabled,
});
}
/**
* 初始化离线搜索提供者
*/
initOfflineProvider() {
try {
this.offlineProvider = new OfflineSearchProvider({
fallbackDataPath: this.offlineConfig.fallbackDataPath,
minSimilarity: this.offlineConfig.minSimilarity,
});
logger.info('Offline search provider initialized');
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.error(`Failed to initialize offline search provider: ${message}`, {
error,
});
}
}
/**
* Add a new search provider
* @param provider - The search provider to add
*/
addProvider(provider) {
this.providers.push(provider);
logger.info(`New provider added, total providers: ${this.providers.length}`);
}
/**
* Remove a search provider
* @param index - The index of the provider to remove
*/
removeProvider(index) {
if (index >= 0 && index < this.providers.length) {
this.providers.splice(index, 1);
logger.info(`Provider removed, total providers: ${this.providers.length}`);
}
else {
logger.warn(`Invalid provider index: ${index}`);
}
}
/**
* Get all current providers
* @returns Array of search providers
*/
getProviders() {
return [...this.providers];
}
async search(arg, options) {
// 解析参数
const params = typeof arg === 'string'
? { taskDescription: arg }
: arg;
if (this.providers.length === 0 && !this.offlineProvider) {
logger.warn('No search providers available');
return [];
}
try {
// 合并默认选项
const mergedOptions = { ...DEFAULT_SEARCH_OPTIONS, ...options };
logger.info(`Searching with ${this.providers.length} providers for task: ${params.taskDescription}`, 'SearchService', { providerCount: this.providers.length });
// 准备所有提供者的列表,包括离线提供者
const allProviders = [...this.providers];
let offlineProviderIndex = -1;
// 如果启用了离线模式,添加离线提供者
if (this.offlineConfig.enabled && this.offlineProvider) {
offlineProviderIndex = allProviders.length;
allProviders.push(this.offlineProvider);
}
// Collect results from all providers in parallel
const providerPromises = allProviders.map((provider, index) => {
const providerName = provider.constructor.name;
logger.info(`Starting search with provider ${index + 1}/${this.providers.length}: ${providerName}`, 'Provider', {
providerName,
providerIndex: index,
params,
});
return provider
.search(params)
.then(results => {
logger.info(`Provider ${providerName} returned ${results.length} results`, 'Provider', {
providerName,
resultCount: results.length,
topResults: results.slice(0, 3).map(r => ({
title: r.title,
similarity: r.similarity,
github_url: r.github_url,
})),
});
// Log full results at debug level
if (results.length > 0) {
logger.debug(`Full results from provider ${providerName}:`, 'Provider', {
providerName,
results,
});
}
return {
providerName,
results,
};
})
.catch(error => {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Provider ${providerName} search failed: ${errorMessage}`, 'Provider', {
providerName,
error: errorMessage,
});
return {
providerName,
results: [],
};
});
});
const namedProviderResults = await Promise.all(providerPromises);
// Log summary of results from each provider
namedProviderResults.forEach(({ providerName, results }) => {
logger.info(`Provider ${providerName} found ${results.length} results`, 'SearchSummary', {
providerName,
resultCount: results.length,
});
});
// 优化:使用 Map 直接构建去重结果,同时考虑提供者优先级
const resultsMap = new Map();
const duplicates = [];
// 处理每个提供者的结果
for (const { providerName, results } of namedProviderResults) {
// 获取提供者优先级
const priority = PROVIDER_PRIORITIES[providerName] || 0;
for (const result of results) {
// 为结果添加提供者优先级信息
const resultWithPriority = { ...result, providerPriority: priority };
// 如果 github_url 为空,使用标题作为键
const key = result.github_url || `title:${result.title}`;
if (!resultsMap.has(key)) {
// 新结果,直接添加
resultsMap.set(key, resultWithPriority);
}
else {
// 已存在结果,检查是否需要替换
const existingResult = resultsMap.get(key);
// 在相似度相同的情况下,优先级高的提供者的结果排在前面
if (existingResult.similarity < result.similarity ||
(existingResult.similarity === result.similarity &&
(existingResult.providerPriority || 0) < priority)) {
resultsMap.set(key, resultWithPriority);
}
// 记录重复项
duplicates.push(key);
}
}
}
// 转换为数组
let mergedResults = Array.from(resultsMap.values());
logger.info(`Merged ${mergedResults.length} total results from all providers`, 'SearchService', {
totalResults: mergedResults.length,
});
// Log deduplication results
logger.info(`Deduplication complete: removed ${duplicates.length} duplicates`, 'Deduplication', {
removedCount: duplicates.length,
remainingCount: mergedResults.length,
duplicateUrls: duplicates.length > 0 ? [...new Set(duplicates)] : undefined,
});
// Log pre-sorting information
logger.info(`Sorting ${mergedResults.length} results by similarity score`, 'Sorting');
// Sort by similarity score (highest first)
mergedResults.sort((a, b) => b.similarity - a.similarity);
// Log top results after sorting
if (mergedResults.length > 0) {
logger.info(`Top 3 results after sorting:`, 'Sorting', {
topResults: mergedResults.slice(0, 3).map(r => ({
title: r.title,
similarity: r.similarity,
github_url: r.github_url,
})),
});
}
// Apply filtering based on options
let filteredCount = 0;
let originalCount = mergedResults.length;
// 优化:简化相似度过滤逻辑
if (mergedOptions.minSimilarity !== undefined &&
mergedResults.length > 0) {
logger.info(`Applying minimum similarity filter: ${mergedOptions.minSimilarity}`, 'Filtering', {
minSimilarity: mergedOptions.minSimilarity,
beforeCount: mergedResults.length,
});
// 保存原始排序的结果,以便在过滤后结果太少时使用
const originalResults = [...mergedResults];
// 首先按相似度过滤
mergedResults = mergedResults.filter(server => server.similarity >= mergedOptions.minSimilarity);
// 如果过滤后结果太少,保留至少5个最相似的结果
if (mergedResults.length < 5 && originalResults.length > 5) {
// 使用原始排序的前5个结果
mergedResults =
mergedResults.length > 0
? mergedResults
: originalResults.slice(0, Math.min(5, originalResults.length));
}
filteredCount += originalCount - mergedResults.length;
originalCount = mergedResults.length;
logger.info(`After similarity filtering: ${mergedResults.length} results remain`, 'Filtering', {
afterCount: mergedResults.length,
removedCount: filteredCount,
});
}
// Apply limit filter
if (mergedOptions.limit !== undefined && mergedOptions.limit > 0) {
logger.info(`Applying result limit: ${mergedOptions.limit}`, 'Filtering', {
limit: mergedOptions.limit,
beforeCount: mergedResults.length,
});
const beforeLimit = mergedResults.length;
mergedResults = mergedResults.slice(0, mergedOptions.limit);
filteredCount += beforeLimit - mergedResults.length;
logger.info(`After limit filtering: ${mergedResults.length} results remain`, 'Filtering', {
afterCount: mergedResults.length,
removedCount: beforeLimit - mergedResults.length,
});
}
// Log final results with full details
logger.info(`Final search results: ${mergedResults.length} servers`, 'SearchResults', {
resultCount: mergedResults.length,
results: mergedResults.map(r => ({
title: r.title,
similarity: r.similarity.toFixed(4),
github_url: r.github_url,
})),
});
logger.debug(`Merged results: ${mergedResults.length} servers after filtering`);
return mergedResults;
}
catch (error) {
logger.error(`Error in search service: ${error instanceof Error ? error.message : String(error)}`);
throw error;
}
}
/**
* 静态离线搜索方法,不需要创建 SearchService 实例
* @param query 搜索查询
* @param options 搜索选项
* @param fallbackDataPath 备用数据路径
* @param useEnhanced 是否使用增强型离线搜索提供者,默认为 true
* @returns 搜索结果
*/
static async searchOffline(query, options = {}, fallbackDataPath, textMatchWeight = 0.7) {
const searchParams = typeof query === 'string'
? { taskDescription: query }
: query;
try {
logger.info(`Searching offline with query: "${query}"`, 'OfflineSearch', {
query,
options,
});
// 记录开始时间
const startTime = Date.now();
// 创建离线搜索提供者
logger.debug('Using OfflineSearchProvider', 'OfflineSearch');
const provider = new OfflineSearchProvider({
fallbackDataPath,
minSimilarity: options.minSimilarity || 0.3,
textMatchWeight,
vectorSearchWeight: 1 - textMatchWeight,
});
const results = await provider.search(searchParams);
const duration = Date.now() - startTime;
logger.info(`Offline search completed in ${duration}ms with ${results.length} results`, 'OfflineSearch', {
duration,
resultCount: results.length,
});
// 处理排序
if (options.sortBy) {
const sortField = options.sortBy;
const sortMultiplier = options.sortOrder === 'desc' ? -1 : 1;
results.sort((a, b) => {
const aValue = a[sortField];
const bValue = b[sortField];
// 处理数字排序
if (typeof aValue === 'number' && typeof bValue === 'number') {
return (aValue - bValue) * sortMultiplier;
}
// 处理字符串排序
if (typeof aValue === 'string' && typeof bValue === 'string') {
return aValue.localeCompare(bValue) * sortMultiplier;
}
// 处理空值
if (aValue === undefined && bValue !== undefined)
return 1;
if (aValue !== undefined && bValue === undefined)
return -1;
return 0;
});
}
// 确保每个结果都有 score 属性
results.forEach(result => {
if (result.score === undefined) {
result.score = result.similarity || 0;
}
});
// 应用限制
if (options.limit && options.limit > 0) {
return results.slice(0, options.limit);
}
return results;
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Error searching offline: ${errorMessage}`, 'OfflineSearch', { error: errorMessage });
throw error;
}
}
/**
* 使用 GetMCP 搜索提供者搜索
* 便捷方法,直接使用 GetMcpSearchProvider
*/
static async searchGetMcp(query, options = {}) {
const searchParams = typeof query === 'string'
? { taskDescription: query }
: query;
try {
logger.info(`Searching GetMCP with query: "${query}"`, 'GetMcpSearch', {
query,
options,
});
// 创建 GetMcpSearchProvider 实例
const provider = new GetMcpSearchProvider();
logger.debug('Created GetMcpSearchProvider instance', 'GetMcpSearch');
// 创建 SearchService 实例
const service = new SearchService([provider]);
logger.debug('Created SearchService with GetMcpSearchProvider', 'GetMcpSearch');
// 执行搜索
const startTime = Date.now();
const results = await service.search(searchParams, options);
const duration = Date.now() - startTime;
logger.info(`GetMCP search completed in ${duration}ms with ${results.length} results`, 'GetMcpSearch', {
duration,
resultCount: results.length,
});
return results;
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Error searching GetMCP: ${errorMessage}`, 'GetMcpSearch', {
error: errorMessage,
});
throw error;
}
}
/**
* 使用 Compass 搜索提供者搜索
* 便捷方法,直接使用 CompassSearchProvider
*/
static async searchCompass(query, options = {}) {
const searchParams = typeof query === 'string'
? { taskDescription: query }
: query;
try {
logger.info(`Searching Compass with query: "${query}"`, 'CompassSearch', {
query,
options,
});
// 创建 CompassSearchProvider 实例
const provider = new CompassSearchProvider();
logger.debug('Created CompassSearchProvider instance', 'CompassSearch');
// 创建 SearchService 实例
const service = new SearchService([provider]);
logger.debug('Created SearchService with CompassSearchProvider', 'CompassSearch');
// 执行搜索
const startTime = Date.now();
const results = await service.search(searchParams, options);
const duration = Date.now() - startTime;
logger.info(`Compass search completed in ${duration}ms with ${results.length} results`, 'CompassSearch', {
duration,
resultCount: results.length,
});
return results;
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Error searching Compass: ${errorMessage}`, 'CompassSearch', { error: errorMessage });
throw error;
}
}
/**
* 使用 Meilisearch 搜索提供者搜索
* 便捷方法,直接使用 MeilisearchSearchProvider
*/
static async searchMeilisearch(query, options = {}) {
const searchParams = typeof query === 'string'
? { taskDescription: query }
: query;
try {
logger.info(`Searching Meilisearch with query: "${query}"`, 'MeilisearchSearch', { query, options });
// 创建 MeilisearchSearchProvider 实例
const provider = new MeilisearchSearchProvider();
logger.debug('Created MeilisearchSearchProvider instance', 'MeilisearchSearch');
// 创建 SearchService 实例
const service = new SearchService([provider]);
logger.debug('Created SearchService with MeilisearchSearchProvider', 'MeilisearchSearch');
// 执行搜索
const startTime = Date.now();
const results = await service.search(searchParams, options);
const duration = Date.now() - startTime;
logger.info(`Meilisearch search completed in ${duration}ms with ${results.length} results`, 'MeilisearchSearch', {
duration,
resultCount: results.length,
});
return results;
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Error searching Meilisearch: ${errorMessage}`, 'MeilisearchSearch', { error: errorMessage });
throw error;
}
}
}