markaDocs
Architecture

Search

Typesense hybrid lexical + vector search, a fire-and-forget index write with a reconciling cron behind it, and the 200-listing candidate ceiling — including which surfaces it binds.

Marka's search runs on Typesense, hybrid: the same query is matched lexically and semantically, and the two result sets are merged under a filter designed to keep vector search from inventing plausible-looking nonsense. Indexing is deliberately not transactional, with a reconciling cron as the safety net.

This page matters if you are consuming GET /v1/search or the MCP search_listings tool, because those two do not have the same reach.

Get an API key to try the API on your own account, then make your first call with Getting started.

What is in the index

One collection, listings. Its schema, with the flags that matter:

FieldTypeNotes
title, titleNormalized, descriptionstringdescription optional
aliasesstring[]resolved synonyms — how "N64" finds "Nintendo 64"
brand, modelstringoptional; brand facetable
categoryPathstring[]facet
condition, currency, authority, sellerAccountIdstringfacet
priceint32integer minor units, like everywhere else
pickupEnabled, shippingEnabledboolfacet
locationgeopointoptional
visibilityRadiusKmint32optional
inventoryStatestringfacet
primaryLanguagestringoptional facet
sellerReliabilityScorefloat0 to 100, see below
createdAtUnixint64the default sorting field
embeddingfloat[]auto-generated from title + description using the built-in ts/multilingual-e5-base model

The embedding is generated by Typesense itself at index time, which is what makes semantic matching work across the nine languages the marketplace supports without a separate embedding service.

How sellerReliabilityScore is computed

100 means the seller has no seller-fault incidents in the last 180 days. Each such incident (a pickup no-show, a cancellation after accepting, a late or unhanded shipment, an item not as described, and so on) lowers it by a fixed amount, down to a floor of 0; incidents caused by the buyer do not count. The score is recomputed every time a listing is written to the index and by the 6-hourly reconcile, so an old incident ages out of the window on the next pass.

What gets indexed, and what gets removed

One predicate decides, and it is short:

if (listing.authority !== "MARKA" || listing.status !== "active") {
  await deleteListingFromTypesense(client, listing.id);
  return "removed";
}

Buyer-visible means MARKA authority and active status. Everything else — draft, paused, sold, reserved, removed, or any listing under external channel authority — is deleted from the index rather than flagged in it. So the index only ever contains things that can be bought, and the delete is idempotent.

Indexing is fire-and-forget

The index write happens after the database transaction commits, and it cannot fail the request that triggered it:

export function indexListingSafe(listing: Listing): void {
  if (!getWebEnv().TYPESENSE_API_KEY) return; // search not configured (dev/test)
  const client = createTypesenseClient();
  void syncListingToIndex(client, listing).catch((err) =>
    reportError(err, {
      route: "listing-index",
      area: "search-index",
      listingId: listing.id,
      status: listing.status,
      severity: "low",
    }),
  );
}

Note the shape: returns void, the promise is void-ed, errors are caught and reported but never rethrown. This is a deliberate trade — a Typesense outage must not stop a seller publishing a listing — and it has an equally deliberate consequence:

Publishing is effectively instant, but not guaranteed instant

In the normal case a published listing is searchable immediately: the index write fires the moment the transaction commits. But because it is fire-and-forget, a Typesense outage means the write is lost rather than retried, and the listing stays unsearchable until the reconciling cron catches it. If you are building a flow that publishes and then immediately expects to find the listing in search results, treat search visibility as usually immediate and eventually consistent, not as a guarantee of the publish call.

The reconciling cron

A background worker re-indexes everything every six hours, and this is what makes the fire-and-forget write acceptable:

  • A health check runs first; if Typesense is unreachable, the run ends rather than proceeding on bad information.
  • The full set of active MARKA listings is paginated — the whole catalogue, not a page of it.
  • With reconciliation on, indexed ids that no longer correspond to an active listing are deleted as orphans.

The orphan deletion carries two safety valves, both worth knowing because they mean a reconcile can legitimately decline to do anything:

  1. If the database enumerated zero active listings while the index is non-empty, the delete phase is skipped entirely — that pattern is far more likely to be a failed database read than every listing genuinely vanishing.
  2. If the orphan set would exceed 90% of the index, it is skipped — rather than risk hiding almost the whole catalogue on the strength of one bad enumeration.

How a query is matched

Search is hybrid. Fields are queried as title, titleNormalized, aliases, description, brand, model, embedding — the last of those being the vector match. A caller that does not want semantic matching drops embedding from the list.

Filtering is always inventoryState:=active, plus whatever the caller supplied: pickup/shipping flags, a price band, or a geographic radius.

Ranking is at most three sort clauses, because that is Typesense's limit:

  1. _text_match:desc — lexical relevance
  2. an optional locale boost, when the query has a language
  3. an optional geographic distance ascending, when a radius was given

followed by sellerReliabilityScore:desc and createdAtUnix:desc as tie-breakers.

The anti-phantom filter

Pure vector search always returns something — ask it for gibberish and it hands back the nearest listings in embedding space with total confidence. That is the single worst failure mode for a marketplace search, so hybrid results are filtered before they are returned:

  • Every lexical hit is kept — if the text actually matched, it is a real result.
  • A semantic-only hit is admitted only if its vector distance is at or below 0.19, and it is separated from the response's median distance by at least 0.15.

The second condition is what does the real work. A meaningless query produces results clustered at uniformly mediocre distances, so nothing stands out from the median and nothing is admitted — the correct answer for that query is no results. Both thresholds are empirically calibrated against observed distance bands and are documented as such in the code.

The 200-listing candidate ceiling

This is the single most important thing to know about GET /v1/search, and it does not apply everywhere.

export const V1_SEARCH_SCAN_CEILING = 200;

GET /v1/search and the Marka website's market page both run the same read: they load the 200 most recently listed active listings as a candidate set, and hydrate Typesense's ranked ids by intersecting against it. So:

  • No query reaches an item outside those 200.
  • An offset at or past the ceiling returns an empty page, not older items — which is why the contract publishes the number, since a client cannot discover it by paging.
  • hasMore is computed by slicing limit + 1 from the full ranked scan, so a paging loop is not silently ended by ranked ids that were dropped at hydration.

The MCP search tool is not bound by this

The search_listings tool on the MCP servers does not go through that candidate load. It queries Typesense directly and hydrates the returned ids straight from the database, and Typesense indexes every active MARKA listing. So the MCP tool can surface listings older than the 200 most recent, while GET /v1/search cannot. If you are comparing results between the two surfaces and they disagree on an older listing, this is why.

Their outage behaviour differs too: if Typesense is unavailable, GET /v1/search degrades to an in-memory ranker over the same 200 candidates — still useful — whereas the MCP tool returns an empty result set.

One more behaviour, on both: a city filter that matches nothing in the catalogue returns no results, rather than dropping the location filter and answering with the whole country.

Where to look

ConcernFile
Collection schema, index/remove predicate, reconcilepackages/search/src/indexing.ts
Hybrid query, ranking, anti-phantom filterpackages/search/src/query.ts
The post-commit index writeapps/web/lib/listing-index.ts
The 6-hourly reconcile cronapps/iii-engine/workers/search-indexer/src/worker.ts
The candidate ceilingapps/web/lib/market-search.ts
V1_SEARCH_SCAN_CEILINGpackages/shared/src/api-contracts/search.ts

On this page