/** * SearchEngine Module * Handles Lunr.js integration and search logic with filtering and grouping */ class SearchEngine { constructor(utils) { this.utils = utils; this.index = null; this.documents = {}; this.isInitialized = false; // Support both new schema (topics, audience) and legacy (categories, personas) this.topics = new Set(); this.tags = new Set(); this.documentTypes = new Set(); this.audience = new Set(); this.difficulties = new Set(); // Dynamic facets - discovered from documents, not predefined this.facets = {}; // { facetKey: Set of values } } /** * Initialize the search engine with documents */ async initialize(documents) { try { await this.loadLunr(); this.documents = documents; this.collectMetadata(); this.buildIndex(); this.isInitialized = true; } catch (error) { throw error; } } /** * Collect metadata for filtering using actual frontmatter values * Supports both new schema (topics, audience) and legacy (categories, personas) * Dynamically discovers all facet keys from documents */ collectMetadata() { // Clear existing sets this.topics = new Set(); this.tags = new Set(); this.documentTypes = new Set(); this.audience = new Set(); this.difficulties = new Set(); this.facets = {}; // Reset dynamic facets Object.values(this.documents).forEach(doc => { // Collect topics (new schema) or categories (legacy) const topicsField = doc.topics || doc.categories; if (topicsField) { if (Array.isArray(topicsField)) { topicsField.forEach(topic => this.topics.add(topic)); } else if (typeof topicsField === 'string') { topicsField.split(',').forEach(topic => this.topics.add(topic.trim())); } } // Collect actual frontmatter tags if (doc.tags) { if (Array.isArray(doc.tags)) { doc.tags.forEach(tag => { // Split space-separated tags and add individually if (typeof tag === 'string' && tag.includes(' ')) { tag.split(' ').forEach(individualTag => { if (individualTag.trim()) { this.tags.add(individualTag.trim()); } }); } else if (tag && tag.trim()) { this.tags.add(tag.trim()); } }); } else if (typeof doc.tags === 'string') { // Handle both comma-separated and space-separated tags const allTags = doc.tags.includes(',') ? doc.tags.split(',') : doc.tags.split(' '); allTags.forEach(tag => { if (tag && tag.trim()) { this.tags.add(tag.trim()); } }); } } // Use actual content_type from frontmatter (not calculated doc_type) if (doc.content_type) { this.documentTypes.add(doc.content_type); } // Collect audience (new schema) or personas (legacy) const audienceField = doc.audience || doc.personas; if (audienceField) { if (Array.isArray(audienceField)) { audienceField.forEach(aud => this.audience.add(aud)); } else if (typeof audienceField === 'string') { this.audience.add(audienceField); } } if (doc.difficulty) { this.difficulties.add(doc.difficulty); } // Dynamically discover all facets from documents if (doc.facets && typeof doc.facets === 'object') { Object.entries(doc.facets).forEach(([facetKey, facetValue]) => { // Initialize Set for this facet if not exists if (!this.facets[facetKey]) { this.facets[facetKey] = new Set(); } // Add value(s) to the facet Set if (Array.isArray(facetValue)) { facetValue.forEach(v => this.facets[facetKey].add(v)); } else if (facetValue) { this.facets[facetKey].add(facetValue); } }); } // Also check for flat facet fields (legacy modality, etc.) // These get added to facets dynamically if (doc.modality && !this.facets.modality) { this.facets.modality = new Set(); } if (doc.modality) { this.facets.modality.add(doc.modality); } }); } /** * Get available filter options using actual frontmatter taxonomy * Returns both new field names and legacy names for backwards compatibility * Includes dynamically discovered facets */ getFilterOptions() { // Convert dynamic facets from Sets to sorted arrays const facetOptions = {}; Object.entries(this.facets).forEach(([facetKey, facetSet]) => { facetOptions[facetKey] = Array.from(facetSet).sort(); }); return { // New schema names topics: Array.from(this.topics).sort(), audience: Array.from(this.audience).sort(), // Legacy names (aliases for backwards compatibility) categories: Array.from(this.topics).sort(), personas: Array.from(this.audience).sort(), // Common fields tags: Array.from(this.tags).sort(), documentTypes: Array.from(this.documentTypes).sort(), difficulties: Array.from(this.difficulties).sort(), // Dynamic facets (user-defined, discovered from documents) facets: facetOptions }; } /** * Load Lunr.js library if not already loaded */ async loadLunr() { if (typeof lunr === 'undefined') { await this.utils.loadScript('https://unpkg.com/lunr@2.3.9/lunr.min.js'); } } /** * Build the Lunr search index * Supports both new schema (topics, audience) and legacy (categories, personas) * * Field boosting rationale: * - Title matches are almost always what users want (highest boost) * - Description (from frontmatter) is hand-crafted summary (high boost) * - Headings provide structural relevance (medium-high boost) * - Content gets lowest boost to prevent long documents from dominating * - Hierarchy: title > description > headings/keywords > tags > content */ buildIndex() { const documentsArray = Object.values(this.documents); const self = this; this.index = lunr(function() { // Define fields with optimized boosting for documentation search patterns this.ref('id'); // Primary fields - highest relevance this.field('title', { boost: 10 }); // Title matches most important this.field('description', { boost: 8 }); // Frontmatter description (hand-crafted) // Secondary fields - structural relevance this.field('keywords', { boost: 7 }); // Explicit keywords this.field('headings_text', { boost: 5 }); // Section headings this.field('headings', { boost: 5 }); // Section headings (legacy format) this.field('tags', { boost: 4 }); // Taxonomy tags // Tertiary fields - content matching this.field('summary', { boost: 3 }); // Summary field this.field('topics', { boost: 2 }); // Topic categorization this.field('content', { boost: 1 }); // Full content (low to prevent long docs dominating) // Metadata fields - filtering support this.field('content_type', { boost: 1 }); this.field('audience', { boost: 1 }); this.field('difficulty', { boost: 1 }); this.field('modality', { boost: 1 }); this.field('section_path', { boost: 1 }); this.field('author', { boost: 1 }); // Add documents to index documentsArray.forEach((doc) => { try { this.add({ id: doc.id, title: doc.title || '', description: doc.description || '', // NEW: separate indexed field content: (doc.content || '').substring(0, 5000), // Limit content length summary: doc.summary || '', headings: self.extractHeadingsText(doc.headings), headings_text: doc.headings_text || '', keywords: self.arrayToString(doc.keywords), tags: self.arrayToString(doc.tags), // Support both topics (new) and categories (legacy) topics: self.arrayToString(doc.topics || doc.categories), content_type: doc.content_type || '', // Support both audience (new) and personas (legacy) audience: self.arrayToString(doc.audience || doc.personas), difficulty: doc.difficulty || '', modality: doc.modality || '', section_path: self.arrayToString(doc.section_path), author: doc.author || '' }); } catch (docError) { // Skip documents that fail to index } }, this); }); } /** * Convert array to string for indexing */ arrayToString(arr) { if (Array.isArray(arr)) { return arr.join(' '); } return arr || ''; } /** * Extract text from headings array */ extractHeadingsText(headings) { if (!Array.isArray(headings)) return ''; return headings.map(h => h.text || '').join(' '); } /** * Perform search with query and optional filters */ search(query, filters = {}, maxResults = 20) { if (!this.isInitialized || !this.index) { return []; } if (!query || query.trim().length < 2) { return []; } try { // Enhanced search with multiple strategies const results = this.performMultiStrategySearch(query); // Process and enhance results const enhancedResults = this.enhanceResults(results, query); // Apply filters const filteredResults = this.applyFilters(enhancedResults, filters); // Group and rank results const groupedResults = this.groupResultsByDocument(filteredResults, query); return groupedResults.slice(0, maxResults); } catch (error) { return []; } } /** * Apply filters to search results * Supports both new schema (topic, audience) and legacy (category, persona) filter names * Handles dynamic facet filters */ applyFilters(results, filters) { return results.filter(result => { // Topic filter (new) or category filter (legacy) const topicFilter = filters.topic || filters.category; if (topicFilter && topicFilter !== '') { const docTopics = this.getDocumentTopics(result); if (!docTopics.includes(topicFilter)) { return false; } } // Tag filter if (filters.tag && filters.tag !== '') { const docTags = this.getDocumentTags(result); if (!docTags.includes(filters.tag)) { return false; } } // Document type filter (using actual frontmatter content_type) if (filters.type && filters.type !== '') { if (result.content_type !== filters.type) { return false; } } // Audience filter (new) or persona filter (legacy) const audienceFilter = filters.audience || filters.persona; if (audienceFilter && audienceFilter !== '') { const docAudience = this.getDocumentAudience(result); if (!docAudience.includes(audienceFilter)) { return false; } } // Difficulty filter if (filters.difficulty && filters.difficulty !== '') { if (result.difficulty !== filters.difficulty) { return false; } } // Dynamic facet filters (e.g., filters.facets = { modality: 'text-only', framework: 'pytorch' }) if (filters.facets && typeof filters.facets === 'object') { for (const [facetKey, facetValue] of Object.entries(filters.facets)) { if (facetValue && facetValue !== '') { const docFacetValue = this.getDocumentFacet(result, facetKey); if (!docFacetValue.includes(facetValue)) { return false; } } } } // Legacy flat facet filters (e.g., filters.modality directly) // Check for any filter key that matches a known facet for (const facetKey of Object.keys(this.facets)) { if (filters[facetKey] && filters[facetKey] !== '') { const docFacetValue = this.getDocumentFacet(result, facetKey); if (!docFacetValue.includes(filters[facetKey])) { return false; } } } return true; }); } /** * Get a specific facet value for a document */ getDocumentFacet(doc, facetKey) { // Check nested facets object first if (doc.facets && doc.facets[facetKey]) { const value = doc.facets[facetKey]; return Array.isArray(value) ? value : [value]; } // Check flat field (legacy) if (doc[facetKey]) { const value = doc[facetKey]; return Array.isArray(value) ? value : [value]; } return []; } /** * Get topics for a document (supports new schema and legacy categories) */ getDocumentTopics(doc) { const topics = []; // From explicit topics (new schema) or categories (legacy) const topicsField = doc.topics || doc.categories; if (topicsField) { if (Array.isArray(topicsField)) { topics.push(...topicsField); } else { topics.push(...topicsField.split(',').map(t => t.trim())); } } // From section path if (doc.section_path && Array.isArray(doc.section_path)) { topics.push(...doc.section_path); } // From document ID path if (doc.id) { const pathParts = doc.id.split('/').filter(part => part && part !== 'index'); topics.push(...pathParts); } return [...new Set(topics)]; // Remove duplicates } /** * Get categories for a document (legacy alias for getDocumentTopics) */ getDocumentCategories(doc) { return this.getDocumentTopics(doc); } /** * Get tags for a document */ getDocumentTags(doc) { if (!doc.tags) return []; if (Array.isArray(doc.tags)) { // Handle array of tags that might contain space-separated strings const flatTags = []; doc.tags.forEach(tag => { if (typeof tag === 'string' && tag.includes(' ')) { // Split space-separated tags tag.split(' ').forEach(individualTag => { if (individualTag.trim()) { flatTags.push(individualTag.trim()); } }); } else if (tag && tag.trim()) { flatTags.push(tag.trim()); } }); return flatTags; } // Handle string tags - check for both comma and space separation if (typeof doc.tags === 'string') { const allTags = []; const tagString = doc.tags.trim(); if (tagString.includes(',')) { // Comma-separated tags tagString.split(',').forEach(tag => { if (tag.trim()) { allTags.push(tag.trim()); } }); } else { // Space-separated tags tagString.split(' ').forEach(tag => { if (tag.trim()) { allTags.push(tag.trim()); } }); } return allTags; } return []; } /** * Get audience for a document (supports new schema and legacy personas) */ getDocumentAudience(doc) { // Support both audience (new) and personas (legacy) const audienceField = doc.audience || doc.personas; if (!audienceField) return []; if (Array.isArray(audienceField)) { return audienceField; } return [audienceField]; } /** * Get personas for a document (legacy alias for getDocumentAudience) */ getDocumentPersonas(doc) { return this.getDocumentAudience(doc); } /** * Perform search with multiple strategies */ performMultiStrategySearch(query) { const strategies = [ // Exact phrase search with wildcards `"${query}" ${query}*`, // Fuzzy search with wildcards `${query}* ${query}~2`, // Individual terms with boost query.split(/\s+/).map(term => `${term}*`).join(' '), // Fallback: just the query query ]; let allResults = []; const seenIds = new Set(); for (const strategy of strategies) { try { const results = this.index.search(strategy); // Add new results (avoid duplicates) results.forEach(result => { if (!seenIds.has(result.ref)) { seenIds.add(result.ref); allResults.push({ ...result, strategy: strategy }); } }); // If we have enough good results, stop if (allResults.length >= 30) break; } catch (strategyError) { console.warn(`Search strategy failed: ${strategy}`, strategyError); } } return allResults; } /** * Enhance search results with document data and apply re-ranking */ enhanceResults(results, query) { const queryLower = query.toLowerCase().trim(); const queryTerms = queryLower.split(/\s+/); return results.map(result => { const doc = this.documents[result.ref]; if (!doc) { console.warn(`Document not found: ${result.ref}`); return null; } // Calculate additional relevance boost for title matches const titleBoost = this.calculateTitleBoost(doc, queryLower, queryTerms); const keywordBoost = this.calculateKeywordBoost(doc, queryTerms); const descriptionBoost = this.calculateDescriptionBoost(doc, queryTerms); // Apply boosts to base score const enhancedScore = result.score * (1 + titleBoost + keywordBoost + descriptionBoost); return { ...doc, score: enhancedScore, baseScore: result.score, titleBoost, keywordBoost, descriptionBoost, matchedTerms: Object.keys(result.matchData?.metadata || {}), matchData: result.matchData, strategy: result.strategy }; }).filter(Boolean); // Remove null results } /** * Calculate boost for title matches * Heavily rewards exact and partial title matches */ calculateTitleBoost(doc, queryLower, queryTerms) { if (!doc.title) return 0; const titleLower = doc.title.toLowerCase(); let boost = 0; // Exact title match (highest boost) if (titleLower === queryLower) { boost += 10; } // Title starts with query else if (titleLower.startsWith(queryLower)) { boost += 8; } // Query is a significant part of title (e.g., "audit" in "Documentation Audit Guide") else if (titleLower.includes(queryLower)) { // Boost more if query is a larger portion of the title const ratio = queryLower.length / titleLower.length; boost += 5 * ratio + 3; } // All query terms appear in title else if (queryTerms.every(term => titleLower.includes(term))) { boost += 4; } // Some query terms appear in title else { const matchingTerms = queryTerms.filter(term => titleLower.includes(term)); if (matchingTerms.length > 0) { boost += 2 * (matchingTerms.length / queryTerms.length); } } // Additional boost if title contains query as a distinct word const titleWords = titleLower.split(/[\s\-_:]+/); if (titleWords.some(word => word === queryLower || word.startsWith(queryLower))) { boost += 2; } return boost; } /** * Calculate boost for keyword matches */ calculateKeywordBoost(doc, queryTerms) { if (!doc.keywords) return 0; const keywords = Array.isArray(doc.keywords) ? doc.keywords.map(k => k.toLowerCase()) : doc.keywords.toLowerCase().split(/[\s,]+/); let boost = 0; queryTerms.forEach(term => { if (keywords.some(kw => kw === term || kw.startsWith(term))) { boost += 1.5; } }); return boost; } /** * Calculate boost for description matches */ calculateDescriptionBoost(doc, queryTerms) { if (!doc.description) return 0; const descLower = doc.description.toLowerCase(); let boost = 0; // Check if query terms appear early in description queryTerms.forEach(term => { const pos = descLower.indexOf(term); if (pos !== -1) { // Boost more if term appears early boost += pos < 50 ? 1 : 0.5; } }); return boost; } /** * Group results by document and find matching sections */ groupResultsByDocument(results, query) { const grouped = new Map(); results.forEach(result => { const docId = result.id; if (!grouped.has(docId)) { // Find matching sections within this document const matchingSections = this.findMatchingSections(result, query); grouped.set(docId, { ...result, matchingSections, totalMatches: 1, combinedScore: result.score }); } else { // Document already exists, combine scores and sections const existing = grouped.get(docId); const additionalSections = this.findMatchingSections(result, query); existing.matchingSections = this.mergeSections(existing.matchingSections, additionalSections); existing.totalMatches += 1; existing.combinedScore = Math.max(existing.combinedScore, result.score); } }); // Convert map to array and sort by combined score return Array.from(grouped.values()) .sort((a, b) => b.combinedScore - a.combinedScore); } /** * Find matching sections within a document */ findMatchingSections(result, query) { const matchingSections = []; const queryTerms = query.toLowerCase().split(/\s+/); // Check if title matches if (result.title) { const titleText = result.title.toLowerCase(); const hasMatch = queryTerms.some(term => titleText.includes(term)); if (hasMatch) { matchingSections.push({ type: 'title', text: result.title, level: 1, anchor: '' }); } } // Check headings for matches if (result.headings && Array.isArray(result.headings)) { result.headings.forEach(heading => { const headingText = heading.text?.toLowerCase() || ''; const hasMatch = queryTerms.some(term => headingText.includes(term)); if (hasMatch) { matchingSections.push({ type: 'heading', text: heading.text, level: heading.level || 2, anchor: this.generateAnchor(heading.text) }); } }); } // If no specific sections found, add a general content match if (matchingSections.length === 0) { matchingSections.push({ type: 'content', text: 'Content match', level: 0, anchor: '' }); } return matchingSections; } /** * Generate anchor link similar to how Sphinx does it */ generateAnchor(headingText) { if (!headingText) return ''; return headingText .toLowerCase() .replace(/[^\w\s-]/g, '') // Remove special chars .replace(/\s+/g, '-') // Replace spaces with hyphens .trim(); } /** * Merge sections, avoiding duplicates */ mergeSections(existing, additional) { const merged = [...existing]; additional.forEach(section => { const isDuplicate = existing.some(existingSection => existingSection.text === section.text && existingSection.type === section.type ); if (!isDuplicate) { merged.push(section); } }); return merged; } /** * Get search statistics */ getStatistics() { // Count facet keys and total values const facetStats = {}; Object.entries(this.facets).forEach(([key, valueSet]) => { facetStats[key] = valueSet.size; }); return { documentsIndexed: Object.keys(this.documents).length, topicsAvailable: this.topics.size, tagsAvailable: this.tags.size, documentTypesAvailable: this.documentTypes.size, audienceAvailable: this.audience.size, difficultiesAvailable: this.difficulties.size, facetsDiscovered: Object.keys(this.facets).length, facetStats: facetStats, isInitialized: this.isInitialized }; } /** * Check if the search engine is ready */ isReady() { return this.isInitialized && this.index !== null; } } // Make SearchEngine available globally window.SearchEngine = SearchEngine;