headless-intelligent-search について
ヘッドレス VTEX ストアを構築する開発者向けのスキルで、商品検索、ファセット、オートコンプリート、分析イベントなど Intelligent Search の連携を扱います。フロントエンドからの直接リクエストと、ページネーション時の API 値の引き継ぎを重視します。
主な機能
- 01商品検索とファセットフィルター
- 02オートコンプリートと検索候補
- 03検索表示・クリックイベントの送信
- 04ページ間で operator と fuzzy を再利用
- 05locale を指定し API のソートを利用
活用シーン
- 01商品検索結果ページの実装
- 02ブランド・色・価格フィルターの追加
- 03検索ボックスへのオートコンプリート追加
- 04検索表示と商品クリックの計測
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ vtex/ skills --skill headless-intelligent-searchインストール後、Claude Code で/headless-intelligent-searchを入力して読み込まれたか確認します。
詳細
インテリジェント検索 API の統合
このスキルを使用する場面
ヘッドレス VTEX ストアフロントで、商品検索、カテゴリー閲覧、オートコンプリート、またはファセットフィルタリングを実装する場合に、このスキルを使用します。
- 商品一覧を含む検索結果ページの構築
- ファセットナビゲーション(カテゴリー、ブランド、価格、色のフィルター)の実装
- 検索入力へのオートコンプリート候補の追加
- インテリジェント検索のランキング用検索分析イベントの接続
次の用途にはこのスキルを使用しないでください。
- BFF アーキテクチャと API ルーティングの決定(
headless-bff-architectureを使用) - チェックアウトまたはカート API の統合(
headless-checkout-proxyを使用) - キャッシュ戦略と TTL の設定(
headless-caching-strategyを使用)
判断ルール
- インテリジェント検索はフロントエンドから直接呼び出してください。「すべて BFF 経由」というルールに対する唯一の例外です。完全に公開されており、認証は不要です。
- 特別な必要性(サーバーサイドレンダリングなど)がない限り、インテリジェント検索を BFF 経由でプロキシしないでください。プロキシすると、高頻度で実行される処理のレイテンシーが増加します。
- フィルタリングと並べ替えには、必ず API の
sortパラメーターとファセットパスを使用してください。クライアント側で結果を並べ替えたり、再度フィルタリングしたりしないでください。 - すべての検索リクエストに、必ず
pageとlocaleパラメーターを含めてください。 - 最初のページ以降をページネーションする場合は、必ず最初のページで API が返した
operatorとfuzzyの値を再利用してください。これらの値は検索クエリに応じて API が動的に決定します。 - インテリジェント検索イベント API に必ず分析イベントを送信してください。イベントを送信しないと、時間の経過とともに検索ランキングの質が低下します。
検索エンドポイントの概要:
| エンドポイント | メソッド | 用途 |
|---|---|---|
/product_search/{facets} | GET | クエリおよび/またはファセットで商品を検索する |
/facets/{facets} | GET | クエリで利用可能なフィルターを取得する |
/autocomplete_suggestions | GET | 入力中に用語と商品の候補を取得する |
/top_searches | GET | 人気の検索語句を上位 10 件取得する |
/correction_search | GET | スペルミスのある語句の修正候補を取得する |
/search_suggestions | GET | 検索語句に類似した候補語句を取得する |
/banners/{facets} | GET | クエリ用に設定されたバナーを取得する |
ファセットの組み合わせルール:
- 同じファセットタイプ → OR(和集合):「赤」と「青」の色を選択すると、どちらかの色に一致する商品が返されます。
- 異なるファセットタイプ → AND(積集合):「赤」の色と「Nike」のブランドを選択すると、赤い Nike の商品のみが返されます。
厳守事項
制約:インテリジェント検索イベント API に分析イベントを必ず送信する
すべてのヘッドレス検索実装では、インテリジェント検索イベント API - Headless に分析イベントを送信する必要があります。最低限、検索結果が表示されたときに検索インプレッションイベントを、検索結果から商品が選択されたときにクリックイベントを送信してください。
重要な理由
インテリジェント検索は、ユーザーの行動に基づいて結果をランキングするために機械学習を使用します。分析イベントがなければ、検索エンジンには行動データがなく、結果のパーソナライズや最適化ができません。時間が経つと、イベントを送信するストアと比べて検索品質が低下します。また、VTEX Admin の検索分析ダッシュボードにもデータが表示されません。
検出
インテリジェント検索の結果を表示しているにもかかわらず、sp.vtex.com/event-api またはインテリジェント検索イベント API を呼び出していない場合は、ただちに停止してください。分析イベントは検索と同時に実装する必要があります。
正しい例
// search-analytics.ts — sends events to Intelligent Search Events API
const ACCOUNT_NAME = "mystore";
const EVENTS_URL = `https://sp.vtex.com/event-api/v1/${ACCOUNT_NAME}/event`;
interface SearchEvent {
type: "search.query" | "search.click" | "search.add_to_cart";
text: string;
misspelled: boolean;
match: number;
operator: string;
locale: string;
agent: string;
url: string;
products?: Array<{ productId: string; position: number }>;
}
export async function sendSearchEvent(event: SearchEvent): Promise<void> {
try {
await fetch(EVENTS_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(event),
keepalive: true, // ensures event is sent even during navigation
});
} catch (error) {
// Analytics failures should not break the UI
console.warn("Failed to send search event:", error);
}
}
// Usage: call after rendering search results
function onSearchResultsRendered(query: string, products: Product[]): void {
sendSearchEvent({
type: "search.query",
text: query,
misspelled: false,
match: products.length,
operator: "and",
locale: "en-US",
agent: "my-headless-store",
url: window.location.href,
products: products.map((p, i) => ({
productId: p.productId,
position: i + 1,
})),
});
}
誤った例
// Search works but NO analytics events are sent — search ranking degrades
async function searchProducts(query: string): Promise<Product[]> {
const response = await fetch(
`https://mystore.vtexcommercestable.com.br/api/io/_v/api/intelligent-search/product_search/?query=${query}&locale=en-US`
);
const data = await response.json();
return data.products;
// Missing: no call to sendSearchEvent() — Intelligent Search cannot learn
}
制約:ページネーションをまたいで operator と fuzzy の値を必ず保持する
最初のページを超えて検索結果をページネーションする場合、最初のページのレスポンスで API から返された operator と fuzzy の値を必ず再利用してください。最初のリクエストではこれらを null として送信し、それ以降のページでは最初のレスポンスの値を使用する必要があります。
これが重要な理由
Intelligent Search API は、クエリと利用可能な結果に基づいて最適な operator と fuzzy の値を動的に決定します。固定値(常に operator: "or" や fuzzy: "0" を使用するなど)を使ったり、後続ページで値を渡し忘れたりすると、結果に一貫性がなくなります。2 ページ目に 1 ページ目の条件に一致しない商品が表示されたり、有効な結果が漏れたりすることがあります。これは検索エクスペリエンスの質を低下させるよくある原因ですが、VTEX では十分に文書化されていません。
検出
ページネーションの実装で、最初のページのレスポンスから operator/fuzzy を保存して再利用していない場合は、直ちに停止してください。後続ページで一貫性のない結果が返されます。
正しい例
// Properly manages operator/fuzzy across pagination
interface SearchState {
query: string;
page: number;
count: number;
locale: string;
facets?: string;
operator: string | null; // null for first page, then persisted
fuzzy: string | null; // null for first page, then persisted
}
async function searchProducts(state: SearchState): Promise<SearchResponse> {
const { query, page, count, locale, facets = "", operator, fuzzy } = state;
const params = new URLSearchParams({
query,
locale,
page: String(page),
count: String(count),
});
// Only add operator/fuzzy if this is not the first page
if (operator !== null) params.set("operator", operator);
if (fuzzy !== null) params.set("fuzzy", fuzzy);
const baseUrl = `https://${ACCOUNT}.vtexcommercestable.com.br`;
const facetPath = facets ? `/${facets}` : "";
const url = `${baseUrl}/api/io/_v/api/intelligent-search/product_search${facetPath}?${params}`;
const response = await fetch(url);
const data = await response.json();
// Store operator/fuzzy from response for next page
if (page === 0) {
state.operator = data.operator;
state.fuzzy = data.fuzzy;
}
return data;
}
// Usage: first page
const state: SearchState = {
query: "running shoes",
page: 0,
count: 24,
locale: "en-US",
operator: null, // API will decide
fuzzy: null, // API will decide
};
const firstPage = await searchProducts(state);
// Usage: subsequent pages
state.page = 1;
const secondPage = await searchProducts(state); // reuses operator/fuzzy
誤った例
// Using fixed operator/fuzzy values — causes bad results
async function searchProducts(query: string, page: number): Promise<SearchResponse> {
const params = new URLSearchParams({
query,
locale: "en-US",
page: String(page),
count: "24",
operator: "or", // WRONG: fixed value instead of API-provided
fuzzy: "0", // WRONG: fixed value instead of API-provided
});
const response = await fetch(
`https://mystore.vtexcommercestable.com.br/api/io/_v/api/intelligent-search/product_search/?${params}`
);
return response.json();
}
// Or forgetting operator/fuzzy entirely on subsequent pages
async function searchProductsPage2(query: string): Promise<SearchResponse> {
const params = new URLSearchParams({
query,
locale: "en-US",
page: "1", // WRONG: no operator/fuzzy from first page
count: "24",
});
// ...
}
制約:Intelligent Search を不必要に BFF 経由でプロキシしない
Intelligent Search API のリクエストは、フロントエンドから直接送信する必要があります。特別な必要性(サーバーサイドレンダリングや、カスタムビジネスロジックの追加など)がない限り、検索トラフィックを BFF 経由にしないでください。
これが重要な理由
Intelligent Search は認証を必要としない公開 API です。BFF のプロキシレイヤーを追加すると、ネットワークのホップが増え、検索操作のたびにレイテンシーが増加し、サーバーコストがかかるうえ、CDN によるレスポンスの効率的なキャッシュが妨げられます。検索クエリは高頻度で実行されるため、追加されるレイテンシーがわずか 50ms でもコンバージョンに影響します。
検出
Intelligent Search のすべての呼び出しが VTEX への直接アクセスではなく BFF エンドポイント経由になっている場合は、その旨を開発者に伝えてください。セキュリティ上の問題ではありませんが、パフォーマンス上の懸念があります。正当な理由(SSR など)がなければ、フロントエンドから直接呼び出すことを推奨してください。
正しい例
// Frontend — calls Intelligent Search directly (no BFF needed)
const VTEX_SEARCH_BASE = `https://${ACCOUNT}.vtexcommercestable.com.br/api/io/_v/api/intelligent-search`;
export async function getAutocomplete(term: string, locale: string): Promise<AutocompleteResponse> {
const params = new URLSearchParams({ query: term, locale });
const response = await fetch(`${VTEX_SEARCH_BASE}/autocomplete_suggestions?${params}`);
return response.json();
}
export async function getTopSearches(locale: string): Promise<TopSearchesResponse> {
const params = new URLSearchParams({ locale });
const response = await fetch(`${VTEX_SEARCH_BASE}/top_searches?${params}`);
return response.json();
}
export async function getFacets(facetPath: string, query: string, locale: string): Promise<FacetsResponse> {
const params = new URLSearchParams({ query, locale });
const response = await fetch(`${VTEX_SEARCH_BASE}/facets/${facetPath}?${params}`);
return response.json();
}
誤った例
// BFF proxy for Intelligent Search — unnecessary overhead
// server/routes/search.ts
router.get("/api/bff/search", async (req, res) => {
const { query, from, to, locale } = req.query;
// This just forwards to VTEX with no added value
const response = await fetch(
`https://mystore.vtexcommercestable.com.br/api/io/_v/api/intelligent-search/product_search/?query=${query}&from=${from}&to=${to}&locale=${locale}`
);
const data = await response.json();
res.json(data); // Added latency for no benefit
});
推奨パターン
ヘッドレスストアフロントにおける Intelligent Search のデータフロー:
Frontend (Browser)
│
├── GET /api/io/_v/api/intelligent-search/product_search/...
│ └── Returns: products, pagination info, operator, fuzzy
│
├── GET /api/io/_v/api/intelligent-search/facets/...
│ └── Returns: available filters with counts
│
├── GET /api/io/_v/api/intelligent-search/autocomplete_suggestions?query=...
│ └── Returns: suggested terms + suggested products
│
└── POST https://sp.vtex.com/event-api/v1/{account}/event
└── Sends: search impressions, clicks, add-to-cart events
すべてのエンドポイントに対応する型付き検索 API クライアント:
// lib/intelligent-search-client.ts
const ACCOUNT = "mystore";
const ENVIRONMENT = "vtexcommercestable";
const BASE_URL = `https://${ACCOUNT}.${ENVIRONMENT}.com.br/api/io/_v/api/intelligent-search`;
interface ProductSearchParams {
query?: string;
page: number;
count?: number;
locale: string;
facets?: string;
sort?: "price:asc" | "price:desc" | "orders:desc" | "name:asc" | "name:desc" | "release:desc" | "discount:desc";
hideUnavailableItems?: boolean;
operator?: string | null;
fuzzy?: string | null;
// Optional parameters
simulationBehavior?: "default" | "skip" | "only1P";
}
interface ProductSearchResponse {
products: Product[];
recordsFiltered: number;
// Pagination info
pagination: {
count: number;
current: { index: number };
before: Array<{ index: number }>;
after: Array<{ index: number }>;
perPage: number;
next: { index: number } | null;
previous: { index: number } | null;
first: { index: number };
last: { index: number };
};
// Search metadata to persist for next pages
operator: string;
fuzzy: string;
// Spelling correction info
correction?: {
misspelled: boolean;
text: string;
correction: string;
};
// Search behavior
translated: boolean;
locale: string;
query: string;
}
export async function productSearch(params: ProductSearchParams): Promise<ProductSearchResponse> {
const { facets = "", operator, fuzzy, ...queryParams } = params;
const searchParams = new URLSearchParams();
if (queryParams.query) searchParams.set("query", queryParams.query);
searchParams.set("page", String(queryParams.page));
searchParams.set("locale", queryParams.locale);
if (queryParams.count) searchParams.set("count", String(queryParams.count));
if (queryParams.sort) searchParams.set("sort", queryParams.sort);
if (queryParams.hideUnavailableItems) searchParams.set("hideUnavailableItems", "true");
if (queryParams.simulationBehavior) searchParams.set("simulationBehavior", queryParams.simulationBehavior);
// Only add operator/fuzzy if not null (i.e., not first page)
if (operator !== null && operator !== undefined) searchParams.set("operator", operator);
if (fuzzy !== null && fuzzy !== undefined) searchParams.set("fuzzy", fuzzy);
const facetPath = facets ? `/${facets}` : "";
const url = `${BASE_URL}/product_search${facetPath}?${searchParams}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Search failed: ${response.status}`);
}
return response.json();
}
レスポンス構造に関する注意事項:
operatorとfuzzy:最初のページから値を保存し、後続ページでも再利用する必要があります。分析イベントにとっても重要です。pagination:現在のページより前後にあるページ数など、詳細なページネーションのメタデータが含まれます。correction.misspelled:検索語のスペルミスの有無を示すブール値です。分析イベントで正確に送信する必要があります。recordsFiltered:検索条件に一致する結果の総数(全ページの合計)です。products:現在のページの商品のみを含む配列です。
ファセットナビゲーションヘルパー:
// lib/facets.ts
export async function getFacets(
facetPath: string,
query: string,
locale: string
): Promise<FacetsResponse> {
const params = new URLSearchParams({ query, locale });
const url = `${BASE_URL}/facets/${facetPath}?${params}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Facets fetch failed: ${response.status}`);
}
return response.json();
}
// Build facet path from selected filters
export function buildFacetPath(selectedFilters: Record<string, string[]>): string {
const parts: string[] = [];
for (const [key, values] of Object.entries(selectedFilters)) {
for (const value of values) {
parts.push(`${key}/${value}`);
}
}
return parts.join("/");
}
検索入力用のデバウンス付きオートコンプリート:
// lib/autocomplete.ts
export function createDebouncedAutocomplete(delayMs: number = 300) {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return function debouncedAutocomplete(
query: string,
locale: string,
callback: (suggestions: AutocompleteResponse) => void
): void {
if (timeoutId) clearTimeout(timeoutId);
if (query.length < 2) {
callback({ searches: [] });
return;
}
timeoutId = setTimeout(async () => {
const params = new URLSearchParams({ query, locale });
const response = await fetch(`${BASE_URL}/autocomplete_suggestions?${params}`);
const suggestions = await response.json();
callback(suggestions);
}, delayMs);
};
}
分析機能を備えた検索の完全なオーケストレーション:
// search-page.ts — framework-agnostic search orchestration
import { productSearch } from "./lib/intelligent-search-client";
import { getFacets, buildFacetPath } from "./lib/facets";
import { createDebouncedAutocomplete } from "./lib/autocomplete";
import { sendSearchEvent } from "./search-analytics";
interface SearchState {
query: string;
page: number;
count: number;
locale: string;
selectedFilters: Record<string, string[]>;
operator: string | null;
fuzzy: string | null;
results: ProductSearchResponse | null;
facets: FacetsResponse | null;
}
const state: SearchState = {
query: "",
page: 0,
count: 24,
locale: "en-US",
selectedFilters: {},
operator: null, // API decides on first page
fuzzy: null, // API decides on first page
results: null,
facets: null,
};
async function executeSearch(): Promise<void> {
const facetPath = buildFacetPath(state.selectedFilters);
const [searchResults, facetResults] = await Promise.all([
productSearch({
query: state.query,
page: state.page,
count: state.count,
locale: state.locale,
facets: facetPath,
operator: state.operator,
fuzzy: state.fuzzy,
}),
getFacets(facetPath, state.query, state.locale),
]);
// Store operator/fuzzy from first page for subsequent pages
if (state.page === 0) {
state.operator = searchResults.operator;
state.fuzzy = searchResults.fuzzy;
}
state.results = searchResults;
state.facets = facetResults;
// Send analytics event after results are rendered
sendSearchEvent({
type: "search.query",
text: state.query,
misspelled: searchResults.correction?.misspelled ?? false,
match: searchResults.recordsFiltered,
operator: searchResults.operator,
locale: state.locale,
agent: "my-headless-store",
url: window.location.href,
products: searchResults.products.map((p, i) => ({
productId: p.productId,
position: state.page * searchResults.products.length + i + 1,
})),
});
}
// Handle product click from search results — send click event
function onProductClick(productId: string, position: number): void {
sendSearchEvent({
type: "search.click",
text: state.query,
misspelled: false,
match: state.results?.recordsFiltered ?? 0,
operator: state.results?.operator ?? "and",
locale: state.locale,
agent: "my-headless-store",
url: window.location.href,
products: [{ productId, position }],
});
}
// Handle page change
function goToPage(newPage: number): void {
state.page = newPage;
executeSearch(); // reuses operator/fuzzy from first page
}
// Handle new search (reset pagination state)
function newSearch(query: string): void {
state.query = query;
state.page = 0;
state.operator = null; // reset for new search
state.fuzzy = null; // reset for new search
executeSearch();
}
よくある失敗例
-
分析用に
correction.misspelledフィールドとoperatorフィールドを追跡していない:API は、用語のスペルミスの有無を示すブール値のcorrection.misspelledと、分析イベントに正しく送信する必要があるoperatorフィールドを返します。これらの値は、Intelligent Search の機械学習が検索品質とユーザーの行動を把握するうえで重要です。const response = await productSearch({ query: "red nike shoes", page: 0, locale: "en-US" }); // Extract values from API response const isMisspelled = response.correction?.misspelled ?? false; const operatorUsed = response.operator; // "and" or "or" // CRITICAL: Send these exact values to analytics sendSearchEvent({ type: "search.query", text: response.query, misspelled: isMisspelled, // Must match API's correction.misspelled operator: operatorUsed, // Must match API's operator value match: response.recordsFiltered, locale: state.locale, agent: "my-headless-store", url: window.location.href, products: response.products.map((p, i) => ({ productId: p.productId, position: i + 1, })), }); -
localeパラメーターを送信していない:localeを指定しないと、Intelligent Search は誤った言語で結果を返したり、ロケール固有の関連性ルールを適用できなかったりする場合があります。多言語ストアでは、複数の言語が混在した結果が表示されます。すべてのリクエストに必ずlocaleを含めてください。// Always include locale in search parameters const params = new URLSearchParams({ query: "shoes", locale: "en-US", // Required for correct language processing page: "0", count: "24", }); -
operatorやfuzzyに固定値を使用している、または値を保持していない:すべてのリクエストでoperator: "or"やfuzzy: "0"などの固定値を設定したり、後続ページのリクエストで値を渡さなかったりすることです。API は最初のページでこれらの値を動的に決定するため、ページネーションのために保存して再利用する必要があります。// Store operator/fuzzy from first page, reuse on subsequent pages let searchState = { operator: null, fuzzy: null }; const firstPage = await productSearch({ query: "shoes", page: 0, count: 24, locale: "en-US" }); searchState.operator = firstPage.operator; // Store from API response searchState.fuzzy = firstPage.fuzzy; // Page 2 reuses these values const secondPage = await productSearch({ query: "shoes", page: 1, count: 24, locale: "en-US", operator: searchState.operator, // Reuse from first page fuzzy: searchState.fuzzy, }); -
/product_searchがファセットを返すと思い込んでいる:/product_searchエンドポイントが返すのは、商品の情報とページネーション情報のみです。利用可能なフィルターを取得するには、別途/facetsエンドポイントを呼び出す必要があります。両方が必要な場合は、並列リクエストを行ってください。// Fetch products and facets in parallel const [products, filters] = await Promise.all([ productSearch({ query: "shoes", page: 0, locale: "en-US" }), getFacets("", "shoes", "en-US"), // Separate call for filters ]); -
適切な場面で
hideUnavailableItemsを使用していない:デフォルトでは、API が在庫切れの商品を返す場合があります。ほとんどのストアフロントでは、クライアント側ではなく API 側で除外するために、hideUnavailableItems: trueを設定してください。const response = await productSearch({ query: "shoes", page: 0, locale: "en-US", hideUnavailableItems: true, // Filter out-of-stock products }); -
検索ランキングのロジックをクライアント側で再構築している:結果を取得してからフロントエンドで並べ替えたり、再度フィルタリングしたりすると、Intelligent Search のランキング機能が活かされません。クライアント側のフィルタリングは現在のページにしか適用されず、カタログ全体には適用されません。API の
sortパラメーターとファセットパスを使用してください。// Use API-level sorting — don't re-sort in the frontend const results = await productSearch({ query: "shirt", sort: "price:asc", // API handles sorting across entire result set locale: "en-US", from: 0, to: 23, facets: "category-1/clothing/color/red", // API handles filtering across entire catalog });
確認チェックリスト
- Intelligent Search はフロントエンドから直接呼び出されていますか(不要な BFF 経由になっていませんか)?
- すべての検索リクエストに
pageパラメーターとlocaleパラメーターが含まれていますか? - 最初のページの
operatorとfuzzyの値を保存し、後続ページで再利用していますか? - API レスポンスの
correction.misspelledとoperatorが、分析イベントに正しく送信されていますか? - 検索結果の描画後に、分析イベントが
sp.vtex.com/event-apiに送信されていますか? - ユーザーが検索結果から商品を選択したときに、クリックイベントが送信されていますか?
- 並べ替えには、クライアント側での再並べ替えではなく、API の
sortパラメーターを使用していますか? - フィルタリングには、クライアント側のフィルタリングではなく、ファセットパスを使用していますか?
- API の過剰な呼び出しを避けるため、オートコンプリートにデバウンスを適用していますか?
- フィルターは
/product_searchから取得できると想定せず、/facetsエンドポイントから取得していますか? - 在庫切れの商品を表示しないストアフロントでは、
hideUnavailableItemsをtrueに設定していますか?
参考資料
- ヘッドレスカタログと検索 — ヘッドレスストアでの商品閲覧と検索の概要
- Intelligent Search API リファレンス — すべての検索エンドポイントの完全な API リファレンス
- Intelligent Search Events API - Headless — ヘッドレス実装から分析データを送信するための Events API
- Intelligent Search の概要 — Intelligent Search の機能の概要
- 検索の設定 — 検索可能な仕様、ファセットの順序、その他の検索設定の構成方法
- オートコンプリート — Intelligent Search でオートコンプリート候補がどのように機能するか