OpenSearch Serverless で「近くの道の駅」を距離順に返す施設検索 API を CDK で作ってみた
こんにちは、製造ビジネステクノロジー部の若槻です。
カーナビや地図アプリの施設検索(POI 検索)では、「現在地から 30 km 以内」「EV 充電施設がある」「名前に『織部』が入る」といった条件を同時に満たす施設を、近い順に返すことが求められます。距離の計算・属性の絞り込み・日本語の名称検索が同時に必要になるため、それぞれ別の仕組みで解こうとすると、絞り込んだ結果をアプリ側で突き合わせる処理が膨らみがちです。
Amazon OpenSearch Service には地理空間のフィールド型 geo_point があり、距離での絞り込み(geo_distance)と距離順の並べ替え(_geo_distance)を、全文検索と同じ 1 つのクエリの中で扱えます。
今回はこれを、サーバー管理の要らない Amazon OpenSearch Serverless で構築してみました。データには国土数値情報の道の駅データを使い、インフラは AWS CDK で構築します。
作ったもの
現在地の緯度経度を渡すと、周辺の道の駅を近い順に返す API です。
検索側とデータ投入側で Lambda 関数を分け、どちらも OpenSearch Serverless のコレクションに SigV4 署名付きでアクセスします。
前提
検証環境は以下のとおりです。
| 項目 | 値 |
|---|---|
| リージョン | ap-northeast-1 |
| aws-cdk-lib | 2.237.1 |
| Lambda ランタイム | Node.js 22(CDK のデフォルト) |
| コレクションタイプ | SEARCH |
| OpenSearch クライアント | @opensearch-project/opensearch 3.6.0 |
コレクションタイプは、全データをホットストレージに置いて検索レスポンスを優先する SEARCH を選びます。時系列ログ向けの TIMESERIES やベクトル検索向けの VECTORSEARCH とは、ストレージ構成とシャーディング戦略が異なります。
使用するデータ
国土数値情報の道の駅データは、都道府県ごとに GeoJSON が配布されています。1 件のフィーチャは以下のような構造です(プロパティは抜粋)。
{
"type": "Feature",
"properties": {
"都道府県名": "東京都",
"市町村名": "八王子市",
"道の駅名": "八王子滝山",
"ホームページアドレス1": "https://www.michi-no-eki.jp/stations/view/365",
"レストラン有無": 1.0,
"温泉施設有無": 2.0,
"EV充電施設有無": 2.0,
"無線LAN有無": 1.0
},
"geometry": {
"type": "Point",
"coordinates": [139.341474899999, 35.6866238]
}
}
〜有無 のプロパティは 1 が「有」、2 が「無」を表します。レストラン・温泉施設・EV 充電施設といった設備の情報が揃っているため、「EV 充電できる道の駅を近い順に」のような POI 検索らしいクエリを試すのにちょうどよいデータです。
配布元はG 空間情報センターで、CKAN の API からデータセットの情報と GeoJSON の URL を取得できます。
実装
CDK スタック
コレクション本体、3 種類のポリシー、2 つの Lambda 関数、API Gateway REST API を作成します。
import * as cdk from "aws-cdk-lib";
import * as apigateway from "aws-cdk-lib/aws-apigateway";
import * as iam from "aws-cdk-lib/aws-iam";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as lambdaNodejs from "aws-cdk-lib/aws-lambda-nodejs";
import * as opensearchserverless from "aws-cdk-lib/aws-opensearchserverless";
import * as cxapi from "aws-cdk-lib/cx-api";
import { Construct } from "constructs";
export class SampleStack extends cdk.Stack {
constructor(scope: Construct, id: string) {
super(scope, id);
// NodejsFunction のデフォルトランタイムを NODEJS_LATEST にする(既定では NODEJS_16_X になる)
this.node.setContext(cxapi.LAMBDA_NODEJS_USE_LATEST_RUNTIME, true);
/** OpenSearch Serverless コレクション名 */
const collectionName = "sample-poi";
/** OpenSearch ダッシュボードを閲覧する IAM ロールの ARN */
const dashboardsRoleArn = this.node.tryGetContext("dashboardsRoleArn") as
| string
| undefined;
/** コレクションの暗号化ポリシー */
const encryptionPolicy = new opensearchserverless.CfnSecurityPolicy(
this,
"CollectionEncryptionPolicy",
{
name: `${collectionName}-encryption`,
type: "encryption",
policy: JSON.stringify({
Rules: [
{
ResourceType: "collection",
Resource: [`collection/${collectionName}`],
},
],
AWSOwnedKey: true, // AWS 所有キーを使用。カスタマー管理キーは KMSARN で指定する
}),
},
);
/** コレクションのネットワークポリシー */
const networkPolicy = new opensearchserverless.CfnSecurityPolicy(
this,
"CollectionNetworkPolicy",
{
name: `${collectionName}-network`,
type: "network",
policy: JSON.stringify([
{
Rules: [
{
ResourceType: "collection",
Resource: [`collection/${collectionName}`],
},
{
ResourceType: "dashboard", // OpenSearch ダッシュボードへのアクセスを許可するために必要
Resource: [`collection/${collectionName}`],
},
],
AllowFromPublic: true, // 検証用にパブリックアクセスを許可。閉域構成では VPC エンドポイントを指定する
},
]),
},
);
/** OpenSearch Serverless コレクション */
const collection = new opensearchserverless.CfnCollection(
this,
"Collection",
{
name: collectionName,
type: "SEARCH", // 全文検索・地理空間検索向けのコレクションタイプ
standbyReplicas: "DISABLED", // 検証用のため冗長化を無効化して OCU を節約
},
);
collection.addDependency(encryptionPolicy);
collection.addDependency(networkPolicy);
/** データ投入 Lambda 関数 */
const poiIngestFunc = new lambdaNodejs.NodejsFunction(
this,
"PoiIngestFunc",
{
architecture: lambda.Architecture.ARM_64,
entry: "../server/src/lambda/handlers/poi-ingest.ts",
timeout: cdk.Duration.minutes(5), // 全国 47 都道府県分の GeoJSON 取得と投入に時間がかかるため延長
memorySize: 1024, // GeoJSON のパースに十分なメモリを確保
environment: {
OPENSEARCH_ENDPOINT: collection.attrCollectionEndpoint,
},
},
);
/** 検索 Lambda 関数 */
const poiSearchFunc = new lambdaNodejs.NodejsFunction(
this,
"PoiSearchFunc",
{
architecture: lambda.Architecture.ARM_64,
entry: "../server/src/lambda/handlers/poi-search.ts",
timeout: cdk.Duration.seconds(29), // API Gateway の統合タイムアウトに合わせる
environment: {
OPENSEARCH_ENDPOINT: collection.attrCollectionEndpoint,
},
},
);
/** コレクションのデータアクセスポリシー */
new opensearchserverless.CfnAccessPolicy(
this,
"CollectionDataAccessPolicy",
{
name: `${collectionName}-data-access`,
type: "data",
policy: JSON.stringify([
{
Rules: [
{
ResourceType: "index",
Resource: [`index/${collectionName}/*`],
Permission: [
"aoss:CreateIndex",
"aoss:DeleteIndex",
"aoss:DescribeIndex",
"aoss:ReadDocument",
"aoss:WriteDocument",
],
},
{
ResourceType: "collection",
Resource: [`collection/${collectionName}`],
Permission: ["aoss:DescribeCollectionItems"], // OpenSearch ダッシュボードからインデックスを一覧するために必要
},
],
Principal: [
poiIngestFunc.role!.roleArn,
poiSearchFunc.role!.roleArn,
...(dashboardsRoleArn ? [dashboardsRoleArn] : []),
],
},
]),
},
);
/** Lambda 関数から OpenSearch Serverless への API アクセスを許可する IAM ポリシー */
const apiAccessPolicyStatement = new iam.PolicyStatement({
actions: ["aoss:APIAccessAll"],
resources: [collection.attrArn],
});
poiIngestFunc.addToRolePolicy(apiAccessPolicyStatement);
poiSearchFunc.addToRolePolicy(apiAccessPolicyStatement);
/** 施設検索 API */
const restApi = new apigateway.LambdaRestApi(this, "RestApi", {
handler: poiSearchFunc,
proxy: false,
deployOptions: {
stageName: "v1", // 既定では "prod" になるため、適切なステージ名に変更
},
});
restApi.root.addResource("pois").addMethod("GET");
/** データ投入 Lambda 関数名 */
new cdk.CfnOutput(this, "PoiIngestFuncName", {
value: poiIngestFunc.functionName,
});
}
}
OpenSearch Serverless のアクセス制御は、IAM ポリシーとデータアクセスポリシーの 2 段構えになっています。IAM 側で aoss:APIAccessAll を許可し、さらにコレクション側のデータアクセスポリシーで「どのプリンシパルがどのインデックスに何をしてよいか」を宣言します。どちらか一方だけでは接続できません。
アクション名の頭に付く aoss は Amazon OpenSearch Serverless を表すサービスプレフィックスです(Service Authorization Reference に Amazon OpenSearch Serverless (service prefix: aoss) と定義されています)。プロビジョンドドメインの Amazon OpenSearch Service は別サービス扱いで、プレフィックスは es です。
dashboardsRoleArn は、OpenSearch ダッシュボード(OpenSearch Dashboards)をブラウザから開くための IAM ロールを渡す口です。ARN をコードに埋め込まないよう CDK コンテキストから受け取る形にしています。
$ npm run deploy -w iac -- -c dashboardsRoleArn=arn:aws:iam::XXXXXXXXXXXX:role/<ロール名>
ダッシュボードにアクセスするには、Lambda 関数から API を叩く場合と比べて次の 2 つが追加で必要でした。
- ネットワークポリシーに
dashboardの Rule を足す。collectionの Rule だけではダッシュボードのエンドポイントに到達できません - データアクセスポリシーに
collectionリソースのaoss:DescribeCollectionItemsを足す。インデックスの一覧を表示するために必要です
OpenSearch クライアント
Lambda 関数から OpenSearch Serverless にアクセスするクライアントです。
// ランタイム内でインスタンスが再利用できるように別モジュール化している
import { defaultProvider } from "@aws-sdk/credential-provider-node";
import { Client } from "@opensearch-project/opensearch";
import { AwsSigv4Signer } from "@opensearch-project/opensearch/aws";
const region = process.env.AWS_REGION || "ap-northeast-1";
/**
* OpenSearch Serverless コレクションのエンドポイント
*/
const node = process.env.OPENSEARCH_ENDPOINT;
if (!node) {
throw new Error("OPENSEARCH_ENDPOINT is not set");
}
/**
* OpenSearch クライアントの初期化
*
* OpenSearch Serverless は SigV4 署名の service 名に `aoss` を使用する
* (プロビジョンドドメインの場合は `es`)
*/
export const openSearchClient = new Client({
...AwsSigv4Signer({
region,
service: "aoss",
getCredentials: () => defaultProvider()(),
}),
node,
});
/**
* 施設情報(POI)を格納するインデックス名
*/
export const POI_INDEX_NAME = process.env.POI_INDEX_NAME || "poi";
SigV4 署名の service に指定する値も、このサービスプレフィックスと同じで、プロビジョンドドメインが es、Serverless が aoss です。es は Amazon OpenSearch Service の前身である Amazon Elasticsearch Service 時代の名前がそのまま残っているもので、aoss の方が新しい命名になります。ここを間違えると署名の検証に失敗して 403 が返ります。
インデックス作成とデータ投入
インデックスを作成し、全国 47 都道府県分の GeoJSON を取得して _bulk API で投入する Lambda 関数です。
import {
openSearchClient,
POI_INDEX_NAME,
} from "../infrastructures/opensearch/client";
/**
* 国土数値情報「道の駅データ」の GeoJSON を配布している G 空間情報センターの CKAN API
* データセットは都道府県コード(01〜47)ごとに分かれている
* @see https://nlftp.mlit.go.jp/ksj/gml/datalist/KsjTmplt-P35.html
*/
const CKAN_PACKAGE_SHOW_URL =
"https://www.geospatial.jp/ckan/api/3/action/package_show?id=ksj-p35-";
/** 一度の _bulk リクエストで投入するドキュメント数 */
const BULK_CHUNK_SIZE = 500;
/**
* 設備を表す GeoJSON のプロパティ名と、インデックスに格納する設備名の対応
* プロパティの値は 1 が「有」、2 が「無」を表す
*/
const FACILITY_PROPERTIES: Record<string, string> = {
レストラン有無: "レストラン",
"軽食・喫茶有無": "軽食・喫茶",
ショップ有無: "ショップ",
宿泊施設有無: "宿泊施設",
温泉施設有無: "温泉施設",
ガソリンスタンド有無: "ガソリンスタンド",
EV充電施設有無: "EV充電施設",
無線LAN有無: "無線LAN",
身障者トイレ有無: "身障者トイレ",
ベビーベッド有無: "ベビーベッド",
観光案内有無: "観光案内",
ATM有無: "ATM",
};
interface PoiDocument {
name: string;
prefecture: string;
city: string;
facilities: string[];
location: { lat: number; lon: number };
url: string;
}
interface Feature {
properties: Record<string, string | number>;
geometry: { type: string; coordinates: [number, number] };
}
/**
* インデックスを作成する(既に存在する場合は作り直す)
*/
const recreateIndex = async (): Promise<void> => {
const { body: exists } = await openSearchClient.indices.exists({
index: POI_INDEX_NAME,
});
if (exists) {
await openSearchClient.indices.delete({ index: POI_INDEX_NAME });
}
await openSearchClient.indices.create({
index: POI_INDEX_NAME,
body: {
settings: {
analysis: {
char_filter: {
// 全角・半角や大文字・小文字の表記ゆれを正規化する
normalize: {
type: "icu_normalizer",
name: "nfkc_cf",
mode: "compose",
},
},
tokenizer: {
// search モードでは複合語を分割した見出し語も出力する
ja_kuromoji_tokenizer: {
type: "kuromoji_tokenizer",
mode: "search",
},
ja_ngram_tokenizer: {
type: "ngram",
min_gram: 2,
max_gram: 3,
token_chars: ["letter", "digit"],
},
},
analyzer: {
// 形態素解析による語単位の一致
ja_text: {
type: "custom",
char_filter: ["normalize"],
tokenizer: "ja_kuromoji_tokenizer",
filter: [
"kuromoji_baseform",
"kuromoji_part_of_speech",
"ja_stop",
"kuromoji_number",
"kuromoji_stemmer",
],
},
// N-gram による部分一致
ja_ngram: {
type: "custom",
char_filter: ["normalize"],
tokenizer: "ja_ngram_tokenizer",
},
},
},
},
mappings: {
properties: {
name: {
type: "text",
analyzer: "ja_text",
fields: {
ngram: { type: "text", analyzer: "ja_ngram" },
keyword: { type: "keyword" },
},
},
prefecture: { type: "keyword" },
city: { type: "keyword" },
facilities: { type: "keyword" },
// 緯度経度。geo_distance などの地理空間クエリの対象になる
location: { type: "geo_point" },
url: { type: "keyword", index: false },
},
},
},
});
};
/**
* 都道府県コードを指定して道の駅の GeoJSON を取得する
*/
const fetchFeatures = async (prefectureCode: string): Promise<Feature[]> => {
const packageResponse = await fetch(
`${CKAN_PACKAGE_SHOW_URL}${prefectureCode}`,
);
const packageBody = (await packageResponse.json()) as {
result: { resources: { format: string; url: string }[] };
};
const geoJsonUrl = packageBody.result.resources.find(
(resource) => resource.format === "GeoJSON",
)?.url;
if (!geoJsonUrl) {
return [];
}
const geoJsonResponse = await fetch(geoJsonUrl);
const geoJson = (await geoJsonResponse.json()) as { features: Feature[] };
return geoJson.features;
};
/**
* GeoJSON のフィーチャをインデックスに投入するドキュメントへ変換する
*/
const toDocument = (feature: Feature): PoiDocument => {
const properties = feature.properties;
const [longitude, latitude] = feature.geometry.coordinates;
return {
name: String(properties["道の駅名"]),
prefecture: String(properties["都道府県名"]),
city: String(properties["市町村名"]),
facilities: Object.entries(FACILITY_PROPERTIES)
.filter(([property]) => Number(properties[property]) === 1)
.map(([, facility]) => facility),
location: { lat: latitude, lon: longitude },
url: String(properties["ホームページアドレス1"] ?? ""),
};
};
/**
* ドキュメントを _bulk API で投入する
*/
const bulkIndex = async (documents: PoiDocument[]): Promise<void> => {
for (let offset = 0; offset < documents.length; offset += BULK_CHUNK_SIZE) {
const { body: response } = await openSearchClient.bulk({
body: documents
.slice(offset, offset + BULK_CHUNK_SIZE)
.flatMap((document) => [
{ index: { _index: POI_INDEX_NAME } },
document,
]),
});
if (response.errors) {
throw new Error(`bulk request failed: ${JSON.stringify(response.items)}`);
}
}
};
/**
* 国土数値情報の道の駅データを OpenSearch Serverless に投入する
*/
export const handler = async (): Promise<{
indexName: string;
documentCount: number;
}> => {
await recreateIndex();
const prefectureCodes = [...Array(47)].map((_, index) =>
String(index + 1).padStart(2, "0"),
);
const featuresByPrefecture = await Promise.all(
prefectureCodes.map((prefectureCode) => fetchFeatures(prefectureCode)),
);
const documents = featuresByPrefecture.flat().map(toDocument);
await bulkIndex(documents);
return { indexName: POI_INDEX_NAME, documentCount: documents.length };
};
インデックスの設計で押さえたのは次の 3 点です。
locationをgeo_pointにする。これでgeo_distanceによる絞り込みと_geo_distanceによる距離順ソートの対象になります- 施設名に 2 系統のアナライザーを当てる。
ja_textは kuromoji による形態素解析で語単位の一致を、サブフィールドのname.ngramは 2〜3 文字の N-gram で部分一致を担当します。日本語の施設名は形態素解析だけでは語の途中から検索したときに当たらないため、両方を用意して併用します icu_normalizerで表記ゆれを正規化する。nfkc_cfを指定すると、全角・半角や大文字・小文字の違いを吸収できます
kuromoji(Japanese Analysis)と ICU Analysis はいずれも OpenSearch Serverless にプリインストールされているプラグインなので、追加のインストール作業は要りません。
設備の情報は、〜有無 プロパティのうち値が 1 のものだけを facilities という keyword 型の配列に詰め替えています。こうしておくと term クエリで「EV 充電施設がある施設だけ」と絞り込めます。
検索 API
現在地の周辺にある施設を近い順に返す Lambda 関数です。
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
import {
openSearchClient,
POI_INDEX_NAME,
} from "../infrastructures/opensearch/client";
/** 検索半径の既定値 */
const DEFAULT_RADIUS = "10km";
/** 取得件数の既定値 */
const DEFAULT_SIZE = 5;
interface PoiSource {
name: string;
prefecture: string;
city: string;
facilities: string[];
location: { lat: number; lon: number };
}
/**
* 現在地の周辺にある施設を近い順に検索する
*
* GET /pois?lat=35.17&lon=136.88&radius=30km&facility=EV充電施設&q=道の駅&size=5
*/
export const handler = async (
event: APIGatewayProxyEvent,
): Promise<APIGatewayProxyResult> => {
const queryStringParameters = event.queryStringParameters ?? {};
const latitude = Number(queryStringParameters.lat);
const longitude = Number(queryStringParameters.lon);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return {
statusCode: 400,
body: JSON.stringify({ message: "lat and lon are required" }),
};
}
const radius = queryStringParameters.radius ?? DEFAULT_RADIUS;
const size = Number(queryStringParameters.size ?? DEFAULT_SIZE);
const keyword = queryStringParameters.q;
const facilities =
event.multiValueQueryStringParameters?.facility?.filter(Boolean) ?? [];
const { body: response } = await openSearchClient.search({
index: POI_INDEX_NAME,
body: {
size,
query: {
bool: {
/**
* geo_distance は現在地からの半径で絞り込むフィルタ
* スコア計算を伴わないため filter 句に置く
*/
filter: [
{
geo_distance: {
distance: radius,
location: { lat: latitude, lon: longitude },
},
},
...facilities.map((facility) => ({
term: { facilities: facility },
})),
],
// 施設名のキーワード検索。形態素解析と N-gram の両方に当てる
...(keyword
? {
must: [
{
multi_match: {
query: keyword,
fields: ["name^3", "name.ngram"],
},
},
],
}
: {}),
},
},
// 現在地からの距離の昇順で並べる。sort 値に距離(km)が返る
sort: [
{
_geo_distance: {
location: { lat: latitude, lon: longitude },
order: "asc",
unit: "km",
},
},
],
_source: ["name", "prefecture", "city", "facilities", "location"],
},
});
/**
* クライアントの型定義では検索ヒットの sort 値が公開されていないためアサーションする
*/
const hits = (response.hits?.hits ?? []) as unknown as {
_source: PoiSource;
sort: number[];
}[];
const pois = hits.map((hit) => ({
name: hit._source.name,
prefecture: hit._source.prefecture,
city: hit._source.city,
facilities: hit._source.facilities,
distanceKm: Math.round(hit.sort[0] * 10) / 10,
}));
return {
statusCode: 200,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pois }),
};
};
クエリの組み立てで押さえたのは次の 2 点です。
geo_distanceと設備のtermはfilter句に置く。filterは「条件を満たすか」の二値で判定するフィルターコンテキストで、スコア計算が発生しません。距離や設備の有無は程度の問題ではなく、順位付けは_geo_distanceソートが担当するため、スコアを計算させる意味がありません。また、設備のtermのように取りうる値が限られるフィルターは繰り返し現れるため、キャッシュも効きやすくなります(geo_distanceは中心座標が現在地ごとに変わるので、キャッシュはあまり期待できません)- 距離は
_geo_distanceソートの副産物として受け取る。ソートにunit: "km"を指定しておくと、各ヒットのsortに現在地からの距離が入って返ってくるので、距離を別途計算する必要がありません
動作確認
デプロイ
$ npm run deploy -w iac
✅ Sample
✨ Deployment time: 261.26s
Outputs:
Sample.PoiIngestFuncName = Sample-PoiIngestFuncXXXXXXXX-XXXXXXXXXXXX
Sample.RestApiEndpoint0551178A = https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/v1/
コレクション、3 種類のポリシー、2 つの Lambda 関数、REST API が作成されました。

CloudFormation スタックのリソース。OpenSearch Serverless のコレクションと 3 種類のポリシーが作成されている
コレクションはステータスがアクティブ、コレクションタイプは検索になっています。

作成されたコレクション。Serverless generation は Classic(従来世代)
データ投入
データ投入 Lambda 関数を呼び出します。
$ time aws lambda invoke \
--function-name Sample-PoiIngestFuncXXXXXXXX-XXXXXXXXXXXX \
--cli-binary-format raw-in-base64-out \
--payload '{}' \
ingest-result.json
{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
aws lambda invoke --function-name Sample-PoiIngestFuncXXXXXXXX-XXXXXXXXXXXX 0.25s user 0.10s system 2% cpu 13.401 total
$ cat ingest-result.json
{"indexName":"poi","documentCount":1145}
全国 47 都道府県分の GeoJSON 取得からインデックス作成、1145 件の投入までが 13 秒で完了しました。
コレクションのモニタリング画面でも、ドキュメント総数が 1145 件になっていることが確認できます。

モニタリングの概要。ドキュメント総数 1145 件、S3 ストレージ使用量 404.15 KiB
なお OpenSearch Serverless のリフレッシュ間隔は約 10 秒で、変更もできません。投入直後に検索しても結果が返らないことがあるので、少し待ってから確認します。
近くの道の駅を距離順に取得する
名古屋駅(緯度 35.1709 / 経度 136.8815)を現在地として、半径 30 km の道の駅を近い順に 5 件取得します。
$ curl -s "$API/pois?lat=35.1709&lon=136.8815&radius=30km&size=5" | jq -r '.pois[] | "\(.name) (\(.prefecture)\(.city)) \(.distanceKm)km"'
立田ふれあいの里 (愛知県愛西市) 17.2km
クレール平田 (岐阜県海津市) 22.3km
瀬戸しなの (愛知県瀬戸市) 22.7km
月見の里 南濃 (岐阜県海津市) 26km
柳津 (岐阜県岐阜市) 26km
近い順に並んでおり、距離も返っています。半径を 5 km に狭めると、名古屋駅の周辺には道の駅がないため 0 件になります。
$ curl -s "$API/pois?lat=35.1709&lon=136.8815&radius=5km" | jq -c
{"pois":[]}
緯度経度を指定しない場合は 400 を返します。
$ curl -s -o /dev/null -w "status=%{http_code}\n" "$API/pois"
status=400
$ curl -s "$API/pois" | jq -c
{"message":"lat and lon are required"}
設備で絞り込む
「EV 充電施設がある道の駅」を近い順に取得します。
$ curl -s -G "$API/pois" \
-d "lat=35.1709" -d "lon=136.8815" -d "radius=50km" -d "size=5" \
--data-urlencode "facility=EV充電施設" \
| jq -r '.pois[] | "\(.name) (\(.prefecture)\(.city)) \(.distanceKm)km"'
志野・織部 (岐阜県土岐市) 37.3km
むげ川 (岐阜県関市) 38.4km
織部の里・もとす (岐阜県本巣市) 41.4km
筆柿の里・幸田 (愛知県額田郡幸田町) 42.8km
奥永源寺渓流の里 (滋賀県東近江市) 46.9km
半径 30 km には EV 充電施設のある道の駅がなかったため、条件を満たす最も近い施設は 37.3 km 先になりました。距離での絞り込みと属性での絞り込みが同じクエリで効いていることが分かります。
同じ条件のクエリは、OpenSearch ダッシュボードの Dev Tools からも実行できます。API が組み立てているクエリ DSL と、OpenSearch が返すレスポンスをそのまま確認できます。

Dev Tools で実行した geo_distance クエリ。各ヒットの sort に現在地からの距離(km)が入って返ってくる
hits.total.value が 5、先頭のヒットの sort が 37.29439980370062 となっており、API が返した 37.3 km と一致しています。
施設名で検索する
q に施設名の一部を渡します。まずは漢字での部分一致です。
$ curl -s -G "$API/pois" \
-d "lat=35.1709" -d "lon=136.8815" -d "radius=100km" \
--data-urlencode "q=織部" \
| jq -r '.pois[] | "\(.name) (\(.prefecture)\(.city)) \(.distanceKm)km"'
志野・織部 (岐阜県土岐市) 37.3km
織部の里・もとす (岐阜県本巣市) 41.4km
「織部」は「志野・織部」では末尾、「織部の里・もとす」では先頭にありますが、どちらもヒットしています。N-gram のサブフィールドが効いている部分です。
次に半角カナで検索します。
$ curl -s -G "$API/pois" \
-d "lat=35.1709" -d "lon=136.8815" -d "radius=100km" \
--data-urlencode "q=クレール" \
| jq -r '.pois[] | "\(.name) (\(.prefecture)\(.city)) \(.distanceKm)km"'
クレール平田 (岐阜県海津市) 22.3km
半角カナの「クレール」で全角カナの「クレール平田」がヒットしました。icu_normalizer による正規化が効いています。
正規化の様子は _analyze API で確認できます。半角カナで入力した文字列が、全角カナのトークンになって出てきます。

ja_text アナライザーに「クレール平田」を通すと、「クレール」「平田」のトークンに分解・正規化される
一方で、ひらがなでの読み検索は当たりません。「月見の里 南濃」を「つきみのさと」で探すと、無関係な施設が返ります。
$ curl -s -G "$API/pois" \
-d "lat=35.1709" -d "lon=136.8815" -d "radius=100km" -d "size=3" \
--data-urlencode "q=つきみのさと" \
| jq -r '.pois[] | "\(.name) (\(.prefecture)\(.city)) \(.distanceKm)km"'
そばの郷 らっせぃみさと (岐阜県恵那市) 50.6km
つくで手作り村 (愛知県新城市) 54.2km
湖北みずどりステーション (滋賀県長浜市) 69.7km
$ curl -s -G "$API/pois" \
-d "lat=35.1709" -d "lon=136.8815" -d "radius=100km" \
--data-urlencode "q=月見" \
| jq -r '.pois[] | "\(.name) (\(.prefecture)\(.city)) \(.distanceKm)km"'
月見の里 南濃 (岐阜県海津市) 26km
インデックスに漢字の表記しか入っていないため、ひらがなの読みからは辿れません。カーナビの音声検索やフリック入力を考えると読み検索は避けて通れない要件になりますが、それには kuromoji_readingform で読みのサブフィールドを持たせるなど、別の設計が必要です。
レイテンシ
手元の Mac から 5 回連続で叩いたときの応答時間です。
$ for i in 1 2 3 4 5; do
curl -s -o /dev/null -G -w " total=%{time_total}s\n" "$API/pois" \
-d "lat=35.1709" -d "lon=136.8815" -d "radius=30km" -d "size=5"
done
total=0.159905s
total=0.199674s
total=0.196470s
total=0.193031s
total=0.161662s
API Gateway と Lambda を経由し、インターネット越しの往復も含めて 160〜200 ms でした。
このうち OpenSearch 側の検索処理が占める時間は、CloudWatch メトリクスの SearchRequestLatency で確認できます。同じクエリを 30 回投げた区間の値です。
$ aws cloudwatch get-metric-statistics \
--namespace AWS/AOSS \
--metric-name SearchRequestLatency \
--dimensions Name=CollectionName,Value=sample-poi \
Name=CollectionId,Value=xxxxxxxxxxxxxxxxxxxx \
Name=ClientId,Value=XXXXXXXXXXXX \
--start-time 2026-08-24T12:30:15 \
--end-time 2026-08-24T12:33:00 \
--period 300 \
--statistics Average Maximum \
--extended-statistics p50 p90 p99
avg=57.7 max=98.0 {'p50': 56.3, 'p90': 72.9, 'p99': 97.7}
検索そのものは 1145 件のインデックスに対して p50 で 56 ms、p99 でも 98 ms でした。残りの 100 ms 前後は API Gateway と Lambda、そして手元からのネットワーク往復です。車載向けのように厳しいレイテンシ要件を置く場合、詰めるべき箇所は検索エンジンだけではないことが分かります。
同じメトリクスはコレクションのモニタリング画面からも確認できます。グラフの縦軸が 5.48K まで伸びているのは、コレクション作成直後の最初のリクエストが 5 秒台かかったためです。

検索パフォーマンスのメトリクス。最初の 1 回を除けばレイテンシーは低い値で推移している
注意点
CfnIndex ではアナライザーを定義できない
インデックスの作成は、CloudFormation の AWS::OpenSearchServerless::Index(CDK の CfnIndex)でも宣言できます。IaC でマッピングまで管理できるのは魅力的なのですが、扱えるプロパティがベクトル検索向けに寄っていて、settings は knn knnAlgoParamEfSearch refreshInterval の 3 つ、マッピングも dimension や method といった k-NN のパラメータが中心です。
今回のように analysis(char_filter / tokenizer / analyzer)を定義したい場合は表現できないため、インデックス作成は OpenSearch クライアント側で行いました。
コスト
OpenSearch Serverless の課金単位は OCU(OpenSearch Compute Unit)で、アイドル時も最小 OCU 分は課金されます。今回は検証用なので standbyReplicas: "DISABLED" で冗長化を無効化しました。この構成で実際に消費された OCU を 5 分粒度で見てみます。
$ aws cloudwatch get-metric-statistics \
--namespace AWS/AOSS --metric-name IndexingOCU \
--dimensions Name=ClientId,Value=XXXXXXXXXXXX \
--period 300 --statistics Maximum \
--start-time 2026-08-24T09:45:00 --end-time 2026-08-24T12:45:00
21:08 1.0
21:13 1.0
21:18 1.0
21:23 0.5
21:28 0.5
21:33 0.5
$ aws cloudwatch get-metric-statistics \
--namespace AWS/AOSS --metric-name SearchOCU \
--dimensions Name=ClientId,Value=XXXXXXXXXXXX \
--period 300 --statistics Maximum \
--start-time 2026-08-24T09:45:00 --end-time 2026-08-24T12:45:00
21:08 1.0
21:13 1.0
21:18 1.0
21:23 1.0
21:28 0.5
21:33 0.5
データ投入と検索を行っている間はインデックス用・検索用がそれぞれ 1 OCU で、アクセスが止まるとそれぞれ 0.5 OCU まで縮退しました。冗長化を無効にした構成では、アイドル時は合計 1 OCU が下限になります。

消費 OCU。投入直後は 1 OCU ずつ、アイドル状態になると 0.5 OCU ずつまで縮退した
とはいえ 0 にはならないため、検証が終わったらスタックを削除します。本番構成では冗長化を有効にする前提で見積もる必要があります。
なお 2025 年以降の次世代 OpenSearch Serverless では、コレクショングループの最小 OCU を 0 にしてアイドル時に課金をゼロにできるスケールトゥゼロが利用できます。断続的にしかアクセスがない検証環境やプレビュー環境では、こちらの構成も検討する価値があります。
おわりに
Amazon OpenSearch Serverless を使って、カーナビの施設検索に必要な「距離での絞り込み」「距離順の並べ替え」「設備での絞り込み」「日本語の名称検索」を 1 つのクエリにまとめてみました。geo_point のマッピングと geo_distance フィルタさえ用意すれば、地理空間の条件を全文検索の一部として素直に書けるのが分かりました。
一方で、ひらがなの読み検索が当たらなかったように、実際の POI 検索で求められる「意図どおりに当たる検索」には、インデックス設計を要件に合わせて詰める作業が残ります。読み・別名・略称への対応、ルート沿いの検索(geo_shape)、そして自然言語での検索を狙ったベクトル検索とハイブリッド検索は、また別の記事で試してみたいと思います。
以上






