Skip to main content
Glama
simplified-search-service.ts11.4 kB
/** * Simplified Search Service - Clean Architecture Implementation * Single responsibility: Execute search with transparent mathematical scoring * * THE IMPLEMENTOR'S RULE: No truth levels, no orchestration theater, just math */ import { Session } from 'neo4j-driver'; import { QueryClassifier, QueryIntent, QueryType } from './query-classifier'; import { ExactSearchChannel } from './exact-search-channel'; import { VectorSearchChannel, VectorCandidate } from './vector-search-channel'; import { WildcardSearchService } from './wildcard-search-service'; import { EnhancedSearchResult } from '../../../types'; import { MCPValidationError, MCPErrorCodes } from '../../errors'; export interface SimpleSearchResult extends EnhancedSearchResult { score: number; // Raw mathematical similarity (0.0-1.0) matchType: 'semantic' | 'exact'; // Simple binary classification } /** * Clean search service that eliminates truth-level complexity * Direct execution: Query → Classification → Search → Score → Results */ export class SimplifiedSearchService { private queryClassifier: QueryClassifier; private exactChannel: ExactSearchChannel; private vectorChannel: VectorSearchChannel; private wildcardService: WildcardSearchService; constructor(private session: Session) { this.queryClassifier = new QueryClassifier(); this.exactChannel = new ExactSearchChannel(session); this.vectorChannel = new VectorSearchChannel(session); this.wildcardService = new WildcardSearchService(session); } /** * Execute search with simplified, transparent scoring */ async search( query: string, limit: number = 10, includeGraphContext: boolean = true, memoryTypes?: string[], threshold: number = 0.1 ): Promise<SimpleSearchResult[]> { // Input validation if (!query || typeof query !== 'string') { throw new MCPValidationError( 'Search query must be a non-empty string', MCPErrorCodes.INVALID_SEARCH_QUERY ); } if (limit <= 0) { throw new MCPValidationError( 'Search limit must be positive', MCPErrorCodes.INVALID_PARAMETER, { parameter: 'limit', value: limit } ); } // Query classification const queryIntent = this.queryClassifier.classify(query); // Wildcard search bypass if (queryIntent.type === QueryType.WILDCARD) { const wildcardResults = await this.wildcardService.search( limit, includeGraphContext, memoryTypes ); return wildcardResults.map(result => ({ ...result, score: 1.0, // Wildcard gets perfect score matchType: 'exact' as const })); } // Execute multi-channel search return this.executeSearch(queryIntent, limit, threshold, memoryTypes); } /** * Execute search across exact and vector channels */ private async executeSearch( queryIntent: QueryIntent, limit: number, threshold: number, memoryTypes?: string[] ): Promise<SimpleSearchResult[]> { // Execute exact search (always) const exactCandidates = await this.exactChannel.search( queryIntent.preprocessing.normalized, limit * 2, memoryTypes ); // Execute vector search (semantic queries only) let vectorCandidates: VectorCandidate[] = []; if (queryIntent.type === QueryType.SEMANTIC_SEARCH) { try { vectorCandidates = await this.vectorChannel.search( queryIntent.preprocessing.normalized, limit * 2, threshold, memoryTypes ); } catch (error) { // Vector search failed - continue with exact results only // Silent failure allowed here as we have exact search fallback } } // Combine and score candidates const candidateMap = new Map<string, { id: string; hasExactMatch: boolean; vectorScore?: number; }>(); // Process exact candidates for (const exact of exactCandidates) { candidateMap.set(exact.id, { id: exact.id, hasExactMatch: true }); } // Process vector candidates for (const vector of vectorCandidates) { const existing = candidateMap.get(vector.id); if (existing) { existing.vectorScore = vector.score; } else { candidateMap.set(vector.id, { id: vector.id, hasExactMatch: false, vectorScore: vector.score }); } } // Score and filter candidates const scoredCandidates = Array.from(candidateMap.values()) .map(candidate => this.calculateSimpleScore(candidate)) .filter(result => result.score >= threshold) .sort((a, b) => b.score - a.score) .slice(0, limit); // Enrich with full memory data return this.enrichWithMemoryData(scoredCandidates, memoryTypes); } /** * Calculate simple mathematical score without truth levels */ private calculateSimpleScore(candidate: { id: string; hasExactMatch: boolean; vectorScore?: number; }): { id: string; score: number; matchType: 'semantic' | 'exact' } { if (candidate.hasExactMatch) { // Exact match gets high score, boosted if also has vector similarity const baseScore = 0.85; const vectorBoost = candidate.vectorScore ? Math.min(candidate.vectorScore * 0.15, 0.15) : 0; return { id: candidate.id, score: Math.min(baseScore + vectorBoost, 1.0), matchType: 'exact' }; } if (candidate.vectorScore) { // Pure semantic match return { id: candidate.id, score: candidate.vectorScore, matchType: 'semantic' }; } // Should not happen, but safety net return { id: candidate.id, score: 0.1, matchType: 'exact' }; } /** * Enrich scored candidates with full memory data */ private async enrichWithMemoryData( candidates: Array<{ id: string; score: number; matchType: 'semantic' | 'exact' }>, memoryTypes?: string[] ): Promise<SimpleSearchResult[]> { if (candidates.length === 0) return []; const candidateIds = candidates.map(c => c.id); // Build WHERE clause with memory type filtering let whereClause = 'WHERE m.id IN $candidateIds'; if (memoryTypes && memoryTypes.length > 0) { whereClause += ' AND m.memoryType IN $memoryTypes'; } const cypher = ` MATCH (m:Memory) ${whereClause} // Graph context - 2 levels deep with exact relation types OPTIONAL MATCH path1 = (ancestor:Memory)-[rel1*1..2]->(m) WHERE ancestor <> m AND ancestor.id IS NOT NULL WITH m, collect(DISTINCT { id: ancestor.id, name: ancestor.name, type: ancestor.memoryType, relation: rel1[0].relationType, distance: length(path1), strength: rel1[0].strength, source: rel1[0].source, createdAt: rel1[0].createdAt })[0..3] as ancestors OPTIONAL MATCH path2 = (m)-[rel2*1..2]->(descendant:Memory) WHERE descendant <> m AND descendant.id IS NOT NULL WITH m, ancestors, collect(DISTINCT { id: descendant.id, name: descendant.name, type: descendant.memoryType, relation: rel2[0].relationType, distance: length(path2), strength: rel2[0].strength, source: rel2[0].source, createdAt: rel2[0].createdAt })[0..3] as descendants // Core content with ordered observations OPTIONAL MATCH (m)-[:HAS_OBSERVATION]->(o:Observation) WITH m, ancestors, descendants, o ORDER BY o.createdAt ASC WITH m, ancestors, descendants, collect(DISTINCT {id: o.id, content: o.content, createdAt: o.createdAt}) as observations RETURN m.id as id, m.name as name, m.memoryType as type, m.metadata as metadata, m.createdAt as createdAt, m.modifiedAt as modifiedAt, m.lastAccessed as lastAccessed, observations, ancestors, descendants ORDER BY m.name `; const result = await this.session.run(cypher, { candidateIds, memoryTypes }); // Create enriched results map const enrichedMap = new Map<string, any>(); for (const record of result.records) { const ancestors = record.get('ancestors') || []; const descendants = record.get('descendants') || []; const memoryData = { id: record.get('id'), name: record.get('name'), type: record.get('type'), observations: this.processObservations(record.get('observations') || []), metadata: this.parseMetadata(record.get('metadata')), createdAt: record.get('createdAt'), modifiedAt: record.get('modifiedAt'), lastAccessed: record.get('lastAccessed'), related: this.processGraphContext(ancestors, descendants) }; enrichedMap.set(memoryData.id, memoryData); } // Merge with scored candidates maintaining order return candidates .map(candidate => { const enriched = enrichedMap.get(candidate.id); if (enriched) { return { ...enriched, score: candidate.score, matchType: candidate.matchType }; } // Return stub if enrichment failed return { id: candidate.id, name: `Unknown Memory ${candidate.id}`, type: 'unknown', observations: [], metadata: {}, score: candidate.score, matchType: candidate.matchType }; }) .filter(result => result.id); // Remove any failed enrichments } private processObservations(observations: any[]): Array<{id?: string, content: string, createdAt: string}> { if (!Array.isArray(observations)) return []; return observations .filter(obs => obs && obs.content) .map(obs => ({ id: obs.id, content: obs.content, createdAt: obs.createdAt || new Date().toISOString() })); } private processGraphContext(ancestors: any[], descendants: any[]): any { const processedAncestors = ancestors .filter((a: any) => a.id !== null) .map((a: any) => ({ ...a, distance: a.distance ? this.convertNeo4jInteger(a.distance) : 0 })); const processedDescendants = descendants .filter((d: any) => d.id !== null) .map((d: any) => ({ ...d, distance: d.distance ? this.convertNeo4jInteger(d.distance) : 0 })); // Only include related if there are actual relationships if (processedAncestors.length > 0 || processedDescendants.length > 0) { return { ...(processedAncestors.length > 0 && { ancestors: processedAncestors }), ...(processedDescendants.length > 0 && { descendants: processedDescendants }) }; } return undefined; } private convertNeo4jInteger(value: any): number { if (typeof value === 'number') return value; if (value && typeof value.toNumber === 'function') return value.toNumber(); return 0; } private parseMetadata(metadata: string | null): Record<string, any> { if (!metadata) return {}; try { return JSON.parse(metadata); } catch { return {}; } } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/sylweriusz/mcp-neo4j-memory-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server