Migrate from the Embeddings Index API to Dataset Embeddings
Replace the deprecated Embeddings Index API with Dataset Embeddings and GROQ: export your index, enable embeddings, rewrite your queries, and cut over.
The Embeddings Index API is deprecated, along with the @sanity/embeddings-index-cli package and the @sanity/embeddings-index-ui Studio plugin. They receive no new features and no fixes. This guide walks through replacing them with Dataset Embeddings and GROQ, from exporting your current index configuration to deleting it once the new path is live.
Does this guide cover you?
It covers search: your application sends a query and renders the results. It does not cover using the index to populate reference fields through AI Assist or Agent Actions, or similar-document search in the Studio plugin. At this time, we do not have a replacement solution available for using references with Agent Actions.
Before you start
You'll need:
- A project role with permission to manage the legacy embeddings indexes, for the export in Step 1 and the delete in Step 5. See roles and permissions.
- The Sanity CLI,
sanity5.18.0 or later. Thesanity datasets embeddingscommands ship in@sanity/cli6.2.0, whichsanity5.18.0 is the first release to require. Run commands asnpx sanity@latestto stay on the current version. To call the legacy endpoints over HTTP instead, you need an API token. - A server-side application using
@sanity/clientand a read token. A read token is all your application needs to query. - Your project ID, dataset name, and the name of every legacy index you're replacing.
- A set of real search queries to test against. Pull these from your application's search logs or analytics.
Also pull your request logs before you start. They show which indexes are still being called, how often, and from where, which is how you catch a caller you forgot about. Self-serve plans can export the last seven days from the Usage section of project settings. That export is capped at 1 GB: a project producing more log data than that within the window gets a truncated file covering less than seven days, so check the range it actually covers before you draw conclusions from it. Enterprise projects can have logs delivered to a storage bucket. Note that the search strings themselves are sent in a POST body and aren't recorded, so your test queries have to come from your own logs.
What is actually changing
Today you create a named index, POST a search string to a query endpoint, and get back document IDs and scores. Then you query GROQ a second time to fetch the documents.
Dataset Embeddings replace both requests with one. text::semanticSimilarity() is a GROQ function you call inside score(), so filtering, keyword matching, boosting, ordering, slicing, and your projection all happen in the same query, and you get documents back. There is no index to create, poll, or keep current.
This is not a drop-in swap. Results will not rank identically, because the embedding model, the chunking, and the score scale all differ. Named indexes no longer exist. Reference expansion stops working inside the embedding projection, though -> still works everywhere else in your queries. Check what ports cleanly and what doesn't before you start.
Concept mapping
Embeddings Index API | Dataset Embeddings |
|---|---|
One or more named indexes per dataset | One embeddings configuration per dataset |
Index | Projection selects which document types get embedded. Value-level filtering moves to query time |
Index | Dataset projection selects which fields get embedded. No reference expansion |
|
|
| A GROQ slice, for example |
|
|
Response: | Documents, with |
|
|
Sanity creates a webhook to keep the index current | Updates are automatic, asynchronous, and debounced. No webhook involved |
Create, poll, and delete an index | Enable embeddings once per dataset |
The endpoints you are replacing
Legacy endpoint | Replacement |
|---|---|
|
|
|
|
|
|
| Nothing. See the warning about |
| A GROQ query using |
What ports cleanly and what doesn't
Each legacy behavior falls into one of three groups: it ports directly, it ports with a code or content change, or it doesn't port at all. Check where yours lands before you plan the work.
Ports directly
Legacy behavior | What to do |
|---|---|
A projection of fields on the document itself | Add the same fields to the dataset projection |
| Use |
| Use a GROQ slice, for example |
Returning document IDs and types | Project |
Fixed RAG retrieval | Same as site search. Query with GROQ and pass the results to your model |
Ports with a code or content change
Legacy behavior | What to do |
|---|---|
The index | Copy it into every replacement query. Enabling embeddings does not carry it over |
A named index | Replace each |
Several indexes over different document types | Merge the fields into one type-conditional projection. Keep a separate query function per index |
Reference expansion in the projection, like | Expanding references does not work in embedding projections. Only what's in the document. To keep a referenced value in the embedding, materialize it into a real field first (see Step 2) |
Webhook-driven index updates | Delete the webhook after cutover. Sanity handles updates |
Passing a JSON document as the query input, as the deprecated CLI's |
|
Doesn't port
Legacy behavior | What to do |
|---|---|
Several indexes with different projections over the same documents | A dataset holds one embeddings configuration, so each document has a single projected representation. Embed the union of fields and separate the behaviors with query-time filters and scoring, or model separate searchable documents |
Numeric score thresholds, like | Remove them. |
Identical result ranking | Different model, different chunking, different scoring. Expect the ordering to shift and test accordingly |
Copy the legacy filter into every replacement query
Enabling embeddings does not carry your index filter forward. If that filter excluded unpublished, private, market-specific, or expired content, leaving it out changes the result set and can surface content you meant to hide.
Steps to execute the migration
The examples in this guide all use the same index: public-articles, filtered to _type == "article" && searchable == true, projecting {title, body, "categoryTitle": category->title}.
Step 1: Export your index configuration
Fetch every index on the dataset and save the filter, projection, and indexName for each one. You need all three to rebuild the behavior. Store the response as JSON in version control or wherever your team keeps infrastructure config, not just in a scratch file. You'll want it again in step 5. Two things about the response: Sanity stores your projection with _type auto-prepended, so the saved config won't be byte-identical to what you created; and the projection string may contain an unescaped newline that trips strict JSON parsers. Save the raw text, or parse tolerantly.
curl https://YOUR_PROJECT_ID.api.sanity.io/vX/embeddings-index/production \ -H "Authorization: Bearer $SANITY_API_TOKEN"
Then search your codebase for the query URL, /embeddings-index/query/, and note every caller and everything downstream that reads the response. Anything reading value.documentId or comparing score to a number needs to change. Cross-check the list against your request logs: an index that's taking traffic but doesn't appear in your codebase means there's a caller somewhere you haven't accounted for.
Step 2: Translate the projections into one dataset projection
You get one projection per dataset. The index in the running example covers one document type, so the projection does too. If you're replacing several indexes, merge their fields into a single conditional projection. See type-specific projections.
Type-level scoping from your old filters can move into the projection, because document types you don't list are not embedded. Value-level conditions, like searchable == true, cannot. Those stay at query time.
{
_type == "article" => {
title,
body
}
}Do not project a reference alias like categoryTitle
In the legacy index, categoryTitle was "categoryTitle": category->title — reference expansion. Dataset embeddings cannot dereference, and there is no categoryTitle field on the document, so projecting categoryTitle here embeds nothing at all, with no error. If you need the referenced value in the embedding, first materialize it into a real string field on the document (for example, an article.categoryTitle field kept in sync by a content migration or a Sanity Function), then project that real field. It's a weak signal either way, so weigh whether it earns the extra field.
Keep the projection tight. Every field you add grows each document's embedding, slows generation and recomputation, and adds noise that competes with the signal your users are searching for. Leave out fields that change often but carry no meaning for search, because each change triggers a recomputation.
Field names carry semantic weight. {"musicalGenre": category} tells the model to read "classical" as music rather than engineering.
Documents are chunked before embedding, and there's a cap of 10 chunks per document (subject to change). Content past the cap is dropped. If you're embedding long body fields, scope the projection.
Expanding references does not work in embedding projections. Only what's in the document. In testing, the legacy index did embed category->title, but as a weak signal—a few hundredths of cosine similarity, outweighed by the document's own title and body. Losing it mostly reshuffles the tail of your results, not the top hit. Materialize the field only if that referenced value is genuinely important to how users search.
Step 3: Enable embeddings on the dataset
npx sanity@latest datasets embeddings enable production \
--projection '{_type == "article" => {title, body}}' \
--waitpnpm dlx sanity@latest datasets embeddings enable production \
--projection '{_type == "article" => {title, body}}' \
--waityarn dlx sanity@latest datasets embeddings enable production \
--projection '{_type == "article" => {title, body}}' \
--waitbunx sanity@latest datasets embeddings enable production \
--projection '{_type == "article" => {title, body}}' \
--wait--wait blocks until the initial generation finishes. Without it, the command returns immediately and generation continues in the background. On a large dataset this takes a while (for a few dozen documents it's under a minute; budget much more for large datasets).
Check the status at any point:
npx sanity@latest datasets embeddings status production
pnpm dlx sanity@latest datasets embeddings status production
yarn dlx sanity@latest datasets embeddings status production
bunx sanity@latest datasets embeddings status production
The status is updating, ready, or error. Don't send production traffic until it reads ready. Querying a dataset without embeddings enabled returns an error.
To do this over HTTP instead:
PUT /projects/:projectId/datasets/:name/settings/embeddings HTTP/1.1
Content-Type: application/json
{
"enabled": true,
"projection": "{_type == \"article\" => {title, body}}"
}The endpoint returns 202 Accepted and generates asynchronously.
Write performance
Depending on system load, write speeds may be slower on datasets with embeddings enabled, and Sanity may apply rate limits to manage resource usage. These behaviors are subject to change. If your dataset takes heavy write traffic, watch it during your test window. See performance considerations.
Step 4: Replace the query call
The POST to the index and the follow-up query that fetched the documents collapse into a single GROQ query.
Before:
const results = await client.request({
url: '/embeddings-index/query/production/public-articles',
method: 'POST',
body: {query: searchText, maxResults: 10, filter: {type: ['article']}},
})
const ids = results.map((result) => result.value.documentId)
const documents = await client.fetch(`*[_id in $ids]{_id, title, slug}`, {ids})After:
*[_type == "article" && searchable == true]
| score(text::semanticSimilarity($searchText))
[0...10] {
_id, _type, title, slug, _score
}The index filter becomes the GROQ filter, filter.type becomes _type, and maxResults becomes the slice.
Query changes has the full client code, the new response shape, a compatibility adapter for callers that still expect the old format, and when to add keyword matching.
Step 5: Delete the legacy index
Destructive operations
Deleting an index is permanent, and the legacy API is deprecated, so recreating one is not a path you want to depend on. Delete only after you have cut over and run at full traffic long enough to notice a problem. Until then, keep the index so a rollback stays available. Keep the JSON you saved in step 1 as well: it holds the filter, projection, and indexName you would need to rebuild. And don't reach for sanity datasets embeddings disable as cleanup: that command turns off your new dataset embeddings, not your old index. Disabling is destructive too. The computed embedding data may be deleted immediately, and re-enabling triggers a full recompute of every document.
curl -X DELETE https://YOUR_PROJECT_ID.api.sanity.io/vX/embeddings-index/production/public-articles \ -H "Authorization: Bearer $SANITY_API_TOKEN"
Sanity removes the webhook it created for the index automatically when you delete the index. If you added any webhooks of your own to keep the index current, remove those.
Query changes
Before
Two requests: one to the embeddings index, one to fetch the documents.
const results = await client.request({
url: '/embeddings-index/query/production/public-articles',
method: 'POST',
body: {
query: searchText,
maxResults: 10,
filter: {type: ['article']},
},
})
// [{score: 0.83, value: {documentId: 'abc123', type: 'article'}}]
const ids = results.map((result) => result.value.documentId)
const documents = await client.fetch(`*[_id in $ids]{_id, title, slug}`, {ids})After
One request. The filter, the scoring, the ordering, the slice, and the projection all live in the same query.
import {createClient} from '@sanity/client'
const client = createClient({
projectId: process.env.SANITY_PROJECT_ID,
dataset: 'production',
apiVersion: '2026-08-21',
token: process.env.SANITY_API_READ_TOKEN,
useCdn: false,
perspective: 'published',
})
export async function searchArticles(searchText: string, maxResults = 10) {
const limit = Math.min(Math.max(Math.trunc(maxResults) || 10, 1), 50)
return client.fetch(
`*[_type == "article" && searchable == true]
| score(text::semanticSimilarity($searchText))
[0...$limit] {
_id, _type, title, slug, _score
}`,
{searchText, limit},
)
}The old index filter, searchable == true, is now in the GROQ filter. Nothing carries it over for you.
text::semanticSimilarity() is only valid as an argument to score(). Using it anywhere else returns an error. score() already sorts results by _score descending, so you don't need an explicit order().
maxResults becomes the upper bound of a slice. There is no default limit in GROQ, so always bound your results. Note that .. is inclusive and ... is exclusive, which matters when you convert a maxResults integer into a slice bound.
What the response looks like now
You get documents back, not pointers to documents:
[
{
"_id": "article-auth-guide",
"_type": "article",
"title": "Authentication guide",
"slug": {"current": "authentication-guide"},
"_score": 8.341205
}
]_score is a unitless, opaque ranking value. It orders results within one query and nothing more. It is not comparable across queries, and it is not on the same scale as the score the old API returned. If your code has a line like results.filter(r => r.score > 0.75), delete it and control the result count with the slice instead.
Semantic queries automatically include an _embeddings field on each result, carrying the text fragments that drove the match along with their source fields and character offsets. If your query uses an explicit projection, add _embeddings to it to keep the field. Good for highlighting search results, and good for working out why something ranked where it did:
*[_type == "article" && searchable == true]
| score(text::semanticSimilarity($searchText))
[0...$limit] {
_id, _type, title, slug, _score, _embeddings
}{
"_embeddings": [
{
"fragments": ["OAuth 2.0 provides a secure delegation protocol..."],
"fields": ["body"],
"startPositions": [0],
"endPositions": [74],
"score": 8.341205
}
]
}Keeping the old response shape temporarily
If several callers read the legacy format and you'd rather not change them all at once, rebuild the old shape in the projection. No adapter code needed:
*[_type == "article" && searchable == true]
| score(text::semanticSimilarity($searchText))
[0...$limit] {
"score": _score,
"value": {
"documentId": _id,
"type": _type
}
}The shape matches, but score no longer means what it did. Treat this as a stepping stone, not a destination, and make sure nothing downstream is thresholding on that number.
Add keyword matching when exact terms matter
Semantic search alone handles conceptual queries well. It's weaker on proper nouns, product codes, brand names, and part numbers, where the exact string is the point. Add a match expression alongside the semantic one inside score(), wrapped in boost() to set how much weight the keyword signal carries:
*[_type == "article" && searchable == true]
| score(
boost([title, body] match text::query($searchText), 0.5),
text::semanticSimilarity($searchText)
)
[0...10] {
_id, _type, title, slug, _score
}Each expression contributes to _score independently, and a document doesn't need to match both to appear. boost() sets the balance. Keyword matches on short fields like title can outweigh strong semantic matches on long fields like body, because each matching term is a bigger share of a short field, so start the keyword weight low and tune from there.
Start semantic-only. Add keyword matching when you can point at queries it fixes.
Building an agent rather than a search feature?
If a model decides what to look up, inspects your schema, and writes its own queries, Sanity Context is likely a better fit than querying GROQ directly. It reads from the same dataset embeddings, so enable them either way. Passing search results to a model does not by itself mean you need Context.
Validation and launch
Your new results will not match the old ones exactly. The goal is to confirm they're as good or better, not that they're identical.
Compare before you switch
Use the queries you gathered in before you start. Twenty to thirty is enough. Include the high-volume ones, the long-tail ones, queries that should return nothing, and any query containing an exact name, code, or brand. Those last ones are where semantic-only search tends to fall short, and where keyword matching helps most.
Run each query through both paths and look at the top few results side by side. You're checking for two things: results that got worse, and results that appeared out of nowhere. The second one usually means a filter didn't make it across.
Watch for the known traps
- Content appearing that the old index excluded. The legacy filter is missing from your GROQ query. Check your perspective too. On API versions from 2025-02-19 onward the default is
published, but on older versions it'sraw, which returns drafts alongside published documents. Setperspective: 'published'explicitly if the old index only covered published documents. - Exact names and codes ranking poorly. Add keyword matching.
- Recently edited content returning stale matches. Embedding updates are asynchronous and debounced. The lag is usually under a minute but can be longer on large or busy datasets. If your product needs fresher results than that, measure the real lag before you commit.
- Everything returning an error. Check that the embeddings status is
ready, and that you're callingtext::semanticSimilarity()insidescore().
Cut over
Run both paths against production traffic for a short period if you can, comparing outputs without acting on the new one. Then switch, keeping the old index in place so a rollback is a config change rather than a rebuild. On a large or business-critical search integration, talk to your account team before you cut over.
After the switch, watch error rate, latency, and your zero-result rate. Give it a week at full traffic, then confirm in your request logs that the legacy index is taking no traffic before you delete it. Check that your log export actually covers the whole week before you treat quiet logs as evidence: a truncated export looks exactly like an idle index.
FAQs
Do I have to migrate?
Yes, if you want your search to keep working. The Embeddings Index API is deprecated and should be replaced with Dataset Embeddings. It receives no fixes or new features.
What replaces the @sanity/embeddings-index-cli package?
The Sanity CLI. See sanity datasets embeddings.
What about the Studio plugin, AI Assist, and Agent Actions references?
There's no replacement for reference population in AI Assist and Agent Actions yet, and none for the similar-document search in the Studio plugin. If you depend on either, contact us or post in the Sanity Community so we can factor your use case into what comes next.
Does this cost more?
Embeddings generation is included on all plans. Embeddings queries are metered, with a monthly allowance that varies by plan and overage pricing above it. Semantic search itself is included. See pricing for current allowances, and if you're on an enterprise plan, ask your account team for a forecast based on your actual query volume.
Next steps
- Dataset Embeddings. Enabling, projections, chunking, querying, and result metadata.
- Search text content with GROQ. Filtering, scoring, BM25, boosts, and pagination.
- Datasets CLI reference. The
sanity datasets embeddingscommands. - Embeddings Index API reference. The deprecated API, for reference while you migrate.