AACsearch
Architecture

Analytics feedback loop

How a search query becomes a SearchUsageEvent, how clicks and conversions are recorded, and how aggregation feeds the dashboard for relevance tuning.

Every public-facing search interaction emits a row in the unified SearchUsageEvent table. A single table with a type discriminator (search_query, zero_results, click, conversion, widget_open, filter_applied, visit) keeps the schema small and makes aggregation cheap.

End-to-end loop

Description

The diagram shows how a search request, a result click and a conversion all land in the unified SearchUsageEvent table with a queryId correlation, then are aggregated by index × hour × type into CTR, CVR and zero-result-rate metrics. The Studio analytics dashboard surfaces those metrics, and editor actions (synonyms, curations, field weights, presets) flow back through policy-cache to the read path — closing the loop without a deploy.

flowchart LR
    classDef http fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
    classDef db fill:#fef3c7,stroke:#b45309,color:#78350f
    classDef agg fill:#dcfce7,stroke:#15803d,color:#14532d
    classDef ui fill:#ede9fe,stroke:#6d28d9,color:#4c1d95

    subgraph Stage1["1 · Query"]
        SDK1["Customer SDK / Widget"] --> Search["/api/search/public/multi"]:::http
        Search --> Hit["TS multi_search<br/>returns hits"]
        Hit --> Resp["Response sanitized<br/>queryId attached"]
        Resp --> Ev1["recordSearchUsageAsync<br/>type=search_query"]:::db
        Hit --> Zero{"hits.length == 0?"}
        Zero -- yes --> Ev2["type=zero_results"]:::db
    end

    subgraph Stage2["2 · Click"]
        SDK2["Widget / SDK click handler"] --> Track1["/api/events/track<br/>type=result_click"]:::http
        Track1 --> Ev3["type=click<br/>{ queryId, productId, position }"]:::db
    end

    subgraph Stage3["3 · Conversion"]
        SDK3["Checkout / add-to-cart"] --> Track2["/api/events/track<br/>type=conversion"]:::http
        Track2 --> Ev4["type=conversion<br/>{ queryId, productId, conversionType }"]:::db
    end

    subgraph Stage4["4 · Aggregation (async)"]
        Ev1 --> Agg["analytics aggregation<br/>group by indexId × hour × type"]:::agg
        Ev2 --> Agg
        Ev3 --> Agg
        Ev4 --> Agg
        Agg --> CTR["CTR = clicks / search_query"]
        Agg --> CVR["CVR = conversion / click"]
        Agg --> ZRR["zero-rate = zero_results / search_query"]
    end

    subgraph Stage5["5 · Dashboard refresh"]
        CTR --> Studio["apps/saas analytics dashboard"]:::ui
        CVR --> Studio
        ZRR --> Studio
        Studio --> Tune["relevance tuning:<br/>synonyms, curations,<br/>field weights, presets"]
    end

    Tune -. feeds .-> Search

The event types

TypeEmitted byCarries
search_querypublic-handler.ts (fire-and-forget)query, filters, sort, locale, queryId, referrer, ua
zero_resultspublic-handler.ts when found == 0same shape as search_query
result_clickclickwidget / SDK → /api/events/trackqueryId, productId, position, sessionId
widget_openhosted widget on mountsessionId, anonymousUserId
filter_usedfilter_appliedwidget on facet togglefilters (the chosen facet/value), queryId
conversioncheckout integration → /api/events/trackqueryId, productId, conversionType (purchase, add_to_cart, ...)
visitoptional widget on page-viewsessionId, anonymousUserId, referrer
click (raw)non-search-bound clickproductId, position

All events share the row shape { indexId, organizationId, type, count, metadata, createdAt }. The metadata JSON column is the only schema-flexible field; it caps at 4 KB.

The server-side search_query event mints a stable queryId and returns it on the response. The widget echoes the queryId back on every downstream click / conversion event. Aggregation joins on (organizationId, indexId, queryId) to compute click-through and conversion per query, per ranking slot.

Tuning loop

The Studio analytics dashboard surfaces the three core ratios (CTR, CVR, zero-rate) plus per-query drilldowns. An editor can act on them by:

  • adding a synonym or stemming rule for a high-zero-result query,
  • promoting a SKU via a curation set for an under-clicked query,
  • adjusting field weights or building a preset for a slow-converting query.

These edits flow into the read path through policy-cache (60 s LRU) — the loop closes without a deploy.

  • Read path — where recordSearchUsageAsync sits.
  • Connector lifecycle — ingest-side counterpart that produces the documents being searched and clicked.

On this page