MongoDB: $facet + $bucket + テキスト/地理空間検索

最終更新:2026-08-26

高度な集計機能—$facet、$bucket、テキスト検索、地理空間クエリを習得し、包括的なデータ分析能力を構築します。

本コースの4大テーマ: $facet(並列マルチチャネル)は「単一クエリで多次元結果を取得」する課題に対処;$bucket(データバケティング)は「区間統計」の課題に対処;テキスト検索は「キーワード検索」の課題に対処;地理空間クエリは「距離と範囲」の課題に対処。これら4つのテーマは独立しているように見えますが、EC検索システムの完全な機能を構成します—検索結果 + ファセット統計 + 価格バケティング + 近隣店舗。

1. 学習内容

高度な検索の教育的価値: $facet + $bucket + $text + $geoNearはMongoDB検索の完全な機能を構成します—$facetは検索アーキテクチャの骨格(多次元並列処理)、$bucketはデータ分布分析のツール(ヒストグラム)、$textはキーワード検索のエンジン、$geoNearは位置サービスの中核です。これら4つの演算子を習得すれば、Elasticsearchに依存しない検索システムを構築できます—中小規模プロジェクトのインフラコストを大幅に削減します。

検索システムの進化: プロジェクト検索要件の進化—フェーズ1(MVP):シンプルな$match + $sort(カテゴリ/価格でフィルタリングとソート);フェーズ2(ファセット検索):+ $facet + $bucket(検索ページで多次元統計);フェーズ3(全文検索):+ テキストインデックス + $text(キーワード検索);フェーズ4(LBS):+ 2dsphere + $geoNear(近隣検索);フェーズ5(専門検索):Elasticsearchへの移行(中国語形態素解析、同義語、ピンイン)。ほとんどのプロジェクトはフェーズ3–4にとどまり、データ量または検索複雑さがMongoDBの能力を超えた場合のみフェーズ5に進みます。

$facetの本番環境での考慮事項: 本番環境での$facetには3つの重要な制限があります:1. メモリ制限:各サブパイプラインは100MBのメモリ制限を共有、全サブパイプラインの総出力はこの制限を超えられません。大規模データセットを扱う場合、$facet適用前に$matchでデータをフィルタリングする必要があります;2. サブパイプライン内で$collation(照合ルール)を使用しない、中国語ソートが必要な場合は$facet外で処理;3. サブパイプライン内で$lookupを避ける($facet内の$lookupは壊滅的なパフォーマンス問題を引き起こす可能性—各サブパイプラインが結合クエリを一度実行)。ベストプラクティス:$facet前に$matchまたは$projectを使用して入力量を削減、サブパイプラインは軽量な統計($group$bucket$count)に限定し、重い操作($lookup$sort)は$facet外に配置。


100%
graph TB
    Input[入力セット<br/>1000ドキュメント] --> Facet[$facet<br/>並列実行]

    Facet --> B1[分岐1<br/>topExpensive<br/>価格順(降順)上位10件]
    Facet --> B2[分岐2<br/>byCategory<br/>カテゴリ別統計]
    Facet --> B3[分岐3<br/>priceRanges<br/>価格別バケット]
    Facet --> B4[分岐4<br/>totalStats<br/>概要統計]

    B1 --> Output[多次元結果<br/>1つの結果を返す]
    B2 --> Output
    B3 --> Output
    B4 --> Output

    style Facet fill:#d4edda
    style Output fill:#cce5ff

$facet並列実行モデル: $facetの並列性は「マルチスレッド高速化」ではありません—MongoDBは単一スレッド内で各サブパイプラインを順次実行しますが、同じ入力スナップショットを共有するため、論理的には並列です。真のパフォーマンス上の利点は並列実行ではなく、「ネットワークラウンドトリップの削減」にあります—4つの統計次元は4回のクエリではなく1回のクエリで済みます。$facetのメモリ消費は全サブパイプラインの合計です—1,000ドキュメントを入力し、4つのサブパイプラインがそれぞれ1,000ドキュメントを処理する場合、総メモリ消費は約4,000ドキュメント分に相当。

$facet出力構造設計: $facetの出力はドキュメントで、キーはサブパイプライン名、値はサブパイプライン結果の配列です。サブパイプライン設計時、以下に留意:1. 各サブパイプラインは独立—heサブパイプラインの結果を参照できない;2. サブパイプラインの順序は結果に影響しない(論理的に並列実行);3. 任意の集計ステージ($match、$group、$sort、$lookup等)をサブパイプライン内で使用可能;4. 空結果は空配列[]を返し、nullではない。フロントエンドで$facet結果を解析する際、Object.keys()で各次元を走査。

$facetのコストと代替手段: $facetのコスト = 入力データ量 × サブパイプライン数。入力データ量が大きく(>100,000レコード)、サブパイプラインが多い場合(>5)、メモリ消費が制限を超える可能性があります。代替手段:1. 大きな$facet操作を複数の独立した集計クエリに分割—メモリ安全性のためにネットワーク効率を犠牲;2. $facetの前に$match/$projectを追加して入力量を大幅に削減;3. マテリアライズドビュー($merge/$out)で統計結果を事前計算。選択基準:小規模データ(<10,000レコード)には$facetを使用、大規模データにはマテリアライズドビューを使用。


2. $facet マルチチャネル並列処理

概念説明: $facetは同じ入力ドキュメントセットに対して複数の独立したサブパイプラインを並列実行できます。各サブパイプラインが独自の結果を生成し、最終的に単一のドキュメントにマージされます。これは検索結果ページ(リスト + ファセット統計 + バケティング + 総計)を構築する理想的なツールです—1回のクエリで複数のラウンドトリップを置き換えます。

動作原理: $facetはオブジェクトを受け取り、キーはサブパイプライン名、値はステージの配列です。MongoDBは同じ入力に対して全サブパイプラインを並列実行し、それぞれが独立にデータを処理します。出力はドキュメントで、各キーがサブパイプラインの結果配列に対応します。注意:$facet内のサブパイプラインは入力を共有しますが、互いの結果を参照できず、総メモリ消費は全サブパイプラインのメモリ使用量の合計です。

100%
sequenceDiagram
    participant Input as 入力: 1000商品
    participant F as $facet
    participant P1 as 子パイプライン1: topExpensive
    participant P2 as 子パイプライン2: byCategory
    participant P3 as 子パイプライン3: priceRanges
    participant P4 as 子パイプライン4: stats

    Input->>F: ドキュメントストリーム入力
    par 並列実行
        F->>P1: 1000ドキュメント → $sort + $limit → 10件
        F->>P2: 1000ドキュメント → $group → 8グループ
        F->>P3: 1000ドキュメント → $bucket → 5バケット
        F->>P4: 1000ドキュメント → $group → 1概要記事
    end
    P1-->>F: topExpensive: [...]
    P2-->>F: byCategory: [...]
    P3-->>F: priceRanges: [...]
    P4-->>F: stats: [...]
    F-->>Input: {topExpensive, byCategory, priceRanges, stats}
$facet サブパイプライン 典型的用途 一般的なステージ組み合わせ
結果リスト ページネーション $sort + $skip + $limit + $project
総数統計 ページネーション情報 $count
カテゴリ統計 ファセット別フィルタ $group + $sort
価格別バケット 価格範囲フィルタ $bucket / $bucketAuto
概要統計 集計指標 $group({ _id: null })

多次元分析アーキテクチャの原則: $facetの中核価値は「1クエリ、多次元結果」です—EC検索ページでは以下を同時表示が必要:商品リスト(ページネーション)、総数(ページネーション情報)、価格分布(フィルタ)、カテゴリ統計(サイドバー)。$facetがない場合、これは4つの別々のクエリ(4回のネットワークラウンドトリップ)が必要でした。$facetなら1回のクエリで全次元を返し、フロントエンドレンダリング速度が4倍向上します。

$facetのメモリ制約: $facetの並列実行は真の並列ではありません—MongoDBはサブパイプラインを定義順に実行しますが、同じ入力を共有します。メモリ消費は各サブパイプラインのメモリ使用量の合計:1,000ドキュメント × 4サブパイプライン = 4,000ドキュメントを処理するメモリが必要。軽減策:1. $facetの前に$matchを使用して入力量を削減;2. サブパイプライン内で$projectを使用してフィールドを絞り込み;3. サブパイプライン数を制限(3–5を推奨);4. 非常に大規模なデータセットではallowDiskUse: trueを設定。

$facetサブパイプラインの独立性: 各$facetサブパイプラインは完全に分離されています—他のサブパイプラインの結果を参照できず、中間計算も共有できません。複数のサブパイプラインで同じ前処理($projectによるフィールド抽出など)が必要な場合、各サブパイプラインで繰り返し実行が必要です。独立性の利点は依存関係のない並列性、欠点は冗長計算です。少量の冗長計算のオーバーヘッドは、シリアル実行の待機時間より遥かに低い—これが$facet設計の中核的なトレードオフです。

$facetサブパイプラインの設計パターン: $facetサブパイプラインの設計は「1パイプ1次元」の原則に従います—1. 結果リストパイプライン:$sort + $skip + $limit + $project(ページネーション表示用、$limitで結果数を制限必須);2. 総数カウントパイプライン:$count(数値のみ返す、最も軽量なパイプライン);3. カテゴリ統計パイプライン:$group + $sort + $limit(カテゴリフィールドでグループ化してカウント、$limitでTop Nを返す);4. 価格バケティングパイプライン:$bucket/$bucketAuto(範囲でデータをバケット化、ヒストグラムデータを出力);5. 概要パイプライン:$group({_id: null, avg: {$avg}, sum: {$sum}})(グローバル統計、1ドキュメントのみ返す)。これら5つのパイプラインは検索ページのデータ要件の90%をカバーします。

$facet実行モデルの詳細: $facetは並列に見えますが、MongoDBは単一スレッドで各サブパイプラインを順次実行します—真の並列性はシャードクラスタでのみ発生(各シャードが独自のデータを独立処理し、mongosが結果をマージ)。単一インスタンスまたはレプリカセット環境では、サブパイプラインは定義順に順次実行されます。しかし、$facetは複数の独立クエリより高速です—1. 入力データを1回のみスキャン(各サブパイプラインが同じデータ読み取りを共有);2. ネットワークラウンドトリップが1回のみ(クライアント→MongoDB→完全結果返却);3. 重複$match操作のオーバーヘッドを回避($facet前の$matchは1回のみ実行)。

多次元分析のビジネスシナリオ: $facetの最も典型的なビジネスシナリオはEC検索ページです—ユーザーが「スマートフォン」を検索すると、ページは以下を同時表示が必要:1. 商品リスト(ページネーション、ソート済み、画像と価格付き);2. 検索結果総数(「256件の商品が見つかりました」);3. 価格分布の棒グラフ(0–1,000、1,000–3,000、3,000以上の3つの範囲);4. ブランド分布(Huawei 30%、Apple 25%、Xiaomi 20%など);5. 画面サイズ分布など。従来のアプローチでは5クエリ × 50 ms = 250 msが必要でしたが、単一$facetクエリは約80 msで、「体感できる遅延」から「即座の応答」へとユーザー体験を変革します。

$facet vs 複数クエリ:トレードオフ: $facetは1クエリで多次元結果を返しますが、制限もあります—1. 全$facetサブパイプラインは同じ集計コール内にあり、オンデマンドでロードできません(例:ユーザーが商品リストのみを見て統計を見ない場合でも、統計パイプラインは実行される);2. $facet結果構造は固定で、次元を動的に追加できません(例:「色分布」次元を追加するには集計パイプラインを修正して再デプロイが必要);3. エラーハンドリングが困難—$facet内の任意のサブパイプラインが失敗すると集計全体が失敗(部分返却不可)。代替アプローチ:次元が動的に変化する可能性があるシナリオでは、高頻度次元を$facet内に配置(商品リスト + 総数)、低頻度次元は別のAPIに配置(ブランド分布/価格ヒストグラムは別エンドポイントでオンデマンド呼び出し)。これにより高頻度クエリの高速応答を確保しながら、低頻度クエリが$facetリソースを浪費するのを防止。

JAVASCRIPT
// === $facet 複数の独立パイプラインを並列実行 ===
db.products.aggregate([
  {
    $facet: {
      // 分岐1:価格順(降順)上位10件
      topExpensive: [
        { $sort: { price: -1 } },
        { $limit: 10 }
      ],
      // 分岐2:カテゴリ別統計
      byCategory: [
        { $group: { _id: '$category', count: { $sum: 1 } } }
      ],
      // 分岐3:価格範囲分布
      priceRanges: [
        {
          $bucket: {
            groupBy: '$price',
            boundaries: [0, 100, 500, 1000, 5000],
            default: '5000+',
            output: { count: { $sum: 1 } }
          }
        }
      ],
      // 分岐4:概要統計
      stats: [
        { $group: { _id: null, total: { $sum: 1 }, avgPrice: { $avg: '$price' } } }
      ]
    }
  }
]);
// 1回のクエリで全次元の結果を返す

$bucketの数学的原理: $bucketboundaries配列はN-1個のバケットを定義し、それぞれが左閉右開区間[boundaries[i], boundaries[i+1])です。ドキュメントはgroupBy値に基づいて対応するバケットに配置されます。例えば、boundaries=[0, 100, 500, 1000]は3つのバケットを定義:[0,100)、[100,500)、[500,1000)。$bucketの_idはバケットの左境界値です。$bucketAutoはデータ分布に基づいて自動的にバケット境界を選択し、各バケットにほぼ同数のドキュメントを含むようにします—これによりデータ分布が未知の探索的データ分析に適しています。

$bucketのビジネスシナリオ: $bucketの典型的なビジネスシナリオ—1. 価格範囲統計:ECプラットフォームが価格範囲(0–100、100–500、500–1,000)別に商品数をカウント、検索ページの価格フィルタに使用;2. 評価分布:レビューシステムが評価(1星/2星/3星/4星/5星)でバケット化し、評価棒グラフを表示;3. 年齢グルーピング:ユーザープロファイリングが年齢範囲(18–25、25–35、35–50、50+)でユーザーをグループ化、ターゲティング広告に使用;4. 時間グルーピング:ログを時間、日、または月でグループ化し、トレンドチャートに使用。$bucketは本質的に「連続値→離散区間」への変換です。

空バケットの扱い: $bucketは空バケット(count=0)を表示しますが、$bucketAutoは表示しません。これにはビジネス的意味があります—検索ページの価格フィルタは全価格範囲(空バケットを含む)を表示する必要があります、さもなければユーザーはどの範囲が利用可能か分かりません。評価分布チャートも全星評価のバーを表示する必要があります(0レビューのものを含む)。したがって、評価/価格フィルタリングシナリオには$bucket(既知の範囲)を使用し、探索的分析には$bucketAuto(データ分布パターンを自動検出)を使用します。

$bucket出力の拡張: $bucketの出力は$sum: 1のカウントだけでなく、他の集計も可能です—各価格範囲の平均評価(avgRating: {$avg: '$rating'})、最高価格(maxPrice: {$max: '$price'})、含まれる商品リスト(products: {$push: '$title'})など。出力を拡張することで$bucketは単純なカウントツールから多次元グルーピング統計ツールへと変革—1回のクエリで各区間の複数の統計指標を返せます。

$facet + $bucket検索ページアーキテクチャ: EC検索ページのクラシックなアーキテクチャ—単一$facetクエリが返す:1. 検索結果リスト($sort + $skip + $limit);2. 総数($count);3. 価格分布($bucket、価格範囲別グルーピング);4. カテゴリ分布($group、カテゴリ別グルーピング);5. 評価分布($bucket、評価別グルーピング)。フロントエンドは1回のリクエストで全データを取得し、完全な検索ページをレンダリング—商品リスト、価格フィルタ、カテゴリサイドバー、評価棒グラフを含む。

$facetクエリのパフォーマンス考慮: $facetのサブパイプラインは並列実行されますが入力データを共有—1. 共有入力:$facet前の$matchステージは1回のみ実行されるため、全サブパイプラインが同じ入力データセットを処理;2. メモリ使用:各サブパイプラインが独自のメモリ状態を維持、Nサブパイプライン = N倍のメモリ使用(単一ステージ制限が100MBの場合、4サブパイプラインで400MB消費する可能性);3. 最適化戦略:サブパイプライン内で冗長$match操作を避ける($facet前で既にフィルタリング済み)、必要な$project/$group操作のみ実行;4. 代替アプローチ:$facetがメモリ制限を超える場合、複数の独立した集計クエリに分割可能(原子性とネットワークラウンドトリップ増加のコストで)。本番環境では、$facetは中小規模データセット(< 100,000マッチングドキュメント)に適用、大規模データセットではメモリ使用量を評価する必要があります。


3. $bucket データバケティング

バケティングの背後にある統計的原理: $bucketは本質的にヒストグラムデータ構造です—連続値範囲を離散区間に分割し、各区間のドキュメント数をカウントします。ヒストグラムはデータ探索の第一歩です:値の分布を観察することで、データが正規分布かどうか、外れ値があるか、正規化が必要かを判断できます。$bucketは既知の区間(価格範囲0–100、100–500、500–1000など)に適用、$bucketAutoは探索的分析(均等バケットを自動選択)に適用。

$bucket vs $switch:バケティングの比較: $bucketと$switchはどちらも範囲分類に使用できますが、設計目的が異なります—$bucketは統計ツール(各グループのカウントと集計値を出力)、$switchは分類ツール(各ドキュメントに分類ラベルを出力)。選択:ヒストグラムまたは統計が必要→$bucketを使用、各ドキュメントにラベルを付与が必要→$switch + $addFieldsを使用。

$bucketAutoのバケティングアルゴリズム: $bucketAutoは近似等深度バケティングアルゴリズムを使用—各バケットにほぼ同数のドキュメントを含むことを目標とし、等幅バケティング(各バケットが同じ値範囲)ではありません。例えば、商品価格が50–200の範囲に集中している場合、$bucketAutoはデータ密度が高い50–200範囲により多くのバケットを作成し、データが希薄な500–5000範囲にはより少ないバケットを作成します。これは等幅バケティングより多くの情報を提供—データ希薄領域に空バケットを作成し、データ密集領域に大量のデータを1つのバケットに詰め込むことを避けます。$bucketAutogranularitiesオプションはバケット数を制御(例:5、10、または20バケット)。

100%
graph LR
    A["price: 599"] --> B{どのバケット?}
    C["price: 29"] --> B
    D["price: 1299"] --> B
    E["price: 7500"] --> B

    B --> F["[0, 100): price=29 ✅"]
    B --> G["[100, 500): なし"]
    B --> H["[500, 1000): price=599 ✅"]
    B --> I["[1000, 5000): price=1299 ✅"]
    B --> J["default 'その他': price=7500 ✅"]

    style F fill:#d4edda
    style H fill:#d4edda
    style I fill:#d4edda
    style J fill:#fff3cd
比較次元 $bucket $bucketAuto
バケット境界 手動指定 自動計算
バケット数 境界数で決定 buckets: Nで指定
バケットサイズ 不均一の場合あり できるだけ均一
使用用途 既知の区間(価格帯) データ分布の探索
空バケットの扱い 空バケットを表示 空バケットを非表示

$bucket出力拡張: $bucketoutputパラメータで各バケット内で集計計算が可能—単純な$sum: 1カウントだけでなく、$avg(平均価格)、$push(バケット内ドキュメントの特定フィールドを収集)、$max/$min(バケット内の最大/最小値)も可能。典型的な使用法:$bucket + output: {count: {$sum: 1}, avgPrice: {$avg: '$price'}, topProduct: {$max: '$price'}}で単一バケット内のカウント、平均価格、最高額商品を返せます。これにより$bucketは「バケット別カウント」ツールだけでなく、「バケット別統計分析」ツールになります。

$bucketAutoのアルゴリズムと制限: $bucketAutoは近似等深度バケティングアルゴリズムを使用—各バケットに同数のドキュメントを含むことを目標とし、同じ幅の区間ではありません。これは価格0–100範囲に500ドキュメント(安価な商品が多いため)、5,000–10,000範囲に50ドキュメントしか含まれない可能性を意味します(高価な商品が少ないため)。制限:1. バケット境界は制御不能(自動計算、「0–100」や「100–500」のようなビジネス固有の範囲を指定できない);2. データに偏りがある場合パフォーマンスが低下(例:製品の99%が0–1,000価格帯、1%が10,000以上、自動バケティングは0–1,000範囲に多数の小さなバケットを作成);3. ビジネスユーザーへの提示には不適切(バケット境界が47.5–89.3のような任意の値、「0–100」や「100–500」のような直感的な範囲ではない)。$bucketAutoは探索的データ分析に適用、$bucketはビジネスユーザーに固定範囲を提示する場合に適用。

JAVASCRIPT
// === $bucket カスタムバケティング ===
db.products.aggregate([
  {
    $bucket: {
      groupBy: '$price',
      boundaries: [0, 100, 500, 1000, 5000, 10000],  // バケット境界
      default: 'その他',                                // 範囲外
      output: {
        count: { $sum: 1 },
        products: { $push: { sku: '$sku', title: '$title', price: '$price' } }
      }
    }
  }
]);
// [
//   { _id: 0, count: 120, products: [...] },
//   { _id: 100, count: 80, products: [...] },
//   ...
// ]

// === $bucketAuto 自動バケティング ===
db.products.aggregate([
  {
    $bucketAuto: {
      groupBy: '$price',
      buckets: 5  // 自動的に5バケットに分割
    }
  }
]);

検索アーキテクチャの選択: MongoDBテキスト検索 vs Elasticsearchは古典的なアーキテクチャ決定です。MongoDBテキスト検索の利点:1. 追加インフラ不要(データは既にMongoDBにある);2. CRUD操作と一貫性(同じクエリ言語);3. 小規模データセット(<100万レコード)で十分なパフォーマンス。Elasticsearchの利点:1. 中国語形態素解析(IK Analyzerなどの分かち書き器を使用);2. ファジーマッチングと同義語;3. ハイライト;4. 数十億規模のデータ対応。選択原則:シンプルな検索にはMongoDB、複雑な検索にはElasticsearchを使用。MongoDBテキスト検索から始め、検索要件が複雑になったらElasticsearchに移行可能。

転置インデックス構築プロセス: テキストインデックス作成時、MongoDBは各ドキュメントのテキストフィールドで字句解析を実行—1. トークン化(スペース/句読点で分割);2. ステミング(例:「running」→「run」、複数形→単数形);3. ストップワード除去(「the」、「a」、「an」など高頻度・無意味語);4. 転置インデックス構築(単語→ドキュメントIDリスト + 出現頻度)。クエリ時、$search句内の語もトークン化とステミングを行い、転置インデックスからマッチングドキュメントを取得。注意:CJK(中国語/日本語/韓国語)テキストは単語単位ではなく文字単位で分割(スペースベースのトークン化がないため)、結果が悪い—例えば複数文字のCJK単語は1文字ずつのエントリに分割され、単一の意味単位として認識されません。

BM25関連性スコア: MongoDBテキスト検索はBM25アルゴリズムを使用して関連性スコアを計算、3つの要因を考慮:1. 語頻度(TF):ドキュメント内での語の出現回数が多いほどスコアが高い;2. 逆文書頻度(IDF):全ドキュメントで出現頻度が低い(レアな)語ほどスコアが高い;3. ドキュメント長正規化:短いドキュメントでのマッチは長いドキュメントより価値が高いと見なされる。重み付きテキストインデックスのweightsパラメータはBM25計算に影響—重みが高いフィールドはマッチ時により多くスコアに寄与。


4. テキスト検索

検索システムの原理: MongoDBテキスト検索は転置インデックスに基づきます—インデックス作成時、テキストをトークン化・ステミングして「単語→ドキュメント」マッピングテーブルを構築。クエリ時、$search演算子でキーワードを指定し、転置インデックスでマッチングドキュメントを検索、TF-IDFアルゴリズムで関連性スコアを計算。制限:1. 中国語形態素解析をサポートしない(文字単位で分割、意味ベースのトークン化ではない);2. ファジーマッチングをサポートしない(Elasticsearchのファジークエリが必要);3. 同義語拡張をサポートしない;4. コレクションあたり1つのテキストインデックスのみ。

MongoDBテキスト検索とElasticsearchの使い分け: MongoDBテキスト検索が適しているのは—英語コンテンツの完全一致/フレーズ検索、小規模データセットの全文検索。Elasticsearchが適しているのは—中国語形態素解析、ファジー検索、同義語、ハイライト、ピンイン検索、マルチフィールド重み付け。判断基準:1. データ量 < 100,000で英語検索のみ→MongoDBで十分;2. 中国語形態素解析またはファジー検索が必要→Elasticsearch必須;3. 検索がコア機能→Elasticsearch;4. 検索が補助機能→MongoDBでシンプル実装。

MongoDB Atlas Searchという第3の選択肢: Atlas SearchはMongoDB Atlasクラウドサービスの組み込み検索エンジンです—Apache Lucene(Elasticsearchと同じ基盤エンジン)をベース—中国語形態素解析、ファジー検索、同義語、ハイライトなどの高度機能を提供し、MongoDBとシームレスに統合(外部クラスタへのデータ同期不要)。利点:1. データ同期遅延ゼロ(LuceneインデックスはMongoDBコレクションとリアルタイム同期);2. 集計パイプラインの$searchステージで直接使用(新しいクエリ構文を学習不要);3. 運用負担ゼロ(Atlasがホスティング、Elasticsearchクラスタ管理不要)。制限:1. Atlasクラウドサービスでのみ利用可能(セルフホストMongoDBでは非サポート);2. 追加コストが発生;3. 機能がElasticsearchほど包括的ではない(例:集計バケットやネストタイプなし)。中小規模プロジェクトでは、Atlas Searchが最適なバランスを提供—Elasticsearchクラスタの維持不要で、プロフェッショナルグレードの検索機能を利用可能。

検索システム選択決定のまとめ: 3つの検索ソリューションの選択—1. MongoDBネイティブ$text:コストゼロ、メンテナンスゼロ、機能限定(英語検索、形態素解析なし、ファジーマッチングなし)、検索が補助機能の小規模プロジェクトに適用;2. Atlas Search:メンテナンス低、コスト中程度、機能充実(中国語形態素解析、ファジーマッチング、ハイライト)、Atlasユーザーの中規模プロジェクトに適用;3. Elasticsearch:運用オーバーヘッド高、コスト高、機能最高(全検索機能 + Kibana分析)、検索がコア機能の大規模プロジェクトに適用。選択基準:データ量(小→MongoDB、大→ES)、言語(英語のみ→MongoDB、中国語→ES/Atlas)、検索の重要性(補助→MongoDB、コア→ES)、運用能力(限定的→Atlas、強力→ES)。

100%
graph TD
    A[ドキュメント: title='Smartphone X 5G'<br/>description='Latest 5G phone'] --> B[テキストインデックス<br/>字句解析]
    B --> C[転置インデックス<br/>smartphone → doc1<br/>phone → doc1<br/>5g → doc1<br/>latest → doc1]

    D["$search: 'phone'"] --> E[転置インデックス検索<br/>phone → doc1]
    E --> F[✅ ヒット]

    G["$search: 'phone -cheap'"] --> H[phone → doc1<br/>cheap除外含む]
    H --> F

    style C fill:#d4edda
    style F fill:#d4edda
検索タイプ 構文 説明
単語レベル検索 $search: 'phone smartphone' いずれかの単語にマッチ(OR)
フレーズ検索 $search: '"smartphone 5g"' 完全なフレーズである必要がある
除外検索 $search: 'phone -cheap' "phone"を含むが"cheap"を除外
関連性順ソート score: { $meta: 'textScore' } 関連性でソート

(1) テキストインデックス作成

テキストインデックス設計戦略: 各コレクションは最大1つのテキストインデックスを持てますが、複数フィールドをカバーし重み付けが可能—重みが高いフィールドはマッチ時に高いtextScoreを受け取り、検索結果ランキングがより合理的になります。設計考慮:1. タイトルは説明より高い重みを付ける(検索時、タイトルマッチの方がユーザーにとって重要);2. 情報量が低いフィールド(例:statuscategory)にはテキストインデックスを作成しない;3. テキストインデックスはかなりのストレージ容量を消費(データ量の約2–3%)、大規模コレクションでは慎重に使用;4. 複合インデックスとテキストインデックスは同じクエリ内で共存できない。

JAVASCRIPT
// === テキストインデックス作成(単一フィールド)===
db.products.createIndex({ title: 'text' });

// === マルチフィールドテキストインデックス ===
db.products.createIndex({
  title: 'text',
  description: 'text',
  tags: 'text'
});

// === 重み付きテキストインデックス ===
db.products.createIndex(
  {
    title: 'text',
    description: 'text'
  },
  {
    weights: {
      title: 10,        // タイトルは高重み
      description: 1
    },
    name: 'TextIndex'
  }
);

(2) $text検索

$textの4つの検索モード: $textは4つの検索構文をサポート—1. 単語レベル検索(デフォルトORセマンティクス):'phone smartphone'はどちらかの単語を含むドキュメントにマッチ;2. フレーズ検索(ダブルクォート):'"smartphone 5g"'は正確なフレーズを含む必要がある;3. 除外検索(マイナス記号):'phone -cheap'は"phone"を含むが"cheap"を含まない;4. 関連性ソート:$meta: 'textScore'でスコアを取得し結果をソート。注意:$text検索はワイルドカード(*)、ファジーマッチング(~)、正規表現をサポートしない。

テキストインデックス構築と維持のコスト: テキストインデックス構築コストは通常インデックスより高い—1. インデックスサイズ:テキストインデックスは各フィールドをトークン化して転置インデックスを作成、インデックスサイズは元データの30–50%に達する可能性(通常インデックスは約10%);2. 構築時間:大規模データセット(> 100万ドキュメント)のテキストインデックス構築は数分から数時間かかり、オフピーク時間に実行すべき;3. 書き込みパフォーマンス:テキストフィールドを含むドキュメントの挿入/更新のたびに転置インデックスを更新、書き込みレイテンシが10–30%増加;4. 制限:各コレクションに1つのテキストインデックスのみ(複数フィールドをカバー可能)、$orクエリ内のテキスト検索をサポートしない。これらのコストは、本当に全文検索が必要なフィールドにのみテキストインデックスを作成し、全文字列フィールドに作成しないべきことを意味します。

JAVASCRIPT
// === 基本検索 ===
db.products.find({ $text: { $search: 'phone smartphone' } });
// titleまたはdescriptionに"phone"または"smartphone"を含むドキュメントにマッチ

// === フレーズ検索 ===
db.products.find({ $text: { $search: '"smartphone 5g"' } });
// 完全なフレーズを含む必要がある

// === 除外検索 ===
db.products.find({ $text: { $search: 'phone -cheap' } });
// phoneを含むがcheapを含まない

// === 関連性順ソート ===
db.products.find(
  { $text: { $search: 'phone' } },
  { score: { $meta: 'textScore' } }
).sort({ score: { $meta: 'textScore' } });

地理空間クエリパフォーマンス最適化: 2dsphereインデックスは地理空間クエリパフォーマンスの鍵です—これがないと、$geoNearと$nearSphereは全コレクションスキャンを実行します。最適化のヒント:1. $geoNearはパイプラインの最初のステージである必要がある(さもなければインデックスを使用できない);2. maxDistanceが小さいほどスキャン範囲が小さく、パフォーマンスが良い;3. 複合インデックス{location: '2dsphere', category: 1}は「特定カテゴリの近隣店舗」クエリをサポート;4. $geoWithin$geoNearより高速(ソートなし)、純粋な範囲クエリには優先使用。

中国語検索の代替手段: MongoDBテキストインデックスは中国語のサポートが限定的—文字単位で分割し、単語単位ではないため、「複数文字のCJK単語」は4つの1文字エントリに分割され、複合語の検索でマッチしない。3つの代替手段:1. 正規表現検索$regex: /単語/—シンプルだが結果をソートできず、インデックスを使用しない;2. アプリケーション層の形態素解析—挿入時にjiebaなどの分かち書き器で前処理し、分割結果を配列フィールドに保存、マルチキーインデックスでクエリ;3. Elasticsearch—プロフェッショナルな検索エンジンで、中国語分かち書き器(IK Analyzer)を内蔵し、ピンイン検索と同義語拡張をサポート。本番環境ではオプション3が推奨。

中国語形態素解析の実践的アプローチ: 中国語検索の中核課題は「単語」に自然な区切りがないこと—1. jieba形態素解析アプローチ:ドキュメント挿入時にNode.jsのnodejiebaで分割し、結果をsegmentedTitle: ['単語1', '単語2', '複合']フィールドに保存、マルチキーインデックスを作成、クエリ時に検索語を分割して$allでマッチ;2. N-gramアプローチ:$regexでn-gramマッチングを使用(例:/単語1.{0,2}単語2/)。ファジーマッチングをサポートするがパフォーマンスが低い(全件スキャン);3. ピンイン検索:追加でpinyinTitleフィールドを保存(pinyinライブラリで変換)、ピンイン入力で中国語商品を検索可能に;4. Elasticsearchソリューション:MongoDB→Elasticsearch同期パイプラインを作成(Change StreamまたはMongoSyncを使用)。クエリはES経由で、データはMongoDBに保存。ソリューション1は中小規模プロジェクトに適用(コストゼロ)、ソリューション4は大規模プロジェクトに適用(プロフェッショナルな検索体験)。

2dsphereインデックスの内部構造: 2dsphereインデックスは地理座標をGeohashにエンコード—地球表面の再帰的グリッド分割で、各グリッドセルが文字列で表現されます。クエリ時、$geoNearは中心点のGeohashに基づいて近隣グリッドセルを特定し、次に球面距離を精密に計算します。Geohashのプレフィックスマッチング特性により範囲クエリがインデックスを効率的に活用可能—「5km以内」をクエリする際、全データセットではなく数個の隣接グリッドをスキャンするだけで済みます。

距離計算の精度: MongoDBは球面幾何(WGS84楕円体)を使用して距離を計算—$geoNearが返すdistanceFieldはメートル単位で、精度は約0.5メートルです。$geoWithin$centerSphereの半径はラジアン—キロメートルからラジアンへの変換はkm / 6378.1を使用。注意:6378.1は地球の赤道半径(km)。高緯度地域でこの変換を使用するとわずかな誤差が生じる可能性(極半径は6356.8 km)が、LBSアプリケーションへの影響は無視できます。

検索 + LBS統合アーキテクチャ: EC検索システムは多くの場合、「キーワード検索」と「近隣検索」を同時にサポートする必要があります—例えば「3km以内の5Gスマートフォン」。これら2種類の検索の統合アプローチ:1. まず$text検索を実行、次に$geoNearで距離フィルタリング(検索結果が少なく、近隣フィルタリングが高速なシナリオに適用);2. まず$geoNear近隣検索、続いて$matchキーワード検索(近隣結果が少なく、キーワードフィルタリングが高速なシナリオに適用);3. $facetで両方の検索を並列実行し結果をマージ(最も柔軟だがメモリを多く消費)。アプローチ1が最も一般的—ユーザーはまず関連商品を見て、その後距離でソート。


5. 地理空間クエリ

地理空間インデックスの原理: 2dsphereインデックスは球面座標(経度/緯度)をGeohashにエンコード—地球表面を再帰的にグリッド分割し、各グリッドを文字列でエンコードするシステムです。近傍クエリでは、システムはまずGeohashプレフィックスが同じグリッドをマッチング(粗いフィルタリング)、次に球面距離を精密に計算(詳細フィルタリング)。この2段階戦略により$geoNearと$nearSphereのクエリ複雑さがO(N)からO(log N)に削減されます。

地理空間クエリ演算子の選択: 3つのクエリ演算子はそれぞれ独自の用途があります—$geoNearは集計フェーズで使用、距離フィールドを出力、ソートとフィルタリングをサポート、「近隣検索 + 結果ソート」シナリオに最適;$nearSphereはクエリ演算子で構文がシンプルだが距離値を出力しない、「範囲内かどうか」の判定に適用;$geoWithinは結果をソートせず、「領域内の全結果」をバッチ取得(配送エリアなど)に適用。

演算子 距離出力 ソート 集計互換性 使用用途
$geoNear あり LBS検索
$nearSphere なし 距離チェック
$geoWithin あり 領域別バッチ

$geoNearの独自制限: $geoNearは集計パイプラインの最初のステージである必要があります—これはハード制限で、$geoNearは2dsphereインデックスをルートから走査する必要があるためです。先に$matchフィルタリングを適用する必要がある場合、$geoNearのqueryオプションで指定する必要があり、$matchステージを$geoNearの前に配置できません。$geoNearのdistanceFieldは球面距離(メートル単位)を出力し、distanceMultiplierで単位変換可能(例:× 0.001でキロメートルに変換)。includeLocs: trueはマッチング座標点を出力(複数位置を持つドキュメントに有用—例:企業が複数の支店位置を持つ場合)。

LBSシステムのアーキテクチャモデル: 完全なLBS(位置情報サービス)システムは3層で構成:1. データ層:2dsphereインデックス + 地理座標保存;2. クエリ層:$geoNear/$geoWithinで近隣検索と領域クエリ;3. アプリケーション層:距離ソート + ページネーション + 結果キャッシュ。典型的なビジネスフロー:ユーザーが「近隣カフェ」を開く→ユーザー位置を取得→$geoNearで3km以内を検索→距離でソート→上位20件を返却。主要パフォーマンス要因:2dsphereインデックス + maxDistance制限 + limitで返却結果数を制御。

LBSキャッシュ戦略: LBSクエリのキャッシュは通常クエリより複雑—位置は連続値で、隣接位置の検索結果は大幅に重複するためです。キャッシュ戦略:1. 地理グリッドキャッシュ(地図を1km × 1kmのグリッドに分割し、各グリッドの検索結果をキャッシュ、同じグリッド内のクエリはキャッシュ結果を返す);2. ユーザーベースキャッシュ(「ユーザーが最近検索した位置」の結果を5分TTLでキャッシュ);3. 人気エリアキャッシュ(都市中心部などの高頻度検索エリアを事前計算、低頻度エリアはリアルタイムクエリ)。注意:キャッシュ有効期限—店舗情報が変更された場合、影響を受けるグリッドのキャッシュをクリア必要。

2dと2dsphereインデックスの選択: MongoDBは2種類の地理空間インデックスを提供—2d(平面座標、平面マップやゲームシーンに適用、従来の座標ペア[x, y]で保存)、2dsphere(球面座標、実世界の地球座標に適用、GeoJSON Point {type: 'Point', coordinates: [lng, lat]}で保存)。LBSシナリオの99%は2dsphereを使用すべき—1. 真の球面距離計算をサポート(2dはユークリッド距離を使用、高緯度で大きな誤差);2. $geoNear、$geoWithin、$nearなど全地理空間演算子をサポート(2dは$nearの一部機能のみ);3. 複数のGeoJSONシェイプ(Point/LineString/Polygon)をサポート。2dはゲームマップなどの純粋な平面シナリオのみに使用。

(1) 2dsphereインデックス作成

JAVASCRIPT
// === 位置フィールド追加 ===
db.stores.insertOne({
  name: '東京ストア',
  location: {
    type: 'Point',
    coordinates: [139.6917, 35.6895]  // [経度, 緯度]
  }
});

// === 2dsphereインデックス作成 ===
db.stores.createIndex({ location: '2dsphere' });

(2) 地理的クエリ

GeoJSON座標形式の重要ポイント: MongoDB地理空間クエリはGeoJSON形式を使用—type: 'Point' + coordinates: [lng, lat]。2つのよくあるエラー:1. 経度と緯度の順序が逆(Google Mapsはlat/lngを使用、MongoDBはlng/latを使用、要注意);2. 座標値が範囲外(経度-180~180、緯度-90~90)。$geoNearのデフォルト距離単位はメートル(spherical: trueの場合);maxDistance: 5000は5キロメートルの範囲を示します。

$centerSphere ラジアン変換: $geoWithinと$centerSphereの半径単位はラジアン—1ラジアン ≈ 6,378.1キロメートル(地球の赤道半径)。変換式:ラジアン = キロメートル / 6378.1。例:5キロメートル = 5/6378.1 ≈ 0.000784ラジアン。この変換はエラーを起こしやすいので、ユーティリティ関数としてカプセル化を推奨:kmToRadians(km) { return km / 6378.1; }。

JAVASCRIPT
// === $geoNear 近隣検索 ===
db.stores.aggregate([
  {
    $geoNear: {
      near: { type: 'Point', coordinates: [139.6917, 35.6895] },  // 東京座標
      distanceField: 'distance',     // 出力フィールド
      maxDistance: 5000,             // 最大距離5km(メートル)
      spherical: true
    }
  }
]);

// === $geoWithin + $centerSphere 範囲クエリ ===
db.stores.find({
  location: {
    $geoWithin: {
      $centerSphere: [
        [139.6917, 35.6895],         // 中心点
        5 / 6378.1                   // 5km半径(ラジアン)
      ]
    }
  }
});

// === $nearSphere シンプルクエリ ===
db.stores.find({
  location: {
    $nearSphere: {
      $geometry: { type: 'Point', coordinates: [139.6917, 35.6895] },
      $maxDistance: 5000
    }
  }
});

$geoWithinパフォーマンス特性: $geoWithinはソートや距離計算を行いません—ある点が領域内にあるかどうかを判定するのみなので、$geoNearよりはるかに高速です。$geoWithinは「領域内にいくつあるか」という統計クエリ(例:「配送エリア内の店舗」)に適用、$geoNearは「最も近いN件」というソート済みクエリ(例:「最も近い5件のコーヒーショップ」)に適用。$geoWithinは3種類の領域シェイプをサポート:$centerSphere(円)、$polygon(ポリゴン)、$box(矩形)。

$geoWithinと$geoNearの選択: どの演算子を選択するかはビジネス要件に依存—1. 「最も近いN件」が必要→$geoNear(距離順ソート + limit);2. 「領域内の全結果」が必要→$geoWithin(ソートなし、より良いパフォーマンス);3. 距離値が必要→$geoNear(distanceFieldを出力);4. $facetと組み合わせ必要→$geoWithin($geoNearはパイプラインの最初のステージでなければならないため$facet内で使用不可);5. ポリゴン領域クエリ→$geoWithin + $polygon($geoNearは円形領域のみサポート)。配送エリア(不規則なポリゴン)には$geoWithinを、近隣検索(円形領域)には$geoNearを使用。

LBSシステムのキャッシュ戦略: LBSクエリ結果はキャッシュ可能—同じ位置のユーザーの検索結果は短時間変わらないため—1. キャッシュキー設計:lbs:{lat}:{lng}:{radius}:{category}:{keyword}、全クエリパラメータをキャッシュキーにエンコード;2. キャッシュ粒度:緯度経度は小数点3桁まで保持(約110メートル精度)、同じグリッド内のユーザーはキャッシュを共有;3. TTL設定:3–5分(店舗位置は短時間変わらないが、新規オープン店舗は迅速に表示必要);4. キャッシュ事前ロード:人気商業エリア(北京の三里屯、上海の南京路など)の検索結果を事前キャッシュ;5. 有効期限戦略:新店舗がオープンしたら、該当エリアのキャッシュをクリア。LBSキャッシュはデータベースクエリを80%以上削減でき、LBSシステムパフォーマンス最適化の主要手法です。

ラジアン変換クイックリファレンス: $centerSphereと$nearSphereの距離パラメータはラジアンを使用—km → ラジアン = km / 6378.1、マイル → ラジアン = miles / 3963.2。よく使う変換:1 km ≈ 0.00015696ラジアン、5 km ≈ 0.0007848ラジアン、10 km ≈ 0.00157ラジアン。$geoNearmaxDistanceパラメータはメートルを使用(変換不要)、これが$geoNearが$nearSphereより使いやすい理由の1つです。

LBSシステムでの距離計算精度: MongoDB地理空間クエリはデフォルトで球面幾何(WGS84楕円体で近似)を使用—精度は赤道付近で最高(誤差 < 0.5%)、高緯度ではわずかに低下するが、平面幾何を使用した計算より遥かに優れています。2dsphereインデックスの距離単位はメートル、$geoNeardistanceField出力もメートルです。一般的な精度考慮事項:1. 短距離(< 100 m)では十分な精度(誤差 < 1 m);2. 長距離(> 1,000 km)では数十メートルの誤差が生じる可能性(球面近似誤差による);3. 極を跨ぐクエリで精度が最低(ただしこのようなシナリオは極めて稀)。LBSアプリケーションの99%(近隣レストランや店舗の検索)では、MongoDBの距離精度は完全に要件を満たします。



6. 総合実践トレーニング

概念概要: この総合演習は$facet、$bucket、テキスト検索、地理空間クエリを組み合わせて、完全なEC検索システムを構築します。単一$facetクエリが検索結果リスト、総数、価格バケット、カテゴリ統計を同時に返し、フロントエンドが検索ページを直接レンダリングできます。地理空間クエリはLBS(位置情報サービス)シナリオをサポート。

動作原理: 検索APIの$facetクエリは$match(テキスト検索 + カテゴリフィルタリング)を共通入力とし、4つのサブパイプラインが並列処理:item(ページネーションリスト)、total(総数)、facets(価格バケット)、categories(カテゴリ統計)。地理空間クエリは独立実行され、距離順でソートされた結果を返します。

検索APIアーキテクチャ設計の要点: 包括的検索APIの中核課題は「単一リクエストで全次元のデータを返す」こと—フロントエンドはリスト(検索結果をレンダリング)、総数(ページネーション用)、バケティング(価格/評価フィルタ)、カテゴリ化(サイドバーナビゲーション)を必要とします。$facetでこれら4次元を1つの入力に対して並列計算でき、4回の別々クエリを回避。ただし、$facet前の$matchがテキスト検索とビジネスフィルタ(カテゴリや価格範囲など)の両方を満たす必要があることに注意、さもなければ各サブパイプラインが見る入力が不一致になります。

検索結果ソート戦略: 検索結果のソート順序はユーザー体験に直接影響—コンテキストに基づいてソート戦略を選択すべき:1. 関連性ソート($meta: 'textScore'):ユーザーがキーワードを検索する場合、最も関連性の高い結果が上位に表示されることを期待;2. 距離ソート($geoNear):近隣店舗を検索する場合、最も近いものが上位に表示されることを期待;3. 価格ソート:価格比較する場合、最安値または最高値が上位に表示されることを期待;4. 販売数ソート:購入する場合、人気商品が上位に表示されることを期待;5. 総合ソート(重み付け式):$addFieldsで複合スコア = 0.4 × 関連性 + 0.3 × 販売数 + 0.2 × 評価 + 0.1 × 新鮮度を計算。総合ソートがEC検索の究極のソリューションです。

検索システムのユーザー体験設計: 検索は「キーワードを入力して結果を返す」だけでなく—完全な検索体験は以下を含み:1. 検索候補(入力中にオートコンプリート);2. 検索結果(リスト + ソート);3. ファセットフィルタリング(カテゴリ/価格/ブランドフィルタ);4. 結果統計(総数/カテゴリ別分布);5. 空結果の処理(人気商品を推奨またはキーワード修正を提案)。MongoDBの$facetは項目2、3、4のデータを1回のクエリで提供でき、検索候補と空結果処理は追加のクエリとビジネスロジックが必要。

100%
graph TB
    A[検索リクエスト<br/>q=5Gスマホ<br/>category=Electronics] --> B[$match<br/>$text + category]
    B --> C[$facet]

    C --> D[items<br/>$sort+skip+limit<br/>ページネーションリスト]
    C --> E[total<br/>$count<br/>総数]
    C --> F[facets<br/>$bucket<br/>価格別バケット]
    C --> G[categories<br/>$group<br/>カテゴリ別統計]

    D --> H[検索結果ページ<br/>1つの結果を返す]
    E --> H
    F --> H
    G --> H

    style C fill:#d4edda
    style H fill:#cce5ff

(1) 商品検索 + ファセット統計

ファセット検索のビジネス価値: ファセット検索はEC検索の中核的なインタラクションモデルです—ユーザーがキーワードを入力すると、ページは同時に検索結果と各次元のフィルタ条件(価格範囲、カテゴリ、ブランド、評価)を表示。ユーザーが任意のフィルタをクリックすると、検索結果が即座に絞り込まれ、フィルタカウントもリアルタイム更新。この「検索→フィルタ→再検索」の反復的な体験は、従来の「検索→ページネート→再検索」アプローチより10倍効率的です。$facetはファセット検索を実装する最良のツール—全次元の統計を1回のクエリで返し、複数リクエストの必要を排除。

検索APIパラメータ設計: 検索APIパラメータは全フィルタ次元をカバーする必要がある—1. キーワード(q):$text検索クエリ;2. カテゴリ(category):「category」フィールドの完全一致;3. 価格範囲(minPrice/maxPrice):$gte/$lteでpriceフィールドをフィルタ;4. ブランド(brand):完全一致または$inで複数選択;5. 評価(minRating):$gteでratingフィールドをフィルタ;6. ページネーション(page/limit):$skip/$limitで結果数を制御;7. ソート(sort):関連性、価格、評価、または販売数順。パラメータ設計原則:全フィルタパラメータはオプション(パラメータなしの場合全結果返す)、ページネーションパラメータにはデフォルト値(page=1, limit=20)、ソートにはデフォルトオプション(sort=relevance)。

JAVASCRIPT
// === 商品検索 + 複数統計次元 ===
app.get('/api/products/search', async (req, res) => {
  const { q, category } = req.query;
  const match = {};
  if (q) match.$text = { $search: q };
  if (category) match.category = category;

  const result = await Product.aggregate([
    { $match: match },
    {
      $facet: {
        items: [
          { $sort: { score: { $meta: 'textScore' } } },
          { $skip: 0 },
          { $limit: 20 },
          { $project: { sku: 1, title: 1, price: 1, thumbnail: 1 } }
        ],
        total: [{ $count: 'count' }],
        facets: [
          {
            $bucket: {
              groupBy: '$price',
              boundaries: [0, 100, 500, 1000, 5000],
              default: '5000+',
              output: { count: { $sum: 1 } }
            }
          }
        ],
        categories: [
          {
            $group: {
              _id: '$category',
              count: { $sum: 1 }
            }
          }
        ]
      }
    }
  ]);

  res.json(result[0]);
});

(2) 近隣店舗検索

JAVASCRIPT
// === 近隣店舗検索 ===
app.get('/api/stores/nearby', async (req, res) => {
  const { lng, lat, maxDistance = 5000 } = req.query;

  const stores = await Store.find({
    location: {
      $nearSphere: {
        $geometry: { type: 'Point', coordinates: [parseFloat(lng), parseFloat(lat)] },
        $maxDistance: parseInt(maxDistance)
      }
    }
  }).limit(20);

  res.json(stores);
});

▶ サンプル 1:$facetマルチ次元検索 + 位置別近隣店舗の実践的応用

JAVASCRIPT
// シナリオ1:マルチ次元商品検索(テキスト + カテゴリ別統計)
db.products.insertMany([
  { sku: 'PHONE-001', title: 'Smartphone X 5G', description: 'Latest 5G phone', price: 599, category: 'Electronics', location: { type: 'Point', coordinates: [139.6917, 35.6895] } },
  { sku: 'PHONE-002', title: 'Smartphone Pro 5G', description: 'Pro 5G phone', price: 899, category: 'Electronics', location: { type: 'Point', coordinates: [139.7017, 35.6995] } },
  { sku: 'LAPTOP-001', title: 'Laptop Pro', description: 'Powerful laptop', price: 1299, category: 'Computers', location: { type: 'Point', coordinates: [139.6817, 35.6795] } }
]);

db.products.createIndex({ title: 'text', description: 'text' });
db.products.createIndex({ location: '2dsphere' });

// テキスト検索 + カテゴリ別統計 + 価格別バケット
db.products.aggregate([
  { $match: { $text: { $search: '5G phone' } } },
  {
    $facet: {
      // 結果リスト
      items: [
        { $sort: { score: { $meta: 'textScore' } } },
        { $limit: 10 },
        { $project: { sku: 1, title: 1, price: 1, score: { $meta: 'textScore' } } }
      ],
      // 総数
      total: [{ $count: 'count' }],
      // 価格別バケット
      priceBuckets: [
        {
          $bucket: {
            groupBy: '$price',
            boundaries: [0, 500, 1000, 2000],
            default: '2000+',
            output: { count: { $sum: 1 }, avgPrice: { $avg: '$price' } }
          }
        }
      ],
      // カテゴリ別統計
      categories: [
        { $group: { _id: '$category', count: { $sum: 1 } } }
      ]
    }
  }
]);

// シナリオ2:近隣店舗検索(5km以内の電子製品)
db.products.aggregate([
  {
    $geoNear: {
      near: { type: 'Point', coordinates: [139.6917, 35.6895] },  // 東京駅
      distanceField: 'distance',
      maxDistance: 5000,  // 5km
      spherical: true,
      query: { category: 'Electronics' }
    }
  },
  { $project: { sku: 1, title: 1, price: 1, distance: { $round: ['$distance', 0] } } }
]);

// 出力:各商品への距離(メートル)、距離順でソート

出力: シナリオ1は多次元結果(リスト + 総数 + 価格バケット + カテゴリ)を返す;シナリオ2は距離順でソートされた商品リストを返す。

▶ サンプル 2:ShopHub マルチ次元商品検索API

JAVASCRIPT
// シナリオ:ShopHub EC検索ページ、1回のクエリでリスト+バケット+カテゴリ+総数を返す
db.products.insertMany([
  { sku: 'PHONE-001', title: 'Smartphone X 5G', description: 'Latest 5G phone with amazing camera', price: 599, category: 'Electronics', brand: 'TechCorp' },
  { sku: 'PHONE-002', title: 'Budget Phone 5G', description: 'Affordable 5G phone', price: 299, category: 'Electronics', brand: 'DataFlow' },
  { sku: 'TABLET-001', title: 'Tablet Pro 5G', description: '5G tablet for professionals', price: 899, category: 'Electronics', brand: 'TechCorp' },
  { sku: 'BOOK-001', title: '5G Technology Guide', description: 'Understanding 5G networks', price: 39, category: 'Books', brand: 'AppVenture' },
  { sku: 'WATCH-001', title: 'Smart Watch', description: 'Fitness tracker watch', price: 199, category: 'Wearables', brand: 'DataFlow' }
]);

db.products.createIndex({ title: 'text', description: 'text' });

// マルチ次元検索:テキスト検索 + バケット + カテゴリ + 総数
const results = db.products.aggregate([
  { $match: { $text: { $search: '5G' } } },
  {
    $facet: {
      items: [
        { $sort: { score: { $meta: 'textScore' } } },
        { $limit: 10 },
        { $project: { sku: 1, title: 1, price: 1, category: 1, score: { $meta: 'textScore' } } }
      ],
      total: [{ $count: 'count' }],
      priceBuckets: [
        {
          $bucket: {
            groupBy: '$price',
            boundaries: [0, 100, 300, 600, 1000],
            default: '1000+',
            output: { count: { $sum: 1 } }
          }
        }
      ],
      categories: [
        { $group: { _id: '$category', count: { $sum: 1 } } },
        { $sort: { count: -1 } }
      ]
    }
  }
]);

// 出力構造:
// {
//   items: [Smartphone X 5G(score:2.5), Tablet Pro 5G(score:2.0), Budget Phone 5G(score:1.8), 5G Technology Guide(score:1.2)],
//   total: [{count: 4}],
//   priceBuckets: [{_id:0,count:1},{_id:100,count:1},{_id:300,count:1},{_id:600,count:1}],
//   categories: [{_id:'Electronics',count:3},{_id:'Books',count:1}]
// }

出力: 単一$facetクエリが検索リスト(関連性順ソート)、総数、価格分布バケット、カテゴリ統計を返し、フロントエンドが検索ページを直接レンダリング可能。

▶ サンプル 3:$textと$facetを使ったEC商品検索(難易度 ⭐⭐)

JAVASCRIPT
// シナリオ:ShopHub商品検索ページ(オートコンプリート、フィルタ、集計付き)
db.products.createIndex({ title: 'text', description: 'text' });

db.products.insertMany([
  { sku: 'PHONE-001', title: 'Smartphone X Pro', description: 'Flagship smartphone with AI camera', price: 999, category: 'Electronics', stock: 50 },
  { sku: 'PHONE-002', title: 'Smartphone Y Lite', description: 'Budget smartphone with long battery', price: 299, category: 'Electronics', stock: 120 },
  { sku: 'LAPTOP-001', title: 'Laptop Pro 15', description: 'Powerful laptop for creators', price: 1499, category: 'Electronics', stock: 30 },
  { sku: 'BOOK-001', title: 'MongoDB Guide', description: 'Complete guide to MongoDB', price: 49, category: 'Books', stock: 200 }
]);

// 完全検索:テキスト検索 + ファセットフィルタ + 価格バケット
const searchResults = db.products.aggregate([
  // ステージ1:スマートフォンの全文検索
  { $match: { $text: { $search: 'smartphone' } } },
  // ステージ2:関連性スコア計算
  { $addFields: { score: { $meta: 'textScore' } } },
  // ステージ3:複数次元のファセット
  { $facet: {
    // ページネーション付き結果リスト
    products: [
      { $sort: { score: -1 } },
      { $skip: 0 },
      { $limit: 10 },
      { $project: { sku: 1, title: 1, price: 1, score: 1 } }
    ],
    // ページネーション用総数
    totalCount: [{ $count: 'count' }],
    // バケット別価格分布
    priceBuckets: [
      { $bucket: {
        groupBy: '$price',
        boundaries: [0, 100, 500, 1000, 2000],
        default: 'other',
        output: { count: { $sum: 1 } }
      }}
    ],
    // カテゴリ別内訳
    byCategory: [
      { $group: { _id: '$category', count: { $sum: 1 } } }
    ]
  }}
]);

console.log(JSON.stringify(searchResults.toArray()[0], null, 2));

出力:

TEXT 📖 参照専用
{
  "products": [
    { "sku": "PHONE-001", "title": "Smartphone X Pro", "price": 999, "score": 1.5 },
    { "sku": "PHONE-002", "title": "Smartphone Y Lite", "price": 299, "score": 1.2 }
  ],
  "totalCount": [{ "count": 2 }],
  "priceBuckets": [
    { "_id": 0, "count": 0 },
    { "_id": 100, "count": 1 },
    { "_id": 500, "count": 0 },
    { "_id": 1000, "count": 1 }
  ],
  "byCategory": [{ "_id": "Electronics", "count": 2 }]
}

❓ よくある質問

よくある質問の解説: これら4つの質問は4つの重要領域の核心的な制限に対応します—$facetのメモリ制限、テキスト検索の言語制限、地理空間クエリの座標慣習、$bucketAutoのアルゴリズム制限。各制限には技術的な理由と対応戦略があります。これらの制限を理解することは答えを暗記するより重要です—例えば、MongoDBのテキスト検索が中国語形態素解析をサポートしないことを知っていれば、アーキテクチャ設計の初期段階からElasticsearchを組み込むことができます。

Q $facetと複数クエリの違いは?
A $facetは単一クエリで全次元を返し、ネットワークラウンドトリップを削減。ただし、多くのメモリを消費。
Q テキスト検索は中国語にマッチする?
A デフォルトでは、MongoDBテキストインデックスは単語分割(スペースまたは句読点で)、中国語は文字分割。中国語形態素解析にはElasticsearchが必要。
Q 地理座標の順序は?
A MongoDB地理座標は[経度, 緯度](注意:経度が先)。
Q $bucketAutoはどうやってバケット境界を選択?
A データ分布に基づいて自動的に均一なバケットサイズを選択(例:5バケットで、各バケットに約20%のデータ)。

📖 まとめ

4大テーマの統合: $facet + $bucketは集計能力を拡張—検索結果ページが全次元のデータを一度に返すことを可能に;テキスト検索と地理空間クエリはクエリ能力を拡張—完全一致からファジー検索へ、属性クエリから空間クエリへ。これら4つの能力を組み合わせることで、完全なEC検索システムを構成—キーワード検索 + ファセットフィルタ + 価格バケティング + 近隣店舗—検索シナリオの90%をカバー。

検索システムの技術選択決定: MongoDBネイティブ検索とElasticsearchの選択—1. MongoDBが適用:小規模プロジェクト(< 100,000ドキュメント)、シンプルな検索ロジック(キーワード + カテゴリ + 価格フィルタ)、新インフラ導入を望まない状況;2. Elasticsearchが適用:大規模プロジェクト(> 100万ドキュメント)、中国語形態素解析、ピンイン検索、同義語拡張が必要、複雑なランキングアルゴリズム(BM25 + カスタムブースト)、ニアリアルタイムインデックス(ミリ秒レベル更新)が必要;3. ハイブリッドアプローチ:データはMongoDBに保存しChange StreamでElasticsearchに同期、クエリはElasticsearchで実行。このハイブリッドアプローチはMongoDBの書き込みパフォーマンスとElasticsearchの検索能力を組み合わせ—本番環境で最も一般的なアーキテクチャ。

$facetサブパイプラインの設計パターン: $facetサブパイプラインには3つの設計パターンがあります—1. 独立サブパイプライン(最も一般的):各サブパイプラインが独立して全データセットを処理、相互依存なし(例:itemsサブパイプラインはリストを返す、totalサブパイプラインはカウントを返す、facetsサブパイプラインはバケットを返す);2. 共有$match:全サブパイプラインが$facet前の$matchステージのフィルタリング結果を共有、重複フィルタリングを回避;3. ネスト$facet(非推奨):$facet内に$facetをネスト、ロジックが複雑になりメモリ消費が倍増。パターン2がベストプラクティス—共通フィルタリングを事前に適用(カテゴリ、キーワード、価格範囲など)、サブパイプラインはそれぞれの統計ロジックのみを実行。


📝 練習問題

  1. 基本問題(⭐)productsコレクションにテキストインデックス(title + description)を作成し、$text検索を実行。
  2. 基本問題(⭐):$bucketを使用して価格範囲別の商品数をカウント。
  3. 応用問題(⭐⭐):完全な検索API(テキスト検索 + バケット別統計 + カテゴリ別統計)を実装。
  4. 応用問題(⭐⭐):近隣店舗検索(2dsphere + $nearSphere)を実装。
  5. チャレンジ問題(⭐⭐⭐):統合検索システム(テキスト + 地理 + ファセット統計)。

チャレンジ実装の推奨事項: 包括的検索システムは本コースで最も複雑なチャレンジです—4つの能力を組み合わせる必要があります:$text(テキスト検索)、$facet(多次元統計)、$bucket(価格バケティング)、$geoNear(距離ソート)。段階的実装を推奨:1. まず$text検索と結果リストを実装;2. 次に$facetで多次元統計を追加;3. さらに$bucketで価格バケティングを追加;4. 最後に$geoNear近隣検索を追加。各ステップが検証された後に次へ進む。$textと$geoNearは同じ$match内で使用できないことに注意—$textは$matchの最初の条件である必要があり、$geoNearはパイプラインの最初のステージである必要がある。解決策:$facetで2つの検索を並列実行、または$textを先に実行し、続いて$geoNearを実行。

包括検索システムデプロイの要点: 教育版チャレンジは本番環境まで数ステップ離れています—1. 入力検証:全クエリパラメータを検証(qは長さ1–200、categoryは列挙値、価格範囲0–999999、座標形式検証);2. デフォルトソート:キーワードなしの場合は販売数または評価順(ランダム順ではない)、キーワードありの場合は関連性順;3. 結果キャッシュ:同じクエリパラメータの結果を5分間キャッシュ(key = hash(params))、データベース負荷を削減;4. タイムアウト保護:maxTimeMS: 5000で集計実行時間を制限、タイムアウト時は部分結果を返し「結果が不完全な可能性」のプロンプトを表示;5. スロークエリ監視:実行時間1秒超の検索クエリをログに記録し、インデックスとパイプラインを最適化。これらのポイントは検索システムのデモから本番環境への移行における重要なギャップを表しています。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%