순수 Node.js를 사용하여 원리부터 CSV 역 인덱스 구현까지 TF-IDF로 영어 검색 엔진 구축하기
Ethan Miller
Product Engineer · Leapcell

순수 Node.js를 사용하여 원리부터 CSV 역 인덱스 구현까지 TF-IDF로 영어 검색 엔진 구축하기
정보 폭발 시대에 검색 엔진은 사람들이 정보에 접근하는 핵심 도구가 되었습니다. Google에서 Bing에 이르기까지 이러한 대규모 검색 엔진은 복잡한 기술 아키텍처를 기반으로 하지만 핵심 원리는 기본 기술 스택을 사용하여 구현할 수 있습니다. 이 기사에서는 타사 라이브러리 없이 순수 Node.js를 사용하여 TF-IDF 알고리즘 기반 영어 검색 엔진을 처음부터 구축하고 역 인덱스를 CSV 파일에 저장하는 방법을 안내합니다. 이 연습을 통해 정보 검색의 핵심 메커니즘에 대한 깊은 이해를 얻고 텍스트 처리, 가중치 계산 및 인덱스 구성의 핵심 기술을 마스터하게 됩니다.
검색 엔진 및 TF-IDF의 기본 사항
검색 엔진은 기본적으로 사용자 쿼리를 수신하고, 방대한 문서에서 가장 관련성 높은 결과를 빠르게 찾아 순위를 매기는 정보 필터링 시스템입니다. 최신 검색 엔진은 크롤러(데이터 획득), 인덱서(검색 구조 구성), 검색기(쿼리 처리) 및 순위 알고리즘(결과 최적화)의 네 가지 핵심 모듈로 구성됩니다. 간소화된 버전에서는 로컬 문서를 데이터 소스로 사용하여 인덱서와 검색기에 중점을 둘 것입니다.
TF-IDF(Term Frequency-Inverse Document Frequency)는 1972년 Karen Spärck Jones가 제안한 정보 검색 분야의 고전적인 가중치 알고리즘이며 다양한 텍스트 시스템에서 널리 사용되고 있습니다. 핵심 아이디어는 다음과 같습니다. 단어가 문서에 대한 중요도는 문서에서 발생하는 빈도에 비례하고 모든 문서에서 발생하는 빈도에 반비례합니다.
-
용어 빈도(TF): 현재 문서에서 단어가 나타나는 횟수를 문서의 총 단어 수로 나눈 값입니다. 공식은 TF(t,d) = count(t,d) / totalTerms(d)입니다. 예를 들어 "machine"이 총 100단어의 문서에 5번 나타나면 TF 값은 0.05입니다.
-
역 문서 빈도(IDF): 단어가 포함된 문서 수의 역수의 로그입니다. 공식은 IDF(t) = log(totalDocs / docsWithTerm(t))입니다. 코퍼스에 1000개의 문서가 있고 그 중 20개에 "learning"이 포함되어 있으면 IDF 값은 log(1000/20) = log(50) ≈ 3.912입니다.
두 값을 곱하면 TF-IDF(t,d) = TF(t,d) × IDF(t)가 됩니다. 이 값이 높을수록 단어가 현재 문서를 더 잘 나타냅니다. 검색하는 동안 쿼리 용어와 문서 간의 TF-IDF 벡터 유사성(예: 코사인 유사성)을 계산하여 결과 순위를 매길 수 있습니다.
순수 Node.js를 사용한 구현 단계
환경 준비 및 프로젝트 구조
이 프로젝트는 Node.js 런타임(v14 이상이면 충분함)에만 의존하며 npm 패키지를 설치할 필요가 없습니다. 다음 디렉터리 구조를 만듭니다.
tfidf-search/
├── corpus/ # 영어 문서 저장
│ ├── doc1.txt
│ ├── doc2.txt
│ └── ...
├── index/ # 인덱스 파일 저장
│ └── inverted.csv
├── src/
│ ├── indexer.js # 인덱스 구성 프로그램
│ └── searcher.js # 검색 프로그램
└── run.js # 진입 파일
corpus
디렉터리에는 검색할 영어 문서가 저장되고 index
디렉터리는 역 인덱스의 CSV 파일을 저장하는 데 사용되며 src
에는 핵심 구현 코드가 포함되어 있습니다.
1. 텍스트 데이터 처리
텍스트 처리는 검색 엔진의 기초이며 문서 읽기, 정리 및 토큰화가 필요합니다. src/indexer.js
를 만들고 기본 처리 기능을 구현합니다.
// 문서 내용 읽기 const fs = require('fs'); const path = require('path'); function readDocuments(corpusPath) { const docs = []; const files = fs.readdirSync(corpusPath); for (const file of files) { if (file.endsWith('.txt')) { const content = fs.readFileSync( path.join(corpusPath, file), 'utf8' ); docs.push({ id: file.replace('.txt', ''), content: content }); } } return docs; }
텍스트 정리에는 구두점 제거, 소문자 변환 및 토큰화가 포함됩니다. 영어 토큰화는 비교적 간단하며 공백으로 분할하여 수행할 수 있지만 특수한 경우를 처리해야 합니다.
function cleanText(text) { // HTML 태그가 있는 경우 제거 let cleaned = text.replace(/<[^>]*>/g, ' '); // 소문자로 변환 cleaned = cleaned.toLowerCase(); // 구두점 및 특수 문자 제거 cleaned = cleaned.replace(/[^a-z0-9\s]/g, ' '); // 여러 공백을 하나로 병합 cleaned = cleaned.replace(/\s+/g, ' ').trim(); return cleaned; } function tokenize(text) { return cleanText(text).split(' '); }
2. TF-IDF 계산 구현
용어 빈도(TF) 계산
TF 계산은 문서에서 각 단어의 빈도를 계산해야 하며 다음과 같이 구현됩니다.
function calculateTF(tokens) { const tf = {}; const totalTerms = tokens.length; for (const token of tokens) { if (token) { // 빈 문자열 건너뛰기 tf[token] = (tf[token] || 0) + 1; } } // 빈도로 변환(단어 수 / 총 단어 수) for (const term in tf) { tf[term] = tf[term] / totalTerms; } return tf; }
역 문서 빈도(IDF) 계산
IDF는 먼저 각 단어가 포함된 문서 수를 계산해야 합니다.
function calculateDF(docs) { const df = {}; const totalDocs = docs.length; for (const doc of docs) { const tokens = new Set(tokenize(doc.content)); // 중복 제거 for (const token of tokens) { if (token) { df[token] = (df[token] || 0) + 1; } } } // IDF 계산: log(총 문서 수 / 용어를 포함하는 문서 수) const idf = {}; for (const term in df) { idf[term] = Math.log(totalDocs / df[term]); } return idf; }
문서 벡터 생성
TF 및 IDF를 결합하여 각 문서에 대한 벡터를 생성합니다.
function generateDocVectors(docs, idf) { const docVectors = {}; for (const doc of docs) { const tokens = tokenize(doc.content); const tf = calculateTF(tokens); const vector = {}; for (const term in tf) { vector[term] = tf[term] * idf[term] || 0; } docVectors[doc.id] = vector; } return docVectors; }
3. 역 인덱스 구성 및 CSV 저장
역 인덱스는 검색 엔진의 핵심 데이터 구조로, 용어와 용어를 포함하는 문서 목록(가중치 포함) 간의 매핑을 저장합니다. 그 구조는 일반적으로 다음과 같습니다. term,docId1
function buildInvertedIndex(docVectors) { const index = {}; // 메모리 내 역 인덱스 구축 for (const docId in docVectors) { const vector = docVectors[docId]; for (const term in vector) { if (!index[term]) { index[term] = []; } index[term].push({ docId, weight: vector[term] }); } } // CSV 형식으로 변환 let csvContent = 'term,documents\n'; for (const term in index) { const docsStr = index[term] .map(item => `${item.docId}:${item.weight.toFixed(6)}`) .join(';'); csvContent += `"${term}",${docsStr}\n`; } return csvContent; } // 인덱스를 CSV 파일에 저장 function saveIndex(csvContent, indexPath) { fs.writeFileSync(indexPath, csvContent, 'utf8'); }
CSV 형식을 선택하는 이유는 일반 텍스트의 강력한 가독성, 간단한 구현(직렬화 라이브러리 불필요) 및 Excel과 같은 도구로 직접 구문 분석하기 때문입니다. 그러나 단점은 쿼리 중에 전체 텍스트 스캔이 필요하며 나중에 더 효율적인 형식으로 최적화할 수 있다는 것입니다.
4. 전체 인덱스 구성 프로세스
위의 함수를 인덱스 구성 프로세스에 통합합니다.
async function buildIndex(corpusPath, indexPath) { try { console.log('문서 읽는 중...'); const docs = readDocuments(corpusPath); console.log('문서 빈도(DF) 계산 중...'); const idf = calculateDF(docs); console.log('문서 벡터 생성 중...'); const docVectors = generateDocVectors(docs, idf); console.log('역 인덱스 구축 중...'); const csvContent = buildInvertedIndex(docVectors); console.log('인덱스를 CSV에 저장 중...'); saveIndex(csvContent, indexPath); console.log(`인덱스 구성이 완료되었으며 ${docs.length}개의 문서를 처리했습니다.`); } catch (error) { console.error('인덱스 구성 실패:', error); } } module.exports = { buildIndex };
5. 검색 기능 구현
검색 프로그램은 인덱스를 로드하고, 쿼리를 구문 분석하고, 유사성을 계산하고, 결과를 반환해야 합니다.
// src/searcher.js const fs = require('fs'); const path = require('path'); // CSV 인덱스를 로드하여 메모리 내 객체로 변환 function loadIndex(indexPath) { const index = {}; const csvContent = fs.readFileSync(indexPath, 'utf8'); const lines = csvContent.split('\n'); // 헤더 건너뛰기 for (let i = 1; i < lines.length; i++) { const line = lines[i].trim(); if (!line) continue; // CSV 줄 구문 분석(인용된 경우 처리) const [termPart, docsPart] = line.split(','); const term = termPart.replace(/"/g, ''); // 따옴표 제거 if (docsPart) { index[term] = docsPart.split(';').map(item => { const [docId, weight] = item.split(':'); return { docId, weight: parseFloat(weight) }; }); } } return index; } // 쿼리 벡터 계산 function getQueryVector(query, idf) { const tokens = tokenize(query); const tf = calculateTF(tokens); const vector = {}; for (const term in tf) { vector[term] = tf[term] * (idf[term] || 0); } return vector; } // 검색 실행 function search(query, index, idf) { const queryVector = getQueryVector(query, idf); const results = {}; // 문서 점수 누적 for (const term in queryVector) { if (index[term]) { const queryWeight = queryVector[term]; for (const doc of index[term]) { // 간단한 유사성 계산: 가중치 곱의 합 const score = (results[doc.docId] || 0) + (queryWeight * doc.weight); results[doc.docId] = score; } } } // 정렬된 배열로 변환 return Object.entries(results) .map(([docId, score]) => ({ docId, score })) .sort((a, b) => b.score - a.score); } module.exports = { loadIndex, search };
6. 진입 프로그램 구현
프로그램 진입점으로 run.js
를 만들고 인덱스 구성 및 검색의 두 가지 모드를 지원합니다.
const { buildIndex } = require('./src/indexer'); const { loadIndex, search } = require('./src/searcher'); const { calculateDF, readDocuments } = require('./src/indexer'); const CORPUS_PATH = path.join(__dirname, 'corpus'); const INDEX_PATH = path.join(__dirname, 'index', 'inverted.csv'); // 명령줄 인수 처리 const args = process.argv.slice(2); if (args[0] === 'build') { buildIndex(CORPUS_PATH, INDEX_PATH); } else if (args[0] === 'search' && args[1]) { const query = args[1]; try { console.log(`검색 중: "${query}"`); const index = loadIndex(INDEX_PATH); const docs = readDocuments(CORPUS_PATH); const idf = calculateDF(docs); const results = search(query, index, idf); console.log('검색 결과(관련성별로 정렬): '); results.forEach((result, i) => { console.log(`${i + 1}. ${result.docId} (점수: ${result.score.toFixed(6)})`); }); } catch (error) { console.error('검색 실패:', error); } } else { console.log('사용법: '); console.log(' 인덱스 빌드: node run.js build'); console.log(' 검색: node run.js search "검색어