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
をクリックします。
Host
に redis-server
を入力し、Add Redis database
をクリックします。
以下のようなデータが見られます。左は製品の概要、右は在庫情報です。
データにインデックスを付与する
RedisInsight
の workbench
から以下を実行します。
# 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
から実行もできます。
地理空間問い合わせを実行する
インデックスが付与されたので、地理空間問い合わせを実行してみます。以下の問い合わせは、製品名 puma
で New 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]
はstoreLocation
がNew York City
から50マイル以内 - 条件④ @productDisplayName:'puma' は
productDisplayName
がpuma
という条件です。
次に、距離でソート、更に取得データを制限してみます。
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
関数 に焦点を当てています。実際のコードは記事末尾に記載しておきます。見てもらえると雰囲気がつかめると思います。
-
関数の概要
searchStoreInventoryByGeoFilter は、在庫フィルターオブジェクトを受け取ります。このオブジェクトには、任意で商品表示名、検索半径(マイル単位)、ユーザーの位置情報が含まれます。これを基に、指定した半径内で商品名が一致する店舗商品を検索するクエリを構築します。 -
クエリの構築
関数内では、Redis OM の fluent API を使って検索クエリを作成します。これにより、商品の在庫状況、在庫数、ユーザー位置からの距離などの条件を指定できます。さらに、商品名でのフィルタも任意で追加できます。 -
クエリの実行
構築したクエリは Redis 上で ft.aggregate メソッド を用いて実行されます。これにより、複雑な集約処理やデータ変換が可能です。結果データは、ユーザーの位置からの距離(マイル単位)を計算し、その距離に基づいてソートされます。 -
結果の処理
関数は、異なる店舗にある重複した商品を取り除き、最終出力では一意な商品一覧を保証します。その後、店舗の位置情報を読みやすい形式に整形し、返却する最終的な商品リストをまとめます。
さいごに
本記事では、Redisの地理空間検索機能を活用したeコマースのサンプルアプリケーションを通じて、その仕組みと利便性を探りました。チュートリアルを進める中で、Docker環境の設定変更やRedisInsightの導入など、実践的なノウハウも得られました。
主なポイントは以下の通りです。
- 低遅延な地理空間検索: Redisはインメモリで動作するため、リアルタイム性が求められる位置情報検索に非常に強力です。
-
インデックスの活用:
FT.CREATE
で地理空間情報(GEO
)を含むインデックスを作成し、FT.SEARCH
やFT.AGGREGATE
で効率的なクエリを実行する方法を学びました。 - 実践的なクエリ: 半径を指定した検索、距離の計算、結果のソートなど、具体的なユースケースに沿ったクエリの組み立て方を理解できました。
サンプルコードの環境設定でいくつかの調整が必要でしたが、それを乗り越えることで、実際の開発現場で起こりうる問題への対処法も学べます。本記事が、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