📌

Redisで地理空間検索をしてみる

に公開

はじめに

Redisについて調べている。

地理情報を使うところについて理解を深めたく、以下のチュートリアルをやってみる。インデックスも効くそうだけどパフォーマンスはどんなもんなんでしょうか。

以下チュートリアルをやってみます。

環境

本記事の動作確認は以下の環境で行いました。

  • MacBook Pro
  • 14 インチ 2021
  • チップ:Apple M1 Pro
  • メモリ:32GB
  • macOS:15.5(24F74)

Redisで地理空間検索してみよう

チュートリアルの概要

eコマース向けのマイクロサービスでの地理空間検索を体験します。

近接検索、位置フィルタ、地理空間問い合わせを行います。

高速で効率的な問い合わせのためにデータベースの設定とRedisを使ったインデックス構築を行います。

地理空間問い合わせを実装し、半径で検索する、距離を計算する、距離順でソートする構文を理解します。

Redisと連携した地理空間検索をするAPIを構築します。

なぜ地理空間検索をRedisで行うのか

Redisはインメモリで動作するので、Redisを使うことで地理空間データがメモリ上処理されることが保証されます。それにより地理空間問い合わせに対して低遅延高処理量が実現できます。これはリアルタイムな位置検索機能を必要とするアプリにとっては非常に重要です。

「オンライン注文・店舗受取」のシナリオを考えてみてください。消費者が製品をオンラインで探し、ブラウザやモバイルアプリで注文し、近所の店で受け取ります。Redisによってリアルタイムに店舗の在庫検索をすることができます。

ソースコード

以下のコードをcloneします。

git clone --branch v10.1.0 https://github.com/redis-developer/redis-microservices-ecommerce-solutions

以降は cloneした readme.md を参考にして進めていきます。

サービスを起動する

2025/07/12時点ではcloneしたままだと docker compose up に失敗します。
dockerfile-database-send で使用するベースイメージを変更します。

- FROM node:18-alpine
+ FROM node:18.19-alpine

次に .env ファイルで定義されているPOSTGRESQLのポート番号を変更します。私の環境だけかもしれませんが5432は address already in use となりました。lsof で確認してもポートを使っているプロセスはなかったので原因はわかっていません。

- POSTGRES_PORT = 5432
+ POSTGRES_PORT = 5433

以下でコンテナを起動します。
docker compose up -d

以下でMongoDBの状態を確認できます。
mongodb://localhost:27017/dbFashion?directConnection=true

私のローカルではTablePlusで確認できました。

以下にアクセスするとeコマースサイトが開きます。
http://localhost:4200/

右上の⚙️マークをクリックし、設定から Geo location search を有効にします。

すると zipcode で検索ができるようになりました。

RedisInsightsでデータを確認する

cloneした状態だと RedisInsights の起動方法が分かりませんでした。
以下のコードをdocker-compose.ymlに追加します。

  redis-insight:
    container_name: redis-insight
    image: redislabs/redisinsight
    ports:
      - "5540:5540"
    depends_on:
      - redis-server

環境を起動し直します。
docker-compose down && docker-compose up -d

以下から RedisInsights にアクセスします。
http://localhost:5540/

画面が起動したらプライバシーポリシーをチェックしてSubmitします。

次にRedisを接続します。
Add Redis database -> Connection Settings をクリックします。

Hostredis-server を入力し、Add Redis database をクリックします。

以下のようなデータが見られます。左は製品の概要、右は在庫情報です。

データにインデックスを付与する

RedisInsightworkbench から以下を実行します。

# Remove existing index
FT.DROPINDEX "storeInventory:storeInventoryId:index"

# Create a new index with geo-spatial and other field capabilities
FT.CREATE "storeInventory:storeInventoryId:index"
  ON JSON
  PREFIX 1 "storeInventory:storeInventoryId:"
  SCHEMA
    "$.storeId" AS "storeId" TAG SEPARATOR "|"
    "$.storeName" AS "storeName" TEXT
    "$.storeLocation" AS "storeLocation" GEO
    "$.productId" AS "productId" TAG SEPARATOR "|"
    "$.productDisplayName" AS "productDisplayName" TEXT
    "$.stockQty" AS "stockQty" NUMERIC
    "$.statusCode" AS "statusCode" NUMERIC

あるいは、docker container で起動している redis-server につなぎ、redis-cli から実行もできます。

地理空間問い合わせを実行する

インデックスが付与されたので、地理空間問い合わせを実行してみます。以下の問い合わせは、製品名 pumaNew York City から半径50マイル以内の製品を検索します。

FT.SEARCH "storeInventory:storeInventoryId:index" "( ( ( (@statusCode:[1 1]) (@stockQty:[(0 +inf]) ) (@storeLocation:[-73.968285 40.785091 50 mi]) ) (@productDisplayName:'puma') )"

( ( ( 条件① AND 条件② ) AND 条件③ ) AND 条件④ ) という感じで条件が記載されていてそれぞれ、

  • 条件① @statusCode:[1 1]statusCodeが1
  • 条件② @stockQty:[(0 +inf]stockQtyが0以上
  • 条件③ @storeLocation:[-73.968285 40.785091 50 mi]storeLocationNew York Cityから50マイル以内
  • 条件④ @productDisplayName:'puma' はproductDisplayNamepuma

という条件です。

次に、距離でソート、更に取得データを制限してみます。

FT.AGGREGATE "storeInventory:storeInventoryId:index"
          "( ( ( (@statusCode:[1 1]) (@stockQty:[(0 +inf]) ) (@storeLocation:[-73.968285 40.785091 50 mi]) ) (@productDisplayName:'puma') )"
          LOAD 6 "@storeId" "@storeName" "@storeLocation" "@productId" "@productDisplayName" "@stockQty"
          APPLY "geodistance(@storeLocation, -73.968285, 40.785091)/1609"
          AS "distInMiles"
          SORTBY 1 "@distInMiles"
          LIMIT 0 100
  • SORTBY 1 "@distInMiles" は距離でソートしていて、1はキーが1つという意味です
  • LIMIT 0 100 は0から100件取得するという意味です

APIエンドポイント

上記でクエリーのイメージが掴めました。あとは、APIエンドポイントを作成して、APIからクエリを呼び出せば良さそうです。

APIリクエストは以下のイメージです。

POST http://localhost:3000/products/getStoreProductsByGeoFilter
{
    "productDisplayName":"puma",

    "searchRadiusInMiles":50,
    "userLocation": {
        "latitude": 40.785091,
        "longitude": -73.968285
    }
}

APIレスポンスのイメージは以下

{
  "data": [
    {
      "productId": "11000",
      "price": 3995,
      "productDisplayName": "Puma Men Slick 3HD Yellow Black Watches",
      "variantName": "Slick 3HD Yellow",
      "brandName": "Puma",
      "ageGroup": "Adults-Men",
      "gender": "Men",
      "displayCategories": "Accessories",
      "masterCategory_typeName": "Accessories",
      "subCategory_typeName": "Watches",
      "styleImages_default_imageURL": "http://host.docker.internal:8080/images/11000.jpg",
      "productDescriptors_description_value": "...",

      "stockQty": "5",
      "storeId": "11_NY_MELVILLE",
      "storeLocation": {
        "longitude": -73.41512,
        "latitude": 40.79343
      },
      "distInMiles": "46.59194"
    }
    //...
  ],
  "error": null
}

API実装

cloneしたコードのgetStoreProductsByGeoFilter でAPIが実装されています。特に、コア検索ロジックを実行する searchStoreInventoryByGeoFilter 関数 に焦点を当てています。実際のコードは記事末尾に記載しておきます。見てもらえると雰囲気がつかめると思います。

  1. 関数の概要
    searchStoreInventoryByGeoFilter は、在庫フィルターオブジェクトを受け取ります。このオブジェクトには、任意で商品表示名、検索半径(マイル単位)、ユーザーの位置情報が含まれます。これを基に、指定した半径内で商品名が一致する店舗商品を検索するクエリを構築します。

  2. クエリの構築
    関数内では、Redis OM の fluent API を使って検索クエリを作成します。これにより、商品の在庫状況、在庫数、ユーザー位置からの距離などの条件を指定できます。さらに、商品名でのフィルタも任意で追加できます。

  3. クエリの実行
    構築したクエリは Redis 上で ft.aggregate メソッド を用いて実行されます。これにより、複雑な集約処理やデータ変換が可能です。結果データは、ユーザーの位置からの距離(マイル単位)を計算し、その距離に基づいてソートされます。

  4. 結果の処理
    関数は、異なる店舗にある重複した商品を取り除き、最終出力では一意な商品一覧を保証します。その後、店舗の位置情報を読みやすい形式に整形し、返却する最終的な商品リストをまとめます。

さいごに

本記事では、Redisの地理空間検索機能を活用したeコマースのサンプルアプリケーションを通じて、その仕組みと利便性を探りました。チュートリアルを進める中で、Docker環境の設定変更やRedisInsightの導入など、実践的なノウハウも得られました。

主なポイントは以下の通りです。

  • 低遅延な地理空間検索: Redisはインメモリで動作するため、リアルタイム性が求められる位置情報検索に非常に強力です。
  • インデックスの活用: FT.CREATEで地理空間情報(GEO)を含むインデックスを作成し、FT.SEARCHFT.AGGREGATEで効率的なクエリを実行する方法を学びました。
  • 実践的なクエリ: 半径を指定した検索、距離の計算、結果のソートなど、具体的なユースケースに沿ったクエリの組み立て方を理解できました。

サンプルコードの環境設定でいくつかの調整が必要でしたが、それを乗り越えることで、実際の開発現場で起こりうる問題への対処法も学べます。本記事が、Redisによる地理空間検索の実装を目指す方の一助となれば幸いです。

参考書籍
実践Redis入門 技術の仕組みから現場の活用まで

付録:API実装コード

const getSemanticProductsForStoreSearch = async (
  _inventoryFilter: IInventoryBodyFilter,
  openAIApiKey?: string,
  maxProductCount?: number,
  similarityScoreLimit?: number
) => {

  let productIds: string[] = [];

  if (_inventoryFilter.semanticProductSearchText) {
    //VSS search
    const vectorDocs = await getSimilarProductsScoreByVSS({
      standAloneQuestion: _inventoryFilter.semanticProductSearchText,
      openAIApiKey: openAIApiKey,
      KNN: maxProductCount,
      scoreLimit: similarityScoreLimit,
    });

    if (vectorDocs?.length) {
      productIds = vectorDocs.map(doc => doc?.metadata?.productId);
    }
  }

  return productIds;
}

const searchStoreInventoryByGeoFilter = async (
  _inventoryFilter: IInventoryBodyFilter,
  openAIApiKey?: string,
  maxProductCount?: number,
  similarityScoreLimit?: number
) => {
  const redisClient = getNodeRedisClient();
  const repository = StoreInventoryRepo.getRepository();
  let storeProducts: IStoreInventory[] = [];
  const trimmedStoreProducts: IStoreInventory[] = [] // similar item of other stores are removed
  const uniqueProductIds = {};
  let semanticProductIds: string[] = [];

  if (repository
    && _inventoryFilter?.userLocation?.latitude
    && _inventoryFilter?.userLocation?.longitude) {


    if (_inventoryFilter.semanticProductSearchText) {
      semanticProductIds = await getSemanticProductsForStoreSearch(_inventoryFilter, openAIApiKey, maxProductCount, similarityScoreLimit);
      console.log("semanticProductIds : ", semanticProductIds);
      if (!semanticProductIds?.length) {
        _inventoryFilter.productDisplayName = _inventoryFilter.semanticProductSearchText;
      }
    }


    const lat = _inventoryFilter.userLocation.latitude;
    const long = _inventoryFilter.userLocation.longitude;
    const radiusInMiles = _inventoryFilter.searchRadiusInMiles || 500;

    let queryBuilder = repository
      .search()
      .and('statusCode')
      .eq(DB_ROW_STATUS.ACTIVE)
      .and('stockQty')
      .gt(0)
      .and('storeLocation')
      .inRadius((circle) => {
        return circle
          .latitude(lat)
          .longitude(long)
          .radius(radiusInMiles)
          .miles
      });

    if (_inventoryFilter.productDisplayName) {
      queryBuilder = queryBuilder
        .and('productDisplayName')
        .matches(_inventoryFilter.productDisplayName)
    }
    else if (_inventoryFilter.productId) {
      queryBuilder = queryBuilder
        .and('productId')
        .eq(_inventoryFilter.productId)
    }

    console.log(queryBuilder.query);

    /* Sample queryBuilder.query to run on CLI
    FT.SEARCH "storeInventory:storeInventoryId:index" "( ( ( (@statusCode:[1 1]) (@stockQty:[(0 +inf]) ) (@storeLocation:[-73.968285 40.785091 50 mi]) ) (@productDisplayName:'puma') )"
            */

    const indexName = `${StoreInventoryRepo.STORE_INVENTORY_KEY_PREFIX}:index`;
    const aggregator = await redisClient.ft.aggregate(
      indexName,
      queryBuilder.query,
      {
        LOAD: ["@storeId", "@storeName", "@storeLocation", "@productId", "@productDisplayName", "@stockQty"],
        STEPS: [{
          type: AggregateSteps.APPLY,
          expression: `geodistance(@storeLocation, ${long}, ${lat})/1609`, //convert to miles
          AS: 'distInMiles'
        }, {
          type: AggregateSteps.SORTBY,
          BY: ["@distInMiles", "@productId"]
        }, {
          type: AggregateSteps.LIMIT,
          from: 0,
          size: 1000, //must be > storeInventory count
        }]
      });

    /* Sample command to run on CLI
        FT.AGGREGATE "storeInventory:storeInventoryId:index"
          "( ( ( (@statusCode:[1 1]) (@stockQty:[(0 +inf]) ) (@storeLocation:[-73.968285 40.785091 50 mi]) ) (@productDisplayName:'puma') )"
          "LOAD" "6" "@storeId" "@storeName" "@storeLocation" "@productId" "@productDisplayName" "@stockQty"
          "APPLY" "geodistance(@storeLocation, -73.968285, 40.785091)/1609"
          "AS" "distInMiles"
          "SORTBY" "1" "@distInMiles"
          "LIMIT" "0" "100"
    */

    storeProducts = <IStoreInventory[]>aggregator.results;

    if (!storeProducts.length) {
      // throw `Product not found with in ${radiusInMiles}mi range!`;
    }
    else {

      // filter storeProducts to keep only  semanticProductIds
      if (_inventoryFilter.semanticProductSearchText && semanticProductIds?.length) {
        storeProducts = storeProducts.filter((storeProduct) => {
          return storeProduct.productId && semanticProductIds.includes(storeProduct.productId);
        });
      }

      storeProducts.forEach((storeProduct) => {
        if (storeProduct?.productId && !uniqueProductIds[storeProduct.productId]) {
          uniqueProductIds[storeProduct.productId] = true;

          if (typeof storeProduct.storeLocation == "string") {
            const location = storeProduct.storeLocation.split(",");
            storeProduct.storeLocation = {
              longitude: Number(location[0]),
              latitude: Number(location[1]),
            }
          }

          trimmedStoreProducts.push(storeProduct)
        }
      });
    }
  }
  else {
    throw "Mandatory fields like userLocation latitude / longitude missing !"
  }

  return {
    storeProducts: trimmedStoreProducts,
    productIds: Object.keys(uniqueProductIds)
  };
};
const getStoreProductsByGeoFilter = async (_inventoryFilter: IInventoryBodyFilter) => {
  let products: IStoreProduct[] = [];

  const openAIApiKey = process.env.OPEN_AI_API_KEY;
  const maxProductCount = 10;// IfSemanticSearch
  const similarityScoreLimit = SERVER_CONFIG.PRODUCTS_SERVICE.VSS_SCORE_LIMIT;

  const { storeProducts, productIds } = await searchStoreInventoryByGeoFilter(_inventoryFilter, openAIApiKey, maxProductCount, similarityScoreLimit);

  if (storeProducts?.length && productIds?.length) {
    const repository = ProductRepo.getRepository();
    //products with details
    let generalProducts = <IProduct | IProduct[]>await repository.fetch(...productIds);
    if (!Array.isArray(generalProducts)) {
      generalProducts = [generalProducts];
    }

    //mergedProducts
    products = storeProducts.map(storeProd => {
      const matchingGeneralProd = generalProducts.find(generalProd => generalProd.productId === storeProd.productId);
      //@ts-ignore
      const mergedProd: IStoreProduct = { ...matchingGeneralProd, ...storeProd };
      return mergedProd;
    });
  }


  return products;
};

Discussion