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.
What is in the index
One collection, listings. Its schema, with the flags that matter:
| Field | Type | Notes |
|---|---|---|
title, titleNormalized, description | string | description optional |
aliases | string[] | resolved synonyms — how "N64" finds "Nintendo 64" |
brand, model | string | optional; brand facetable |
categoryPath | string[] | facet |
condition, currency, authority, sellerAccountId | string | facet |
price | int32 | integer minor units, like everywhere else |
pickupEnabled, shippingEnabled | bool | facet |
location | geopoint | optional |
visibilityRadiusKm | int32 | optional |
inventoryState | string | facet |
primaryLanguage | string | optional facet |
sellerReliabilityScore | float | 0 to 100, see below |
createdAtUnix | int64 | the default sorting field |
embedding | float[] | 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:
- 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.
- 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:
_text_match:desc— lexical relevance- an optional locale boost, when the query has a language
- 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
offsetat 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. hasMoreis computed by slicinglimit + 1from 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
| Concern | File |
|---|---|
| Collection schema, index/remove predicate, reconcile | packages/search/src/indexing.ts |
| Hybrid query, ranking, anti-phantom filter | packages/search/src/query.ts |
| The post-commit index write | apps/web/lib/listing-index.ts |
| The 6-hourly reconcile cron | apps/iii-engine/workers/search-indexer/src/worker.ts |
| The candidate ceiling | apps/web/lib/market-search.ts |
V1_SEARCH_SCAN_CEILING | packages/shared/src/api-contracts/search.ts |
Payments and order rails
Two order rails — an in-person handoff that never touches Stripe, and a Stripe Connect direct charge with a 0% application fee. How the money moves, and where it never goes.
The AI seam
How an AI assistant reaches a Marka account — typed tools only, a 60-second signed identity token, writes that go through the same domain functions as the app, and no publish anywhere.