UNPKG

@mtorange/mcp-local-file-search

Version:

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

267 lines (220 loc) 8.02 kB
const fs = require('fs-extra'); const path = require('path'); const natural = require('natural'); const FileParser = require('./file-parser'); class Indexer { constructor(targetDir, debugFile = null) { this.targetDir = targetDir; this.debugFile = debugFile; this.parser = new FileParser(debugFile); this.indexPath = path.join(targetDir, '.local-file-index.json'); this.tokenizer = new natural.WordTokenizer(); this.stemmer = natural.PorterStemmer; } // 디버그 로그 출력 debug(message) { if (this.debugFile) { const timestamp = new Date().toISOString(); const logMessage = `[${timestamp}] ${message}\n`; fs.appendFileSync(this.debugFile, logMessage); } } // 인덱스 파일 경로 생성 getIndexPath() { return this.indexPath; } // 기존 인덱스 로드 async loadExistingIndex() { try { if (await fs.pathExists(this.indexPath)) { const indexData = await fs.readJson(this.indexPath); return indexData; } } catch (error) { this.debug(`기존 인덱스 로드 오류: ${error.message}`); } return { files: {}, terms: {}, stats: {} }; } // 인덱스 저장 async saveIndex(indexData) { try { await fs.writeJson(this.indexPath, indexData, { spaces: 2 }); this.debug(`인덱스 저장 완료: ${this.indexPath}`); } catch (error) { this.debug(`인덱스 저장 오류: ${error.message}`); throw error; } } // 디렉토리의 모든 파일 탐색 async findFiles(dir) { const files = []; try { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); // 인덱스 파일은 무조건 제외 if (entry.isFile() && entry.name === '.local-file-index.json') { continue; } if (entry.isDirectory()) { // 숨김 디렉토리 및 node_modules 제외 if (!entry.name.startsWith('.') && entry.name !== 'node_modules') { const subFiles = await this.findFiles(fullPath); files.push(...subFiles); } } else if (entry.isFile()) { // 지원하는 파일만 포함 if (this.parser.isSupported(fullPath)) { files.push(fullPath); } } } } catch (error) { this.debug(`디렉토리 탐색 오류: ${dir} - ${error.message}`); } return files; } // 개선된 텍스트 토큰화 및 정규화 (한국어 + 영어 지원) tokenizeText(text) { const normalizedText = text.toLowerCase().trim(); // 한국어와 영어 모두 처리하기 위한 토큰 배열 let tokens = []; // 영어 단어 토큰화 const englishTokens = this.tokenizer.tokenize(normalizedText); if (englishTokens) { tokens = tokens.concat(englishTokens); } // 한국어 토큰화 (공백 기준 + 2자 이상 한글 추출) const koreanTokens = normalizedText.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 finalTokens; } // 문서의 용어 빈도 계산 calculateTermFrequency(tokens) { const termFreq = {}; tokens.forEach(token => { termFreq[token] = (termFreq[token] || 0) + 1; }); return termFreq; } // BM25 점수 계산을 위한 통계 업데이트 updateGlobalStats(indexData) { const { files, terms, stats } = indexData; // 문서 수 stats.totalDocuments = Object.keys(files).length; // 각 용어의 문서 빈도 계산 const termDocFreq = {}; Object.values(files).forEach(fileData => { const uniqueTerms = new Set(Object.keys(fileData.termFreq)); uniqueTerms.forEach(term => { termDocFreq[term] = (termDocFreq[term] || 0) + 1; }); }); // 용어 통계 업데이트 Object.keys(termDocFreq).forEach(term => { terms[term] = { documentFrequency: termDocFreq[term], idf: Math.log((stats.totalDocuments - termDocFreq[term] + 0.5) / (termDocFreq[term] + 0.5)) }; }); this.debug(`통계 업데이트 완료: ${stats.totalDocuments}개 문서, ${Object.keys(terms).length}개 용어`); } // 파일 인덱싱 async indexFile(filePath, existingIndex) { try { const stats = await fs.stat(filePath); const existingFileData = existingIndex.files[filePath]; // 파일이 변경되지 않았으면 기존 인덱스 사용 if (existingFileData && existingFileData.mtime === stats.mtime.toISOString()) { this.debug(`파일 변경 없음: ${filePath}`); return existingFileData; } // 파일 파싱 const parsedFile = await this.parser.parseFile(filePath); if (!parsedFile) { this.debug(`파일 파싱 실패: ${filePath}`); return null; } // 텍스트 토큰화 const tokens = this.tokenizeText(parsedFile.content); const termFreq = this.calculateTermFrequency(tokens); const fileData = { file: filePath, content: parsedFile.content, extension: parsedFile.extension, size: parsedFile.size, mtime: parsedFile.mtime.toISOString(), termFreq: termFreq, termCount: tokens.length }; this.debug(`파일 인덱싱 완료: ${filePath} (${tokens.length}개 토큰)`); return fileData; } catch (error) { this.debug(`파일 인덱싱 오류: ${filePath} - ${error.message}`); return null; } } // 메인 인덱싱 함수 async index(force = false) { try { this.debug(`인덱싱 시작: ${this.targetDir} (force: ${force})`); // 기존 인덱스 로드 let indexData = await this.loadExistingIndex(); if (force) { this.debug('강제 인덱싱 모드'); indexData = { files: {}, terms: {}, stats: {} }; } // 모든 파일 탐색 const allFiles = await this.findFiles(this.targetDir); this.debug(`발견된 파일: ${allFiles.length}개`); // 각 파일 인덱싱 const newFiles = {}; let indexedCount = 0; for (const filePath of allFiles) { const fileData = await this.indexFile(filePath, indexData); if (fileData) { newFiles[filePath] = fileData; indexedCount++; } } // 존재하지 않는 파일 제거 const existingFiles = Object.keys(indexData.files); const removedFiles = existingFiles.filter(file => !allFiles.includes(file)); if (removedFiles.length > 0) { this.debug(`제거된 파일: ${removedFiles.length}개`); removedFiles.forEach(file => { delete indexData.files[file]; }); } // 새로운 인덱스 데이터 설정 indexData.files = newFiles; // 전역 통계 업데이트 this.updateGlobalStats(indexData); // 인덱스 저장 await this.saveIndex(indexData); this.debug(`인덱싱 완료: ${indexedCount}개 파일 처리`); return { totalFiles: allFiles.length, indexedFiles: indexedCount, removedFiles: removedFiles.length, totalTerms: Object.keys(indexData.terms).length }; } catch (error) { this.debug(`인덱싱 오류: ${error.message}`); throw error; } } } module.exports = Indexer;