# Search the mock-shop directory

Use the directory to find a complete reference catalog for a business. Search
returns published shops that contain products, ranked by their names,
descriptions, taxonomy categories, collection titles, and a sample of product titles.

## Request

Send GraphQL to `POST https://mock.shopify.com/api/public/graphql` with
`Content-Type: application/json`. The directory is public: no credentials,
API key, or employee login are required. Staging uses
`https://mock-shop-staging.shopify.io/api/public/graphql` and is anonymous too.
Browser clients can call it from any origin. Each selected shop's catalog API
also needs no token for products and collections.

Existing callers of `/api/corpus/graphql` can keep using its authenticated
`shopDirectory` fields. To switch to the public endpoint, change the URL and
remove the Authorization header; the query and response fields stay the same.

```graphql
query FindMockShops($query: String!, $limit: Int = 3) {
  shopDirectory {
    search(query: $query, limit: $limit) {
      id
      subdomain
      name
      shortDescription
      categories
      collectionTitles
      productCount
      collectionCount
      currencyCode
      storefrontUrl
      storefrontApiUrl
    }
  }
}
```

Example variables:

```json
{ "query": "dog accessories", "limit": 3 }
```

`query` must contain a letter or number and be 1–500 characters after trimming.
`limit` defaults to 3 and must be an integer from 1 to 10. Invalid values return
a GraphQL error with `extensions.code: "BAD_USER_INPUT"`; inspect GraphQL
errors even when the HTTP response is 200. Send one search per request. The public
schema limits query complexity to 100, charges 50 plus selected fields for each
search, and rejects excessive queries before executing any search. It also
limits depth to 8 and query tokens to 5,000. Schema introspection is excluded from
the depth and complexity limits so standard GraphQL clients can discover the API.
The public mock-shop API currently exposes only directory queries;
the corpus API continues to require HTTP Basic credentials.

## Ranking and results

Search ranks shops by semantic similarity. Each shop has a
precomputed `gemini-embedding-2` embedding of its name, descriptions,
categories, collections, and up to 12 product titles sampled across categories.
The query is embedded through Shopify's AI Proxy and compared against the shop
vectors using Postgres/pgvector cosine distance. No product/image corpus vectors
are mixed into this search. The existing Gemini embedding client is reused with
[Google's retrieval format](https://ai.google.dev/gemini-api/docs/embeddings#task-types):
`title: {name} | text: {document}` for shops and
`task: search result | query: {query}` for searches.

Exact normalized names or subdomains always rank first. Other indexed matches
must have cosine similarity of at least `0.59` and be within `0.08` of the best
match. When at least 10 current shop vectors exist, the best score must also
exceed the median score by at least `0.055`; this filters queries that have
uniformly high similarity to unrelated catalogs. Smaller directories use only
the absolute and best-match cutoffs. Exact names bypass these filters, using
case-, accent-, and punctuation-insensitive matching without stemming or
synonyms. All other results rank by cosine similarity. Ties sort by subdomain.
These are retrieval heuristics,
not probabilities or guarantees that a shop meets every requested constraint.
Search returns **up to** the requested limit and can return an empty list.

For example, `supplies for a new puppy` can find pet catalogs without the word
“puppy” appearing in their summaries. `comfortable outfits for sleeping` finds
loungewear; `candles` still places the exact candle shop first.

Query vectors are cached for one hour per process. A query embedding request
has a five-second response timeout, a two-second connection timeout, and no
foreground retries. If a query embedding cannot be obtained because credentials
are missing, AI Proxy fails, or the provider returns an invalid vector, the
endpoint returns a GraphQL error with `extensions.code: "SEARCH_UNAVAILABLE"`
and `extensions.retryable: true`. Inspect GraphQL errors even with HTTP 200;
an unavailable search is not a successful empty result. There is no keyword
scoring or fallback.

Search checks document digests against current catalog metadata. Missing or
changed embeddings enqueue a background refresh. Those catalogs can only match
an exact name or subdomain until the job finishes; stale vectors are never used.
If no current shop vectors exist, only exact matches can be returned, and the
query makes no embedding API call. Refresh requests are deduplicated with a five-minute database lease;
failed jobs retry, and an expired lease lets a subsequent search recover a lost
job. Workers use the existing `embeddings_backfill` queue. Publishing or editing
a catalog does not make a synchronous AI call: its next search detects the change.
The materializer does not currently enqueue an embedding at publication time.
Run the refresh task after a batch of new shops to prepare them before their
first directory search.

Each result includes:

| Field | Meaning |
|---|---|
| `id`, `subdomain` | The mock-shop database identity and canonical source slug. Use the slug to identify the chosen catalog. |
| `name`, `shortDescription` | Name and nullable short description for presenting the choice. |
| `categories`, `collectionTitles` | Taxonomy summaries and all collection titles; either list may be empty. Categories are omitted when they do not represent at least half the catalog. |
| `productCount`, `collectionCount` | Catalog sizes at search time; variant counts are separate from product counts. |
| `currencyCode` | Source catalog currency; compare it with the destination before interpreting prices. |
| `storefrontUrl` | Browser preview on the renderer's host family. |
| `storefrontApiUrl` | Public Storefront GraphQL endpoint for fetching the catalog. |

Use the returned URLs. Production API hosts are `<subdomain>.mock.shop`, while
browser previews use `<subdomain>.hydrogen.mock.shop`. The default catalog's
slug is `demostore` and its API URL is `https://demostore.mock.shop/api`. A request through
the `mock.shopify.com` alias still returns canonical `mock.shop` API URLs.
Development and staging return their own configured host families.

## Existing list and exact lookup

`shopDirectory.shops` still lists every published shop ordered by subdomain,
including shops with no products. `shopDirectory.shop(subdomain: "pets")`
still returns an exact published shop or null for an invalid, unknown, or
unpublished slug. Those fields retain `id`, `subdomain`, `name`, and
`shortDescription`; the richer metadata belongs to `search` results.

Recheck the selected shop before importing: a shop can be unpublished after a
search. The public catalog endpoint then returns 404. Search does not create
or modify any Shopify resources.

## Rollout and validation

Deploy the schema-only `mock_shops_gem2_v1` migration before deploying
the search code. Then populate existing catalogs outside migrations:

```sh
shadowenv exec -- bin/rails mock_shop:refresh_directory_embeddings
```

The command is idempotent and skips re-embedding unchanged documents. The
small directory uses an exact cosine scan over one full 3072-dimensional vector
per shop; it needs no HNSW index, partitions, or separate vector database.
The encoder is pinned to Gemini Embeddings 2 in code, with no per-row model
column. A future encoder change must bump the version in `SearchDocument` and
regenerate the vectors; the changed digests exclude older embeddings during
that rebuild.

For a local tophat with the current public catalogs and real AI Proxy calls:

```sh
dev clone-prod all
shadowenv exec -- bin/rails mock_shop:refresh_directory_embeddings
shadowenv exec -- bin/rails runner script/evaluate_shop_directory.rb
```

The evaluator uses the committed natural-language and no-match examples in
`test/fixtures/files/shop_directory_queries.json`, prints semantic
shortlists and elapsed times, and exits nonzero on a missed expected catalog or
an unexpected no-match result. Expected shops should be reviewed when the
published catalog changes. This is a small relevance regression set, not a
comprehensive quality benchmark. Unit and endpoint tests run offline, including
stale vectors, queue/provider failures, authentication, exact matches, and limits.
