0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【初心者向け】Elasticsearch x Node.js x Express で本格検索アプリをゼロから作る完全ガイド

0
Posted at

はじめに

ゼロから動く商品検索アプリを作る初心者の記録 — ECE試験対策のポイントも随所に紹介します。

このプロジェクトは、Node.jsもElasticsearchも全くの初心者の私が2日間のセッションで作り上げたものです。目標は「Elastic認定エンジニア(ECE)試験の対策をしながら、両方を同時に学ぶこと」でした。コード・実行結果・ミス・重要概念をすべてこの記事にまとめます。

いきなりですが、完成品を見てください👇
image (2).png
image (1).png


目次

  1. プロジェクトのセットアップ
  2. Elasticsearchへの接続
  3. インデックスとマッピングの作成
  4. CRUD操作
  5. バルクインデックス
  6. 検索クエリ
  7. Boolクエリ
  8. アグリゲーション
  9. ソートとページネーション
  10. Express.js REST API
  11. フロントエンド
  12. 練習問題

1. プロジェクトのセットアップ

コードを書く前に、Node.js v20以上がインストールされていることを確認してください。

# プロジェクトの作成
mkdir elastic-node-app
cd elastic-node-app
npm init -y

# 依存パッケージのインストール
npm install @elastic/elasticsearch dotenv express cors

ベストプラクティス: 認証情報をコードに直接書かないでください。.envファイルを使い、.gitignoreに追加してGitHubにプッシュされないようにしましょう。

.envファイルの例:

ELASTIC_CLOUD_ID="your_cloud_id_here"
ELASTIC_API_KEY="your_api_key_here"

.gitignoreの例:

node_modules
.env

2. Elasticsearchへの接続

すべてのファイルで使う基本パターンです:

require('dotenv').config()
const { Client } = require('@elastic/elasticsearch')

const client = new Client({
  cloud: { id: process.env.ELASTIC_CLOUD_ID },
  auth: { apiKey: process.env.ELASTIC_API_KEY }
})

async function run() {
  const info = await client.info()
  console.log(info)
}

run().catch(console.error)

Node.jsの概念 — async/await:
サーバーやデータベースとの通信には時間がかかります。Node.jsではasync関数とawaitを使うことで、プログラムを止めずに非同期処理を行います。これはNode.jsで最初に理解すべき最重要パターンです。

node index.jsを実行すると、こんな出力が見られるはずです:

tagline: 'You Know, for Search'

3. インデックスとマッピングの作成

Elasticsearchのインデックスはデータベースのテーブルに相当します。マッピングは各フィールドのデータ型を定義します。

マッピング型 用途
text 全文検索(アナライズあり) 商品説明
keyword 完全一致・ソート・アグリゲーション カテゴリ名
float 小数を含む数値 価格
boolean 真偽値 在庫あり/なし
date 日付・時刻 作成日時
async function createIndex() {
  const response = await client.indices.create({
    index: 'products',
    body: {
      mappings: {
        properties: {
          name:        { type: 'text' },
          description: { type: 'text' },
          category:    { type: 'keyword' },
          price:       { type: 'float' },
          in_stock:    { type: 'boolean' },
          created_at:  { type: 'date' }
        }
      }
    }
  })
  console.log('インデックス作成完了:', response)
}

実行結果:

インデックス作成完了: { acknowledged: true, shards_acknowledged: true, index: 'products' }

ECE試験のポイント: インデックス作成後にシャード数を変更することはできません — これは試験でよく出る問題です!レプリカ数はいつでも変更できます。

Dev Toolsでインデックスを確認:

GET products

4. CRUD操作

// CREATE(作成)
await client.index({
  index: 'products',
  document: {
    name: 'MacBook Pro',
    description: 'プロ向けの高性能ノートパソコン',
    category: 'Electronics',
    price: 1999.99,
    in_stock: true,
    created_at: new Date()
  }
})

// READ(取得)
await client.get({ index: 'products', id: 'your_doc_id' })

// UPDATE(更新)
await client.update({
  index: 'products',
  id: 'your_doc_id',
  doc: { price: 1799.99, in_stock: false }
})

// DELETE(削除)
await client.delete({ index: 'products', id: 'your_doc_id' })

ECEの概念 — ドキュメントのバージョン管理:
すべてのドキュメントには、操作のたびにインクリメントされる_versionがあります。_seq_no_primary_termを組み合わせることで、モダンな楽観的同時実行制御が実現されます。これにより、複数ユーザーが同時に更新しても互いの変更を上書きしてしまうことを防ぎます。


5. バルクインデックス

Bulk APIを使うと、1回のリクエストで大量のドキュメントをインデックスできます。1件ずつ処理するよりはるかに高速です。

const operations = products.flatMap(product => [
  { index: { _index: 'products', _id: product.id } },
  product  // ドキュメント本体
])

const response = await client.bulk({ operations })

実行結果:

10件のドキュメントを正常にインデックスしました!
処理時間: 200 ms

ECE試験のポイント: Bulk APIは特定のフォーマットが必要です。ドキュメントごとに(1)アクション行と(2)ドキュメントデータの2行が必要です。試験でよく出る問題です!


6. 検索クエリ

// MATCH - 全文検索(アナライズあり)
{ match: { description: 'laptop' } }

// TERM - 完全一致(アナライズなし)
{ term: { category: 'Electronics' } }

// TERMS - 複数の値に一致
{ terms: { category: ['Electronics', 'Audio'] } }

// RANGE - 範囲検索
{ range: { price: { gte: 300, lte: 1000 } } }

// MATCH PHRASE - フレーズの完全一致
{ match_phrase: { description: 'noise cancelling' } }

// MULTI MATCH - 複数フィールドを横断して検索
{ multi_match: { query: 'professional', fields: ['name', 'description'] } }

// EXISTS - フィールドが存在するドキュメントを検索
{ exists: { field: 'price' } }

ECE試験のポイント — matchとtermの違い:
matchtextフィールドに使います。アナライズ(小文字化・トークン化など)を経て検索されます。
termkeywordフィールドに使います。アナライズなしで、文字通りの完全一致が必要です。
この使い分けを間違えると試験で失点します!


7. Boolクエリ

BoolクエリはECE試験で最も重要かつ頻出のトピックです。4つの句を組み合わせて複合クエリを作ります:

マッチ必須? スコアに影響? キャッシュ?
must ✅ はい ✅ はい ❌ いいえ
should ❌ いいえ ✅ はい ❌ いいえ
filter ✅ はい ❌ いいえ ✅ はい
must_not ✅ はい(除外) ❌ いいえ ✅ はい

覚え方(採用面接に例えると):

  • must → 必須要件(満たさなければ不採用)
  • filter → バックグラウンドチェック(合否のみ、スコアに影響しない)
  • should → あれば加点のスキル(なくても落とされない)
  • must_not → 絶対NG条件(即不採用)
await client.search({
  index: 'products',
  query: {
    bool: {
      must: [
        { match: { description: 'wireless' } }         // スコアに影響
      ],
      should: [
        { match: { description: 'noise cancelling' } } // マッチでスコアUP
      ],
      must_not: [
        { term: { in_stock: false } }                  // 在庫なしを除外
      ],
      filter: [
        { range: { price: { lte: 400 } } },            // スコア影響なし・キャッシュあり
        { terms: { category: ['Audio', 'Electronics'] } }
      ]
    }
  }
})

黄金ルール:

  • テキストの関連度で検索したい → must
  • 絞り込み条件(yes/no)を指定したい → filter
  • マッチしたものを上位に出したい → should
  • 特定の条件を除外したい → must_not

8. アグリゲーション

アグリゲーションはデータを集計・分析する機能です。SQLのGROUP BYに相当します。

SQL Elasticsearch
AVG(price) avgアグリゲーション
MIN(price) minアグリゲーション
MAX(price) maxアグリゲーション
SUM(price) sumアグリゲーション
GROUP BY category termsバケットアグリゲーション
await client.search({
  index: 'products',
  size: 0,  // ドキュメントは不要、集計結果のみ取得
  aggs: {
    avg_price: { avg: { field: 'price' } },
    price_stats: { stats: { field: 'price' } },
    by_category: {
      terms: { field: 'category', size: 10 },
      aggs: {
        avg_price_per_category: { avg: { field: 'price' } }
      }
    },
    price_ranges: {
      range: {
        field: 'price',
        ranges: [
          { key: 'バジェット',    from: 0,    to: 300  },
          { key: 'ミドルレンジ',  from: 300,  to: 1000 },
          { key: 'プレミアム',    from: 1000           }
        ]
      }
    }
  }
})

実行結果:

========== メトリクスアグリゲーション ==========
平均価格:   $822.99
最低価格:   $99.99
最高価格:   $1999.99

========== カテゴリ別 ==========
Electronics     | 件数: 5 | 平均: $1299.99
Audio           | 件数: 2 | 平均: $314.99
Accessories     | 件数: 1 | 平均: $99.99

========== 価格帯別 ==========
バジェット       | 件数: 2
ミドルレンジ     | 件数: 5
プレミアム       | 件数: 3

ECE試験のポイント:

  • アグリゲーション結果だけ必要な場合は必ずsize: 0を使う
  • statsアグリゲーションはcount・min・max・avg・sumをまとめて返してくれるので効率的
  • Rangeバケットのfromは以上(含む)、toは未満(含まない)
  • termsアグリゲーションはkeywordboolean・数値フィールドに使える。textフィールドには使えない!

9. ソートとページネーション

// 価格の安い順にソート
{ sort: [{ price: { order: 'asc' } }] }

// 複数フィールドでソート
{ sort: [{ category: { order: 'asc' } }, { price: { order: 'asc' } }] }

// 基本ページネーション - 1ページ目
{ sort: [...], from: 0, size: 3 }

// 基本ページネーション - 2ページ目
{ sort: [...], from: 3, size: 3 }

// 大規模データ向けカーソルベースページネーション
{ sort: [...], size: 3, search_after: [349.99, 4] }
from + size search_after
最大深度 10,000件 無制限
ランダムページアクセス ✅ 可能 ❌ 不可(前進のみ)
深いページでの性能 低下する 常に高速
適したデータ量 小規模 大規模

ECE試験のポイント: from + sizeのデフォルト上限は10,000件です。深いページネーションには必ずsearch_afterを使ってください。また、search_after使用時は必ず一意のフィールドをタイブレーカーとして追加してください!


10. Express.js REST API

const express = require('express')
const app = express()
app.use(cors())
app.use(express.json())

// 商品一覧の取得
app.get('/products', async (req, res) => {
  try {
    const { from = 0, size = 10, sort = 'price', order = 'asc' } = req.query
    const response = await client.search({
      index: 'products',
      from: parseInt(from),
      size: parseInt(size),
      sort: [{ [sort]: { order } }]
    })
    res.json({
      total: response.hits.total.value,
      products: response.hits.hits.map(hit => ({ id: hit._id, ...hit._source }))
    })
  } catch (err) {
    res.status(500).json({ error: err.message })
  }
})

app.listen(3000, () => console.log('サーバー起動中: http://localhost:3000'))

Expressでユーザー入力を受け取る3つの方法:

  • req.query → URLパラメータ:/products?sort=price&order=asc
  • req.params → URLパス:/products/:id
  • req.body → POST/PUTのJSONボディ
メソッド エンドポイント 説明
GET /products 商品一覧取得
GET /products/:id 商品1件取得
POST /products 商品作成
PUT /products/:id 商品更新
DELETE /products/:id 商品削除
POST /products/search Boolクエリで検索
GET /products/stats/aggregations 集計データ取得

11. フロントエンド

async function search() {
  const body = {}
  if (searchTerm) body.searchTerm = searchTerm
  if (category) body.category = category

  const response = await fetch('http://localhost:3000/products/search', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body)  // JavaScriptオブジェクトをJSON文字列に変換
  })

  const data = await response.json()  // JSONレスポンスをオブジェクトに変換
  renderProducts(data)
}

セキュリティノート: 本番環境では、XSS攻撃を防ぐために必ずユーザー入力をサニタイズしてください。ユーザーデータをそのままinnerHTMLに渡さず、textContentを使うか、DOMPurifyなどのライブラリを活用しましょう。


12. 練習問題

学習内容を定着させるには、VS CodeのThunder ClientでCRUD操作を手動で試してみましょう:

ステップ1 — 新しい商品を追加する

  • メソッド:POST | URL:http://localhost:3000/products
  • ボディ:{ "name": "商品名", "description": "...", "category": "Electronics", "price": 299.99, "in_stock": true }
  • レスポンスに自動生成された_idが含まれていることを確認しましょう!

ステップ2 — 価格を更新する

  • メソッド:PUT | URL:http://localhost:3000/products/YOUR_ID
  • ボディ:{ "price": 249.99 }

ステップ3 — 商品を削除する

  • メソッド:DELETE | URL:http://localhost:3000/products/YOUR_ID

ステップ4 — 全商品を再インデックス

  • ターミナルでnode bulkIndex.jsを実行して、元の10件の商品データを復元します

学んだこと まとめ

トピック Node.js Elasticsearch / ECE
セットアップ npm, dotenv, require() Cloud ID, APIキー, クライアント
データモデリング JSオブジェクト インデックス・マッピング・フィールド型
CRUD async/awaitパターン index, get, update, delete
バルク操作 flatMap() Bulk APIのフォーマット
検索 match, term, range, bool, BM25スコアリング
集計 toFixed(), forEach() メトリクス・バケットアグリゲーション
ページネーション from+size vs search_after, 1万件上限
API Express, req/res, REST
フロントエンド fetch(), JSON.stringify()

Node.js・Express・Elasticsearchで構築 · Elastic認定エンジニア試験対策として執筆 · 2026年5月

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?