UNPKG

@mtorange/mcp-local-file-search

Version:

Local file indexing and search MCP server with BM25 algorithm and multilingual support

306 lines (254 loc) 9.15 kB
const fs = require('fs-extra'); const path = require('path'); const natural = require('natural'); class Searcher { constructor(targetDir, debugFile = null) { this.targetDir = targetDir; this.debugFile = debugFile; this.indexPath = path.join(targetDir, '.local-file-index.json'); this.tokenizer = new natural.WordTokenizer(); this.stemmer = natural.PorterStemmer; // BM25 파라미터 this.k1 = 1.2; this.b = 0.75; } // 디버그 로그 출력 debug(message) { if (this.debugFile) { const timestamp = new Date().toISOString(); const logMessage = `[${timestamp}] ${message}\n`; fs.appendFileSync(this.debugFile, logMessage); } } // 인덱스 로드 async loadIndex() { try { if (await fs.pathExists(this.indexPath)) { const indexData = await fs.readJson(this.indexPath); return indexData; } else { throw new Error('인덱스 파일이 존재하지 않습니다. 먼저 인덱싱을 실행해주세요.'); } } catch (error) { this.debug(`인덱스 로드 오류: ${error.message}`); throw error; } } // 개선된 쿼리 토큰화 (한국어 + 영어 지원) tokenizeQuery(query) { const normalizedQuery = query.toLowerCase().trim(); // 한국어와 영어 모두 처리하기 위한 토큰 배열 let tokens = []; // 영어 단어 토큰화 const englishTokens = this.tokenizer.tokenize(normalizedQuery); if (englishTokens) { tokens = tokens.concat(englishTokens); } // 한국어 토큰화 (공백 기준 + 2자 이상 한글 추출) const koreanTokens = normalizedQuery.match(/[가-힣]{2,}/g) || []; tokens = tokens.concat(koreanTokens); // 특수 문자 제거 및 빈 토큰 제거 tokens = tokens .map(token => token.replace(/[^\w가-힣]/g, '')) .filter(token => token.length > 1); // 영어 토큰에 대해서만 스테밍 적용 const finalTokens = tokens.map(token => { if (/^[a-zA-Z]+$/.test(token)) { return this.stemmer.stem(token); } return token; }); // 중복 제거 return [...new Set(finalTokens)]; } // 평균 문서 길이 계산 calculateAverageDocumentLength(indexData) { const files = Object.values(indexData.files); const totalLength = files.reduce((sum, file) => sum + file.termCount, 0); return totalLength / files.length; } // BM25 점수 계산 calculateBM25Score(queryTerms, fileData, termStats, avgDocLength, totalDocs) { let score = 0; queryTerms.forEach(term => { const termFreq = fileData.termFreq[term] || 0; const docLength = fileData.termCount; const termStat = termStats[term]; if (termFreq > 0 && termStat) { const idf = termStat.idf; const tf = termFreq / (termFreq + this.k1 * (1 - this.b + this.b * (docLength / avgDocLength))); score += idf * tf; } }); return score; } // 검색 결과 하이라이트 highlightMatches(content, queryTerms, maxLength = 500) { let highlightedContent = content; // 쿼리 용어들을 하이라이트 queryTerms.forEach(term => { // 한국어와 영어 모두 처리 const regex = new RegExp(`(${term})`, 'gi'); highlightedContent = highlightedContent.replace(regex, `**$1**`); }); // 최대 길이로 자르기 if (highlightedContent.length > maxLength) { const firstMatch = highlightedContent.indexOf('**'); if (firstMatch !== -1) { const start = Math.max(0, firstMatch - 100); const end = Math.min(highlightedContent.length, start + maxLength); highlightedContent = '...' + highlightedContent.substring(start, end) + '...'; } else { highlightedContent = highlightedContent.substring(0, maxLength) + '...'; } } return highlightedContent; } // 검색 실행 async search(query, limit = 10) { try { this.debug(`검색 시작: "${query}"`); // 인덱스 로드 const indexData = await this.loadIndex(); if (Object.keys(indexData.files).length === 0) { this.debug('인덱싱된 파일이 없습니다.'); return []; } // 쿼리 토큰화 const queryTerms = this.tokenizeQuery(query); if (queryTerms.length === 0) { this.debug('유효한 검색어가 없습니다.'); return []; } this.debug(`검색 용어: ${queryTerms.join(', ')}`); // 평균 문서 길이 계산 const avgDocLength = this.calculateAverageDocumentLength(indexData); const totalDocs = indexData.stats.totalDocuments; // 각 파일에 대해 BM25 점수 계산 const results = []; Object.values(indexData.files).forEach(fileData => { const score = this.calculateBM25Score( queryTerms, fileData, indexData.terms, avgDocLength, totalDocs ); // 점수가 0보다 큰 경우만 결과에 포함 if (score > 0) { const highlightedContent = this.highlightMatches(fileData.content, queryTerms); results.push({ file: fileData.file, score: score, content: highlightedContent, extension: fileData.extension, size: fileData.size, mtime: fileData.mtime }); } }); // 점수 순으로 정렬 results.sort((a, b) => b.score - a.score); // 결과 제한 const limitedResults = results.slice(0, limit); this.debug(`검색 완료: ${limitedResults.length}개 결과`); return limitedResults; } catch (error) { this.debug(`검색 오류: ${error.message}`); throw error; } } // 파일별 검색 (특정 파일에서만 검색) async searchInFile(filePath, query) { try { const indexData = await this.loadIndex(); const fileData = indexData.files[filePath]; if (!fileData) { throw new Error(`파일이 인덱스에 없습니다: ${filePath}`); } const queryTerms = this.tokenizeQuery(query); const avgDocLength = this.calculateAverageDocumentLength(indexData); const totalDocs = indexData.stats.totalDocuments; const score = this.calculateBM25Score( queryTerms, fileData, indexData.terms, avgDocLength, totalDocs ); if (score > 0) { const highlightedContent = this.highlightMatches(fileData.content, queryTerms); return { file: fileData.file, score: score, content: highlightedContent, extension: fileData.extension, size: fileData.size, mtime: fileData.mtime }; } return null; } catch (error) { this.debug(`파일 검색 오류: ${error.message}`); throw error; } } // 인덱스 통계 조회 async getIndexStats() { try { const indexData = await this.loadIndex(); return { totalFiles: Object.keys(indexData.files).length, totalTerms: Object.keys(indexData.terms).length, indexSize: JSON.stringify(indexData).length, lastUpdated: indexData.stats.lastUpdated || 'Unknown' }; } catch (error) { this.debug(`통계 조회 오류: ${error.message}`); throw error; } } // 유사한 파일 찾기 async findSimilarFiles(filePath, limit = 5) { try { const indexData = await this.loadIndex(); const targetFile = indexData.files[filePath]; if (!targetFile) { throw new Error(`파일이 인덱스에 없습니다: ${filePath}`); } // 대상 파일의 상위 용어들 추출 const termEntries = Object.entries(targetFile.termFreq); termEntries.sort((a, b) => b[1] - a[1]); const topTerms = termEntries.slice(0, 10).map(entry => entry[0]); // 다른 파일들과 유사도 계산 const similarities = []; Object.values(indexData.files).forEach(fileData => { if (fileData.file !== filePath) { const avgDocLength = this.calculateAverageDocumentLength(indexData); const totalDocs = indexData.stats.totalDocuments; const score = this.calculateBM25Score( topTerms, fileData, indexData.terms, avgDocLength, totalDocs ); if (score > 0) { similarities.push({ file: fileData.file, score: score, extension: fileData.extension, size: fileData.size }); } } }); similarities.sort((a, b) => b.score - a.score); return similarities.slice(0, limit); } catch (error) { this.debug(`유사 파일 검색 오류: ${error.message}`); throw error; } } } module.exports = Searcher;