mcpField
H
headless-intelligent-search

headless-intelligent-search

github.com/vtex开发471.1k 次安装2 周前更新
代码仓库

关于 headless-intelligent-search

面向构建无头 VTEX 店铺的开发者,提供 Intelligent Search 集成指导,涵盖商品搜索、筛选、自动补全及分析事件。强调直接从前端调用 API,并在分页时沿用 API 返回的参数。

主要功能

  1. 01商品搜索与分类筛选
  2. 02自动补全和搜索建议
  3. 03发送搜索展示与点击事件
  4. 04分页沿用 operator 和 fuzzy
  5. 05按 locale 请求并使用 API 排序

适用场景

  1. 01实现商品搜索结果页
  2. 02构建品牌、颜色和价格筛选
  3. 03为搜索框添加自动补全
  4. 04追踪搜索曝光和商品点击

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/vtex/skills --skill headless-intelligent-search

安装后在 Claude Code 中输入/headless-intelligent-search确认已加载。

详细介绍

Intelligent Search API 集成

适用场景

在无头 VTEX 商店中实现商品搜索、分类浏览、自动补全或分面筛选时,使用此技能。

  • 构建包含商品列表的搜索结果页
  • 实现分面导航(分类、品牌、价格、颜色筛选)
  • 为搜索输入框添加自动补全建议
  • 接入 Intelligent Search 排名所需的搜索分析事件

以下场景不要使用此技能:

决策规则

  • 直接从前端调用 Intelligent Search——这是“所有请求都通过 BFF”规则的唯一例外。它完全公开,无需身份验证。
  • 除非有特定需求(例如服务端渲染),否则不要通过 BFF 代理 Intelligent Search。代理会增加高频操作的延迟。
  • 始终使用 API 的 sort 参数和 facet 路径进行筛选和排序——绝不要在客户端重新排序或筛选结果。
  • 每个搜索请求都必须包含 page 和 locale 参数。
  • 分页到第一页之后时,始终复用 API 在第一页返回的 operator 和 fuzzy 值——API 会根据搜索查询动态决定这些值。
  • 始终向 Intelligent Search Events API 发送分析事件——没有这些事件,搜索排名会随着时间推移而下降。

搜索端点概览:

端点方法用途
/product_search/{facets}GET按查询和/或 facet 搜索商品
/facets/{facets}GET获取查询可用的筛选条件
/autocomplete_suggestionsGET输入时获取词条和商品建议
/top_searchesGET获取最热门的 10 个搜索词
/correction_searchGET获取拼写错误词条的拼写更正建议
/search_suggestionsGET获取与搜索词相似的建议词条
/banners/{facets}GET获取为查询配置的横幅

Facet 组合规则:

  • 相同 facet 类型 → OR(并集):选择颜色“红色”和“蓝色”时,返回匹配任一颜色的商品
  • 不同 facet 类型 → AND(交集):选择颜色“红色”和品牌“Nike”时,只返回红色的 Nike 商品

硬性约束

约束:必须向 Intelligent Search Events API 发送分析事件

所有无头搜索实现都必须向 Intelligent Search Events API - Headless 发送分析事件。至少要在显示搜索结果时发送搜索曝光事件,并在用户从搜索结果中选择商品时发送点击事件。

为什么这很重要

Intelligent Search 使用机器学习,根据用户行为为结果排序。如果没有分析事件,搜索引擎就没有行为数据,无法个性化或优化结果。随着时间推移,搜索质量会比发送事件的商店更差。此外,VTEX Admin 搜索分析仪表板也不会显示任何数据。

检测

如果某个搜索实现从 Intelligent Search 渲染结果,却没有调用 sp.vtex.com/event-api 或 Intelligent Search Events 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",
  });
  // ...
}

约束:不要通过 BFF 不必要地代理 Intelligent Search

Intelligent Search API 请求应直接从前端发起。除非有特定需求(例如服务端渲染或添加自定义业务逻辑),否则不要通过 BFF 路由搜索流量。

原因

Intelligent Search 是一个无需身份验证的公共 API。增加 BFF 代理层会多出一次网络跳转,增加每次搜索操作的延迟和服务器成本,并导致 CDN 无法高效缓存响应。搜索查询是高频操作——即使增加 50 ms 的延迟也会影响转化率。

检测

如果所有 Intelligent Search 调用都通过 BFF 端点,而不是直接发送至 VTEX → 向开发者指出这一点。这不是安全问题,而是性能问题。如果没有合理理由(例如 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 可能会返回缺货商品。对于大多数店面,请设置 hideUnavailableItems: true,在 API 层面而非客户端过滤这些商品。

    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 调用?
  • 是否从 /facets 端点获取筛选条件(而不是假设它们来自 /product_search)?
  • 对于应隐藏缺货商品的店面,是否将 hideUnavailableItems 设置为 true?

参考资料

评论

支持 Markdown