1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

一歩一歩学ぶNode.js製検索エンジンの作り方

Posted at

Group289.png

Leapcell: サーバーレスWebホスティングの最高の選択肢

純粋なNode.jsを使用したTF-IDFによる英語検索エンジンの構築:原理からCSV逆索引の実装まで

情報爆発の時代において、検索エンジンは人々が情報にアクセスするための中核的ツールとなっています。GoogleからBingに至るまで、これらの大規模検索エンジンは複雑な技術アーキテクチャに支えられていますが、その中核原理は基本的な技術スタックを使用して実装することができます。この記事では、純粋なNode.jsを使用し、サードパーティライブラリを一切使用せず、逆索引をCSVファイルに格納することで、TF-IDFアルゴリズムに基づく英語検索エンジンを一から構築する方法をご紹介します。この実践を通じて、情報検索の中核メカニズムを深く理解し、テキスト処理、重み計算、索引構築におけるキー技術を習得することができます。

検索エンジンとTF-IDFの基礎

検索エンジンは本質的に情報フィルタリングシステムであり、その中核機能はユーザーのクエリを受け取り、大量の文書から最も関連性の高い結果を迅速に検索し、ランク付けすることです。現代的な検索エンジンは4つのコアモジュールで構成されています:クローラー(データ取得)、インデクサー(検索構造の構築)、リトリーバー(クエリ処理)、ランキングアルゴリズム(結果最適化)。この簡易版では、ローカル文書をデータソースとして、インデクサーとリトリーバーに焦点を当てます。

TF-IDF(用語頻度-逆文書頻度)は情報検索分野における古典的な重み付けアルゴリズムで、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です。

この2つを掛け合わせると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, ' ');
  // 複数のスペースを1つに統合
  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格納

逆索引は検索エンジンの中核的データ構造で、用語とその用語を含む文書のリストおよびそれらの重みの間のマッピングを格納します。その構造は通常、用語、docId1:weight1、docId2:weight2、...となります。

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をプログラムのエントリーとして作成し、索引構築と検索の2つのモードをサポートします:

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 "クエリ用語"');
}

機能テストと検証

テストデータの準備

corpusディレクトリに3つの英語文書を作成します:

  • ai.txt: "Artificial intelligence is the simulation of human intelligence processes by machines, especially computer systems. These processes include learning, reasoning, and self-correction."(人工知能とは、機械、特にコンピュータシステムによる人間の知能プロセスの模倣です。これらのプロセスには学習、推理、自己修正が含まれます。)
  • ml.txt: "Machine learning is a subset of artificial intelligence focused on developing algorithms that learn from data. It enables computers to improve performance on a task through experience."(機械学習は、データから学習するアルゴリズムの開発に焦点を当てた人工知能のサブセットです。これにより、コンピュータは経験を通じてタスクの性能を向上させることができます。)
  • dl.txt: "Deep learning is a type of machine learning based on artificial neural networks with multiple layers. It excels at processing unstructured data like images and text."(ディープラーニングは、複数の層を持つ人工ニューラルネットワークに基づく機械学習の一種です。画像やテキストなどの非構造化データの処理に優れています。)

索引構築テスト

node run.js buildを実行すると、コンソールに処理過程が出力され、完了後index/inverted.csvに以下のような内容が生成されます(一部抜粋):

term,documents
"intelligence",ai.txt:0.057538;ml.txt:0.028769
"artificial",ai.txt:0.057538;dl.txt:0.038359
"machine",ai.txt:0.028769;ml.txt:0.028769;dl.txt:0.025573
"learning",ml.txt:0.057538;dl.txt:0.057538

検索機能テスト

node run.js search "machine learning"を実行すると、以下の結果が返されるはずです:

検索中: "machine learning"
検索結果(関連度順):
1. ml.txt (スコア: 0.003308)
2. dl.txt (スコア: 0.001462)
3. ai.txt (スコア: 0.000832)

結果は予想どおりです:ml.txt(機械学習)が最も関連性が高く、次にdl.txt(ディープラーニング)、ai.txt(人工知能)の関連性が最も低くなっています。

技術的な最適化と拡張方向

基本バージョンの限界

現在の実装には明らかな限界があります:

  • 簡易的なトークン化:スペースでの分割のみで、ハイフンで結ばれた単語(例:"state-of-the-art")、略語(例:"don't")などを処理していません。
  • 索引効率の低下:CSV形式ではクエリ時に全文スキャンが必要で、データセットが大きくなると性能が低下します。
  • 基本的なランキングアルゴリズム:単純な加重合計を使用しており、余弦類似度などのより正確なアルゴリズムは実装されていません。
  • ストップワード処理なし:"the"や"is"などの高頻度で意味の少ない単語が索引空間を占めています。

最適化実装計画

1. 高度なトークン化の実装

トークン化機能を改善して特殊なケースを処理します:

function advancedTokenize(text) {
  let cleaned = cleanText(text);
  // ハイフンと略語の処理
  cleaned = cleaned.replace(/-/g, ' ').replace(/'s/g, ' ');
  return cleaned.split(' ').filter(token => token.length > 1); // 1文字のトークンをフィルタリング
}

2. ストップワードフィルタリング

ストップワードリストを追加してフィルタリングします:

const STOP_WORDS = new Set([
  'the', 'and', 'of', 'to', 'a', 'in', 'is', 'it', 'you', 'that'
  // さらに多くのストップワードを追加可能
]);

function tokenizeWithStopWords(text) {
  return advancedTokenize(text).filter(token => !STOP_WORDS.has(token));
}

3. 索引形式の最適化

CSVをより効率的なキー値ストレージに変更(例:1行にterm|docId:weight|docId:weight)して解析速度を向上させます:

// 最適化された索引形式の生成
function buildOptimizedIndex(docVectors) {
  let indexContent = '';
  for (const term in index) {
    const docsStr = index[term]
      .map(item => `${item.docId}:${item.weight.toFixed(6)}`)
      .join('|');
    indexContent += `${term}|${docsStr}\n`;
  }
  return indexContent;
}

4. 余弦類似度ランキング

より正確な類似度計算を実装します:

function calculateCosineSimilarity(queryVector, docVector) {
  let dotProduct = 0;
  let queryNorm = 0;
  let docNorm = 0;
  
  // 内積とベクトルの大きさを計算
  for (const term in queryVector) {
    queryNorm += Math.pow(queryVector[term], 2);
    if (docVector[term]) {
      dotProduct += queryVector[term] * docVector[term];
    }
  }
  
  for (const term in docVector) {
    docNorm += Math.pow(docVector[term], 2);
  }
  
  // ゼロ除算を防止
  if (queryNorm === 0 || docNorm === 0) return 0;
  
  return dotProduct / (Math.sqrt(queryNorm) * Math.sqrt(docNorm));
}

技術的なまとめと応用シナリオ

この記事で実装した検索エンジンは簡易的ですが、現代的な検索エンジンのコアコンポーネント:テキスト処理、TF-IDF重み付け、逆索引、結果ランキングをすべて含んでいます。純粋なNode.jsで実装することで、外部ライブラリへの依存を回避し、各技術リンクの実装詳細を深く理解することができます。

この技術は以下の場面で応用可能です:

  • 小型文書ライブラリ検索:プロジェクト文書やヘルプシステムの内部検索など
  • 教育デモンストレーション:情報検索の核心原理を直感的に示す
  • 二次開発基盤:中国語形態素解析(ICUライブラリの追加またはカスタム解析が必要)、分散索引などのより複雑なシステムに拡張可能

技術進化の観点から、TF-IDFはBM25などのより高度なアルゴリズムの基礎であり、CSV索引はLuceneなどの専門的な索引ライブラリの簡易版と見なすことができます。これらの基本的な実装を理解することで、開発者はElasticsearchなどの高度なツールの動作原理をよりよく把握することができます。

Leapcell: サーバーレスWebホスティングの最高の選択肢

最後に、Node.jsサービスをデプロイするのに最適なプラットフォームを推奨します:Leapcell

brandpic7.png

🚀 お気に入りの言語で構築

JavaScript、Python、Go、またはRustで手軽に開発。

🌍 無料で無制限のプロジェクトをデプロイ

使用分だけ支払い—リクエストなし、料金なし。

⚡ 従量課金制、隠れたコストなし

アイドル料金なし、シームレスなスケーラビリティ。

Frame3-withpadding2x.png

📖 ドキュメントを探る

🔹 Twitterでフォロー: @LeapcellHQ

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?