AACsearch
Guías de migración

From Meilisearch

Migrating from Meilisearch to AACsearch — index export, schema mapping, query translation.

From Meilisearch

Meilisearch and AACsearch share a similar mental model — typo-tolerant search with a clean API, no shard tuning. The migration is mostly mechanical: export, re-ingest, retune ranking. Plan a half day for a typical catalog.

If you're coming from self-hosted Typesense, see From Typesense instead — that's a near-identity move.

Mapping at a glance

Meilisearch conceptAACsearch equivalent
IndexIndex (a SearchIndex row + Typesense collection)
Primary key (primaryKey)id field (string)
Searchable attributesquery_by at query time
Displayed attributesinclude_fields at query time
Filterable attributesEach field marked with facet: true or index: true
Sortable attributesEach field used in sort_by must be int / float / bool
Distinct attributegroup_by at query time
Ranking rulesField weights via query_by_weights + sort_by
Stop wordsCurrently fixed by collection language defaults
SynonymsSynonym sets (SearchIndexSynonym rows)
Facetingfacet_by

Step 1: Export the index

Use the Meilisearch dump API or meilisearch-cli to export documents:

# Snapshot via the API (one document per line)
curl "http://localhost:7700/indexes/products/documents?limit=1000" \
  -H "Authorization: Bearer YOUR_API_KEY"

# Or use the dump API for the whole instance
curl -X POST "http://localhost:7700/dumps" \
  -H "Authorization: Bearer YOUR_API_KEY"

For a more reliable export, scroll through documents using offset and limit until you've covered everything. Save to NDJSON (one document per line):

import { writeFileSync, appendFileSync } from "node:fs";

const HOST = "http://localhost:7700";
const KEY = process.env.MEILI_KEY;
const INDEX = "products";

writeFileSync("/tmp/products.ndjson", "");
let offset = 0;
const limit = 1000;
while (true) {
	const res = await fetch(
		`${HOST}/indexes/${INDEX}/documents?offset=${offset}&limit=${limit}`,
		{ headers: { Authorization: `Bearer ${KEY}` } },
	);
	const { results } = await res.json();
	if (results.length === 0) break;
	for (const doc of results) {
		appendFileSync("/tmp/products.ndjson", JSON.stringify(doc) + "\n");
	}
	offset += limit;
}

Step 2: Export the settings

curl "http://localhost:7700/indexes/products/settings" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  > /tmp/products.settings.json

You'll port:

  • searchableAttributes → which fields go in query_by at search time.
  • filterableAttributes → fields that need index: true or facet: true in the AACsearch schema.
  • sortableAttributes → fields you'll allow in sort_by (must be numeric or bool).
  • synonyms → AACsearch synonym sets.
  • stopWords → currently driven by the collection's tokenizer; AACsearch will use sensible defaults.
  • rankingRules → re-encode as query_by_weights + a thoughtful sort_by.

Step 3: Define the AACsearch schema

If your Meilisearch documents are:

{
  "id": "sku-123",
  "name": "Running Shoe",
  "description": "Cushioned…",
  "price": 99.95,
  "tags": ["sport", "outdoor"],
  "available": true
}

…the matching AACsearch schema is:

await client.searchIndex.create.call({
	slug: "products",
	fields: [
		{ name: "name", type: "string" },
		{ name: "description", type: "string", optional: true },
		{ name: "price", type: "float", facet: true },
		{ name: "tags", type: "string[]", facet: true },
		{ name: "available", type: "bool", facet: true },
		{ name: "tenantId", type: "string", facet: true }, // for multi-tenant orgs
	],
	defaultSortingField: "price",
});

If the Meilisearch corpus is single-tenant (one Meilisearch instance per customer), you can skip the tenantId field. If you're consolidating onto one AACsearch index with multiple tenants, add tenantId and require a scoped token on every browser-side query.

Step 4: Ingest

Same pattern as the database search migration — batch the NDJSON into searchDocument.bulkUpsert in groups of 500. The ingest buffer absorbs the volume.

Step 5: Query translation

MeilisearchAACsearch
POST /indexes/products/search { q: "running" }POST /api/v1/indexes/products/search { q: "running", query_by: "name,description" }
{ q: "running", filter: "price < 100 AND available = true" }{ q: "running", filter_by: "price:&lt;100 && available:=true" }
{ q: "running", facets: ["tags"] }{ q: "running", facet_by: "tags" }
{ q: "running", sort: ["price:asc"] }{ q: "running", sort_by: "price:asc" }
{ q: "running", attributesToHighlight: ["name"] }{ q: "running", highlight_full_fields: "name" }
{ q: "running", limit: 20, offset: 40 }{ q: "running", per_page: 20, page: 3 }

Notable differences:

  • Filter syntax — Meilisearch uses =, <, AND; AACsearch uses :=, :<, &&. Both are reasonable; one is what you have to translate to.
  • Pagination by page, not offset. Use page (1-based) and per_page.
  • Multi-search. Meilisearch supports multi-search natively; AACsearch has it too, but the JSON shape differs — see Multi-search.
  • Distinct. Meilisearch's distinctAttribute becomes group_by; the semantics are very close (one document per group key).

Step 6: Tune ranking

Meilisearch's default ranking is [words, typo, proximity, attribute, sort, exactness]. AACsearch's effective ranking is query_by_weights plus sort_by. The differences worth knowing:

  • Meilisearch's attribute rule (earlier-listed searchableAttributes rank higher) maps to query_by_weights — give name weight 3 and description weight 1 to recreate the behavior.
  • Meilisearch's typo rule is reproduced by AACsearch's typo tolerance, which is on by default with sensible per-length thresholds.
  • Meilisearch's proximity rule (matched words closer together rank higher) is the AACsearch default — you don't need to encode it.

After ingest, validate against the 100-query sample (see Migration overview). Most drift in this migration comes from attribute weight differences — start there.

Step 7: Update your application

Where Meilisearch has its own client SDK, AACsearch has:

  • @repo/search-client (browser).
  • @repo/api/client (server, oRPC).
  • Direct HTTP — see Search API.

The shapes are not drop-in compatible. The most surface-similar pattern is:

// Meilisearch
const result = await meili.index("products").search("running", {
	filter: "price < 100",
	sort: ["price:asc"],
});

// AACsearch (server-side, oRPC)
const result = await client.searchDocument.search.call({
	indexSlug: "products",
	q: "running",
	filterBy: "price:<100",
	sortBy: "price:asc",
});

Validation and cutover

Run the migration checklist. For Meilisearch specifically:

  • Make sure your synonym sets are applied to the new index. Synonyms are not part of the document export.
  • Compare facet counts side-by-side. They're usually identical, but a difference reveals a missing facet: true on the schema.
  • Test the typo-tolerance threshold. If your old corpus was tuned for low typo tolerance and your AACsearch index has the default, results will differ for short queries.

Common mistakes

  • Importing without tenantId when consolidating. If you're moving from one-instance-per-tenant to a shared AACsearch index, the tenant field is mandatory and your scoped-token plan needs to be in place before the cutover.
  • Treating filterable attributes as schema-only. Filterable attributes affect both the document index (must be index: true) and the query (must reference the field name). Check both.
  • Not migrating synonyms. They live outside the document and are easy to forget.

See also

On this page