This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

Search

Index, retrieve, and query documents with full-text search engines

1 - Search overview

Overview of the search API building block

Dapr’s search API provides a single, portable way to store, retrieve, and query documents in lexical (full-text) search engines such as Meilisearch, Elasticsearch, and OpenSearch. Instead of learning a different SDK and query language for each search engine, your application indexes JSON documents and queries them through the same API, and you swap the underlying engine by changing the search component.

The search API is scoped to storing and querying documents that are already index-ready. Text extraction, chunking, embedding generation, and ingestion pipeline orchestration are out of scope; your application performs those steps before calling the API. For similarity search over embeddings, use the vector API instead.

You can pair the search API with Dapr functionality, like:

  • Resiliency policies including circuit breakers, timeouts, and retries for calls to the search engine
  • Observability with metrics (dapr_component_search_count and dapr_component_search_latencies, tagged by operation) and distributed tracing using OpenTelemetry and Zipkin
  • Secret store references for search engine credentials

Concepts

ConceptDescription
Search storeA Dapr search component that connects to a search engine.
IndexA named container of documents in the search store. Index settings are component specific and are passed as metadata when the index is created.
DocumentAn index-ready item made of a caller-supplied id, a content JSON object that is indexed and searchable, and opaque string metadata that is stored and returned unchanged but never indexed or filterable.
HitA document returned by a search, with its relevance score and any requested highlights.

Features

Index management

Create, get, list, and delete indexes. Creating an index that already exists returns ALREADY_EXISTS; Dapr never reconciles the settings of an existing index. Getting an index returns an approximate document count and component-specific properties, such as the configured filterable or sortable attributes.

Keyed upserts

Documents are written with a keyed upsert. Every document carries a non-empty id that must be unique within the request, so retrying the same request is idempotent. Document content must be a UTF-8 encoded JSON object. Documents with any other content are reported individually in failedItems with INVALID_ARGUMENT and are never sent to the search engine, while the rest of the batch is still indexed.

Documents can be read back by ID in request order, and deleted by ID. Deleting an ID that does not exist is not an error.

Write acknowledgements

Many search engines index documents asynchronously. Every write (indexing and deleting documents) accepts the same optional options to control when Dapr returns:

ModeBehaviour
INDEXING_MODE_RETURN_ON_ACCEPTANCE (default)Return as soon as the search engine durably accepts the write. The response ack is INDEX_ACK_QUEUED when the engine queued the write, which does not mean any document has been indexed yet.
INDEXING_MODE_WAIT_FOR_COMPLETIONWait for the final result, up to waitTimeout. The response ack is INDEX_ACK_COMPLETED and failedItems contains every item-specific failure.

When waiting for completion, you must also set onWaitTimeout to decide what happens if the timeout expires:

  • INDEXING_WAIT_TIMEOUT_ACTION_CONTINUE_ASYNC: return INDEX_ACK_QUEUED and let the queued work continue. Only valid for search engines that provide a queued acknowledgement.
  • INDEXING_WAIT_TIMEOUT_ACTION_FAIL_REQUEST: fail the request with DEADLINE_EXCEEDED. The search engine may still complete the write.

waitTimeout must be shorter than the remaining deadline of the call, when one is set. When the search store has a resiliency timeout policy, waitTimeout must also be shorter than that timeout by at least a tenth of it, and by no less than 100ms. Otherwise the request is rejected with INVALID_ARGUMENT before the search engine is called.

Querying

A search request carries an optional query that selects and shapes the hits. The query sets at most one of:

  • A portable full-text query (text), optionally restricted to specific searchFields, or
  • A provider-native query (native) that is passed to the search engine for features not covered by the portable API.

A query that sets neither, or a request without a query, matches every document in the index, subject to the filter. Queries can be combined with:

  • Filters written in the portable filter language
  • Sorting on one or more fields in ascending or descending order
  • Highlighting of matched fragments for selected fields
  • Field projection with returnFields, or includeContent: false to return only document IDs and metadata
  • Pagination using opaque continuation tokens

Hit scores are unnormalised, provider-specific relevance values where higher is more relevant. Compare scores only within the same result set.

Pagination

Use topK to set the page size. When it is omitted or 0, the component’s default page size applies. When more results are available, the response includes a continuationToken; pass it unchanged in the query of the next request to get the next page. A continuation token is bound to the shape of the query that produced it, so the next request must keep the same query, page size, filter, sort, and field options. If the search engine’s cursor behind a token expires, the request fails with FAILED_PRECONDITION and the SEARCH_CONTINUATION_EXPIRED error reason.

The response includes a best-effort totalHits along with a totalHitsRelation that describes its accuracy: TOTAL_HITS_RELATION_EXACT, TOTAL_HITS_RELATION_LOWER_BOUND, or TOTAL_HITS_RELATION_ESTIMATE.

Filter language

Filters are JSON objects that every search component translates into its native query language. Field paths address keys of the document content and use dotted notation for nested keys, for example customer.region. A field set directly to a value is an equality match, and multiple conditions in the same object are combined with a logical AND.

TypeOperators
Comparison$eq, $ne, $gt, $gte, $lt, $lte
Set$in, $nin
Existence$exists (boolean)
Logical$and, $or, $not

For example, the following filter matches guides that cost less than 20, or any document in the reference category:

{
  "$or": [
    { "category": "guide", "price": { "$lt": 20 } },
    { "category": { "$in": ["reference"] } }
  ]
}

Some search engines require fields to be declared as filterable or sortable before they can be used. Check the component reference for details.

Error reporting

Errors that a search engine returns reach your application with their canonical gRPC status code and details, rather than being wrapped in a generic internal error. Over HTTP, the response status matches the gRPC code and the errorCode is the code’s name. For example, creating an existing index returns HTTP 409 with the ALREADY_EXISTS error code.

Errors that Dapr detects before calling the search engine use Dapr error codes instead: ERR_SEARCH_STORE_NOT_FOUND, ERR_SEARCH_STORE_NOT_CONFIGURED, and ERR_SEARCH_INVALID_REQUEST. See the search API reference for details.

Resiliency

Calls to the search engine run under the search store’s resiliency policies:

  • Retry policies can match the canonical gRPC code that the search engine returned with matching.gRPCStatusCodes.
  • A request that exceeds the policy timeout fails with DEADLINE_EXCEEDED.
  • A write that waits for completion with INDEXING_WAIT_TIMEOUT_ACTION_FAIL_REQUEST is not retried once its wait expires.
  • When a retried index delete returns NOT_FOUND, the delete is reported as successful, because an earlier attempt most likely deleted the index.
  • ALREADY_EXISTS on a create is always returned unchanged, including on a retry.

Try out the search API

How-to guide

Now that you’ve learned what the search API building block provides, learn how it can work in your application with the following how-to guide:

Next steps

2 - How-To: Index and search documents

Learn how to index, query, and manage documents using the search API

Let’s get started using the search API. In this guide, you’ll learn how to:

  • Set up a Meilisearch search component.
  • Create an index.
  • Index documents and wait for the write to complete.
  • Run full-text searches with filters, sorting, and pagination.
  • Retrieve and delete documents by ID.

The HTTP API uses the JSON mapping of the protobuf messages: field names are camelCase, 64-bit integers are strings, and bytes are base64-encoded. Responses include fields set to their zero value and omit optional fields that aren’t set. The JavaScript SDK hides these details: document content is a plain object, and enumerations are short strings such as "completed" or "asc".

For .NET examples using typed documents, query options, pagination, and generated index clients, see the Search .NET SDK guide, targeting SDK 1.19.0 or later.

Pre-requisites

Start Meilisearch

Run a local Meilisearch instance with Docker:

docker run -d --name meilisearch -p 7700:7700 \
  -e MEILI_MASTER_KEY=masterKey \
  getmeili/meilisearch:v1.43

Set up the search component

Create a file called search.yaml in your components directory (for example, ./components):

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: mysearch
spec:
  type: search.meilisearch
  version: v1
  metadata:
  - name: host
    value: "http://localhost:7700"
  - name: apiKey
    value: "masterKey"

See the search component specs for all supported search components.

Run the Dapr sidecar

Start a Dapr sidecar that loads the component:

Create a Node.js project and install the Dapr SDK (version 3.19.0 or later):

npm init -y
npm install @dapr/dapr@3.19.0

The examples below go in a single app.mjs file. Create the client once and reuse it for every call. The search API is available over both HTTP and gRPC; the gRPC protocol sends document content as raw bytes, while HTTP base64-encodes it for you.

import { DaprClient, CommunicationProtocolEnum } from "@dapr/dapr";

const client = new DaprClient({
  daprHost: "127.0.0.1",
  daprPort: process.env.DAPR_GRPC_PORT,
  communicationProtocol: CommunicationProtocolEnum.GRPC,
});

const storeName = "mysearch";
const indexName = "articles";

Run the application with a sidecar:

dapr run --app-id search-app --resources-path ./components -- node app.mjs
dapr run --app-id search-app --dapr-http-port 3500 --resources-path ./components

Run the curl commands below in a separate terminal.

Create an index

Create an index called articles. Index settings are component specific and are passed as metadata. Meilisearch only filters and sorts on attributes that are declared up front, so declare category and price here:

await client.search.createIndex(storeName, indexName, {
  metadata: {
    filterableAttributes: "category,price",
    sortableAttributes: "price",
  },
});

Creating an index that already exists fails with ALREADY_EXISTS (HTTP 409), and Dapr does not change the settings of the existing index.

Confirm the index was created. The Meilisearch component always adds id to the sortable attributes, because it uses the document ID as a tie-breaker for stable pagination:

const info = await client.search.getIndex(storeName, indexName);
console.log(info);
// {
//   index: 'articles',
//   documentCount: 0,
//   properties: {
//     filterableAttributes: 'category,price',
//     primaryKey: 'id',
//     sortableAttributes: 'id,price'
//   }
// }

const indexes = await client.search.listIndexes(storeName);
console.log(indexes); // [ 'articles' ]
curl -X POST http://localhost:3500/v1.0-alpha1/search/mysearch/indexes/articles \
  -H "Content-Type: application/json" \
  -d '{
        "metadata": {
          "filterableAttributes": "category,price",
          "sortableAttributes": "price"
        }
      }'

Creating an index that already exists returns HTTP 409 (ALREADY_EXISTS), and Dapr does not change the settings of the existing index.

Confirm the index was created. The Meilisearch component always adds id to the sortable attributes, because it uses the document ID as a tie-breaker for stable pagination:

curl http://localhost:3500/v1.0-alpha1/search/mysearch/indexes/articles
{
  "index": "articles",
  "documentCount": "0",
  "properties": {
    "filterableAttributes": "category,price",
    "primaryKey": "id",
    "sortableAttributes": "id,price"
  }
}

Index documents

Document content must be a JSON object. The following request indexes three documents and waits up to 10 seconds for Meilisearch to finish indexing them:

Pass the content as a plain object; the SDK serializes it for you. Documents can carry optional metadata, which is stored alongside the content and returned with it.

const waitOptions = {
  mode: "waitForCompletion",
  waitTimeout: "10s",
  onWaitTimeout: "failRequest",
};

const result = await client.search.indexDocuments(
  storeName,
  indexName,
  [
    {
      id: "doc-1",
      content: { title: "Getting started with Dapr", category: "guide", price: 10 },
      metadata: { author: "alice" },
    },
    {
      id: "doc-2",
      content: { title: "Dapr pub/sub deep dive", category: "guide", price: 25 },
    },
    {
      id: "doc-3",
      content: { title: "Search API reference", category: "reference", price: 30 },
    },
  ],
  waitOptions,
);

console.log(result.ack); // "completed"

if (result.failedItems.length > 0) {
  for (const item of result.failedItems) {
    console.error(`Failed to index ${item.id}: ${item.errorCode} ${item.errorMessage}`);
  }
}

If some documents could not be indexed, they are listed in failedItems with their error, while the rest of the batch is still indexed. For example, a document whose content isn’t a JSON object, or uses an attribute that the Meilisearch component reserves, is reported with error code 3 (INVALID_ARGUMENT).

Because the content field is defined as bytes, the HTTP API expects it base64-encoded. For example, the content of the first document below is the base64 encoding of {"title":"Getting started with Dapr","category":"guide","price":10}.

curl -X POST http://localhost:3500/v1.0-alpha1/search/mysearch/indexes/articles/documents \
  -H "Content-Type: application/json" \
  -d '{
        "documents": [
          {
            "id": "doc-1",
            "content": "eyJ0aXRsZSI6IkdldHRpbmcgc3RhcnRlZCB3aXRoIERhcHIiLCJjYXRlZ29yeSI6Imd1aWRlIiwicHJpY2UiOjEwfQ==",
            "metadata": { "author": "alice" }
          },
          {
            "id": "doc-2",
            "content": "eyJ0aXRsZSI6IkRhcHIgcHViL3N1YiBkZWVwIGRpdmUiLCJjYXRlZ29yeSI6Imd1aWRlIiwicHJpY2UiOjI1fQ=="
          },
          {
            "id": "doc-3",
            "content": "eyJ0aXRsZSI6IlNlYXJjaCBBUEkgcmVmZXJlbmNlIiwiY2F0ZWdvcnkiOiJyZWZlcmVuY2UiLCJwcmljZSI6MzB9"
          }
        ],
        "options": {
          "mode": "INDEXING_MODE_WAIT_FOR_COMPLETION",
          "waitTimeout": "10s",
          "onWaitTimeout": "INDEXING_WAIT_TIMEOUT_ACTION_FAIL_REQUEST"
        }
      }'
{
  "failedItems": [],
  "ack": "INDEX_ACK_COMPLETED"
}

If some documents could not be indexed, they are listed in failedItems with their error, while the rest of the batch is still indexed. For example, a document whose content isn’t a JSON object, or uses an attribute that the Meilisearch component reserves, is reported with INVALID_ARGUMENT.

Search the index

Run a full-text search for dapr, restricted to guides, sorted by price, and highlighting matches in the title field. Because topK is 1 and there are more results, the response includes a continuationToken that you send back to fetch the next page.

The loop below pages through all matching documents by passing the continuationToken from each response into the next query:

let continuationToken;

do {
  const page = await client.search.search(storeName, indexName, {
    text: "dapr",
    filter: { category: { $eq: "guide" } },
    sort: [{ field: "price", order: "asc" }],
    highlightFields: ["title"],
    includeContent: true,
    topK: 1,
    continuationToken,
  });

  for (const hit of page.hits) {
    console.log(hit.document.id, hit.score, hit.document.content?.title);
    console.log(hit.highlights?.title); // "Getting started with <em>Dapr</em>"
  }

  console.log(`${page.totalHits} total hits (${page.totalHitsRelation})`);
  continuationToken = page.continuationToken;
} while (continuationToken);

A continuation token is only valid for the query that produced it. Changing the query, topK, filter, sort, or field options between pages fails with INVALID_ARGUMENT.

To list every document in the index, call search without a query, or with a query that has neither text nor native. A filter still applies:

const all = await client.search.search(storeName, indexName);

const guides = await client.search.search(storeName, indexName, {
  filter: { category: { $eq: "guide" } },
  topK: 50,
});

The query is set in the query field of the request:

curl -X POST http://localhost:3500/v1.0-alpha1/search/mysearch/indexes/articles/query \
  -H "Content-Type: application/json" \
  -d '{
        "query": {
          "text": "dapr",
          "filter": { "category": "guide" },
          "sort": [ { "field": "price", "order": "SORT_ORDER_ASC" } ],
          "highlightFields": [ "title" ],
          "includeContent": true,
          "topK": 1
        }
      }'
{
  "hits": [
    {
      "document": {
        "id": "doc-1",
        "content": "eyJjYXRlZ29yeSI6Imd1aWRlIiwicHJpY2UiOjEwLCJ0aXRsZSI6IkdldHRpbmcgc3RhcnRlZCB3aXRoIERhcHIifQ==",
        "metadata": { "author": "alice" }
      },
      "score": 0.9,
      "highlights": {
        "title": "Getting started with <em>Dapr</em>"
      }
    }
  ],
  "totalHits": "2",
  "continuationToken": "eyJ...",
  "totalHitsRelation": "TOTAL_HITS_RELATION_ESTIMATE"
}

To get the next page, send the same request again with the token:

curl -X POST http://localhost:3500/v1.0-alpha1/search/mysearch/indexes/articles/query \
  -H "Content-Type: application/json" \
  -d '{
        "query": {
          "text": "dapr",
          "filter": { "category": "guide" },
          "sort": [ { "field": "price", "order": "SORT_ORDER_ASC" } ],
          "highlightFields": [ "title" ],
          "includeContent": true,
          "topK": 1,
          "continuationToken": "<token from the previous response>"
        }
      }'

A continuation token is only valid for the query that produced it. Changing the query, topK, filter, sort, or field options between pages returns an INVALID_ARGUMENT error.

To list every document in the index, omit query, or send a query without text or native. A filter still applies.

Use a provider-native query

When you need a feature the portable request does not cover, send a provider-native query in native instead of text. For Meilisearch, native accepts the fields of the Meilisearch search request, except for pagination and sort fields, which must use the portable topK, continuationToken, and sort fields:

const strict = await client.search.search(storeName, indexName, {
  native: { q: "dapr", matchingStrategy: "all" },
  topK: 10,
});

Setting both text and native on the same query throws a TypeError before the request is sent.

curl -X POST http://localhost:3500/v1.0-alpha1/search/mysearch/indexes/articles/query \
  -H "Content-Type: application/json" \
  -d '{
        "query": {
          "native": { "q": "dapr", "matchingStrategy": "all" },
          "topK": 10
        }
      }'

Get documents by ID

Fetch documents by ID. Found documents are returned in request order; IDs that don’t exist are omitted:

const docs = await client.search.getDocuments(storeName, indexName, ["doc-1", "doc-42"], {
  includeContent: true,
});

for (const doc of docs) {
  console.log(doc.id, doc.content, doc.metadata);
}
// doc-1 { title: 'Getting started with Dapr', category: 'guide', price: 10 } { author: 'alice' }

Without includeContent, each returned document has content set to undefined.

curl -X POST http://localhost:3500/v1.0-alpha1/search/mysearch/indexes/articles/documents/get \
  -H "Content-Type: application/json" \
  -d '{
        "ids": [ "doc-1", "doc-42" ],
        "includeContent": true
      }'

Delete documents

Deleting documents is a write and takes the same options as indexing. IDs that don’t exist are not an error:

const ack = await client.search.deleteDocuments(storeName, indexName, ["doc-2"], {
  mode: "waitForCompletion",
  waitTimeout: "10s",
  onWaitTimeout: "continueAsync",
});

console.log(ack); // "completed", or "queued" if the wait timed out
curl -X POST http://localhost:3500/v1.0-alpha1/search/mysearch/indexes/articles/documents/delete \
  -H "Content-Type: application/json" \
  -d '{
        "ids": [ "doc-2" ],
        "options": {
          "mode": "INDEXING_MODE_WAIT_FOR_COMPLETION",
          "waitTimeout": "10s",
          "onWaitTimeout": "INDEXING_WAIT_TIMEOUT_ACTION_CONTINUE_ASYNC"
        }
      }'

Delete the index

await client.search.deleteIndex(storeName, indexName);
curl -X DELETE http://localhost:3500/v1.0-alpha1/search/mysearch/indexes/articles

Requests without a body (get, list, and delete index) take request metadata as metadata.<key>=<value> query parameters.

Next steps