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 concept | AACsearch equivalent |
|---|---|
| Index | Index (a SearchIndex row + Typesense collection) |
Primary key (primaryKey) | id field (string) |
| Searchable attributes | query_by at query time |
| Displayed attributes | include_fields at query time |
| Filterable attributes | Each field marked with facet: true or index: true |
| Sortable attributes | Each field used in sort_by must be int / float / bool |
| Distinct attribute | group_by at query time |
| Ranking rules | Field weights via query_by_weights + sort_by |
| Stop words | Currently fixed by collection language defaults |
| Synonyms | Synonym sets (SearchIndexSynonym rows) |
| Faceting | facet_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.jsonYou'll port:
searchableAttributes→ which fields go inquery_byat search time.filterableAttributes→ fields that needindex: trueorfacet: truein the AACsearch schema.sortableAttributes→ fields you'll allow insort_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 asquery_by_weights+ a thoughtfulsort_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
| Meilisearch | AACsearch |
|---|---|
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:<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) andper_page. - Multi-search. Meilisearch supports
multi-searchnatively; AACsearch has it too, but the JSON shape differs — see Multi-search. - Distinct. Meilisearch's
distinctAttributebecomesgroup_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
attributerule (earlier-listedsearchableAttributesrank higher) maps toquery_by_weights— givenameweight 3 anddescriptionweight 1 to recreate the behavior. - Meilisearch's
typorule is reproduced by AACsearch's typo tolerance, which is on by default with sensible per-length thresholds. - Meilisearch's
proximityrule (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: trueon 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
tenantIdwhen 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
- Migration overview
- Migration checklist
- From Typesense — closer to identity migration