1 - How to: Index and search documents with the Dapr .NET SDK
Create indexes, write typed documents, search, and retrieve documents using Dapr.Search
Use DaprSearchClient to index JSON documents and run full-text queries through a Dapr search component. In this guide, you will configure a local store, create an index, write and search typed documents, and delete them.
Prerequisites
.NET framework support
Be advised that .NET 8 and .NET 9 reach end of support in November 2026. Support for both will be dropped
in Dapr v1.20; only .NET 10 and .NET 11 will be supported through the end of 2027.Install the package in an ASP.NET Core project:
dotnet add package Dapr.Search
The Search SDK overview describes the alpha API and release requirements.
Create components\searchstore.yaml in your application directory:
apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
name: searchstore
spec:
type: search.in-memory
version: v1
metadata:
- name: indexingDelay
value: "0s"
The in-memory component needs no external service. Data is lost when the sidecar stops and is not shared between sidecars. Use it for local development and testing, not durable storage.
For production, configure a supported search component. Component capabilities differ: for example, the in-memory component does not support highlighting or native queries.
Register and resolve the client
Add the following to Program.cs. The client reads the sidecar endpoint and API token from configuration or the environment.
using Dapr.Search;
using Dapr.Search.Extensions;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDaprSearchClient();
var app = builder.Build();
var client = app.Services.GetRequiredService<DaprSearchClient>();
Place the remaining operation snippets after this code, then call app.Run(); at the end of Program.cs. Create the document type in Article.cs:
public sealed record Article(string Title, string Category, decimal Price);
Run the application with a sidecar that loads the component:
dapr run --app-id search-example --resources-path .\components -- dotnet run
The .NET Search client uses gRPC; it is separate from DaprClient and does not require AddDaprClient().
Create and inspect an index
const string storeName = "searchstore";
const string indexName = "articles";
await client.CreateIndexAsync(storeName, indexName);
var info = await client.GetIndexAsync(storeName, indexName);
Console.WriteLine($"{info.Index}: approximately {info.DocumentCount} documents");
var indexes = await client.ListIndexesAsync(storeName);
Console.WriteLine(string.Join(", ", indexes));
Creating an existing index fails with AlreadyExists; it does not update the index settings. If you keep the sidecar running between executions, delete the example index before creating it again.
CreateIndexAsync accepts component-specific settings in its metadata argument. For example, the Meilisearch component requires filterableAttributes and sortableAttributes to declare fields used for filtering and sorting. These settings are not needed for the in-memory example.
Index typed documents
var writeOptions = new IndexingOptions
{
Mode = IndexingMode.WaitForCompletion,
WaitTimeout = TimeSpan.FromSeconds(10),
OnWaitTimeout = IndexingWaitTimeoutAction.FailRequest
};
var write = await client.IndexDocumentsAsync<Article>(
storeName,
indexName,
[
new("doc-1", new Article("Getting started with Dapr", "guide", 10),
new Dictionary<string, string> { ["author"] = "alice" }),
new("doc-2", new Article("Dapr pub/sub deep dive", "guide", 25)),
new("doc-3", new Article("Search API reference", "reference", 30))
],
writeOptions);
foreach (var failure in write.FailedItems)
{
Console.Error.WriteLine($"{failure.Id}: {failure.ErrorCode} {failure.ErrorMessage}");
}
if (write.FailedItems.Count != 0 || write.Ack != IndexAck.Completed)
{
throw new InvalidOperationException("Not all documents were confirmed indexed.");
}
SearchDocument<T> contains an ID, typed content, and optional string metadata. The SDK serializes content to UTF-8 JSON using the client’s JSON options. Content must serialize to a JSON object; invalid content is reported as an item failure by the runtime. IDs must be non-empty and unique within a write request. Writing an existing ID upserts that document.
Document metadata is stored and returned unchanged, but is not indexed or filterable. Filters address fields of Content, not SearchDocument<T>.Metadata.
Without writeOptions, a write returns on acceptance and can return IndexAck.Queued. An empty FailedItems list at that boundary does not guarantee eventual indexing success. The explicit wait above lets the following reads observe completed writes. See write acknowledgement guidance.
Search and paginate
Add using System.Text.Json; at the top of Program.cs, then search for guides sorted by price:
var query = new SearchQuery
{
Text = "dapr",
SearchFields = ["title"],
Filter = JsonSerializer.Deserialize<JsonElement>("""{"category":{"$eq":"guide"}}"""),
Sort = [new SortClause("price", SortOrder.Ascending)],
IncludeContent = true,
TopK = 1
};
SearchResult<Article> page;
do
{
page = await client.SearchAsync<Article>(storeName, indexName, query);
foreach (var hit in page.Hits)
{
Console.WriteLine($"{hit.Document.Id}: {hit.Document.Content?.Title} ({hit.Score})");
}
Console.WriteLine($"Total: {page.TotalHits}, accuracy: {page.TotalHitsRelation}");
query = query with { ContinuationToken = page.ContinuationToken };
}
while (!string.IsNullOrEmpty(page.ContinuationToken));
The default JSON naming policy is camelCase, so field names use title, category, and price, not the .NET property names. A continuation token belongs to the query that produced it: keep the text, filter, sort, page size, and field options unchanged between pages.
SearchAsync<T> returns one page, not an automatically enumerated stream. Content is omitted unless IncludeContent is true. Omit the query, or omit both Text and Native, to match all documents, subject to any filter.
Retrieve documents by ID
var documents = await client.GetDocumentsAsync<Article>(
storeName, indexName, ["doc-1", "missing"], includeContent: true);
foreach (var document in documents)
{
Console.WriteLine($"{document.Id}: {document.Content?.Title}");
}
Found documents are returned in request order; missing IDs are omitted. Without includeContent: true, Content is default(T).
Delete documents and the index
var ack = await client.DeleteDocumentsAsync(
storeName, indexName, ["doc-2"], writeOptions);
if (ack != IndexAck.Completed)
{
throw new InvalidOperationException("Document deletion was not confirmed complete.");
}
await client.DeleteIndexAsync(storeName, indexName);
Deleting a missing document ID is not an error. Deleting an index removes its documents as well.
Next steps
2 - Dapr Search client
Configure DaprSearchClient, shape queries, manage write acknowledgements, and generate typed index clients
DaprSearchClient in Dapr.Search implements the alpha Search API through gRPC. Use .NET SDK 1.19.0 or later with a Dapr runtime and component that support Search. See the overview for release requirements and the how-to for a complete document lifecycle.
Client lifetime and configuration
Reuse a long-lived client rather than creating one per operation. Dependency injection registers a singleton by default and owns its disposal:
using Dapr.Search.Extensions;
builder.Services.AddDaprSearchClient((serviceProvider, clientBuilder) =>
{
clientBuilder.UseGrpcEndpoint("http://localhost:50001");
});
The callback can resolve configuration or other services through serviceProvider. Use the optional lifetime argument to choose another ServiceLifetime when required.
For applications without dependency injection, create and dispose a client yourself:
using Dapr.Search;
using var client = new DaprSearchClientBuilder()
.UseGrpcEndpoint("http://localhost:50001")
.Build();
The builder accepts an optional IConfiguration. Defaults resolve DAPR_GRPC_ENDPOINT, or fall back to the local endpoint using DAPR_GRPC_PORT (50001 by default); DAPR_API_TOKEN supplies sidecar authentication. Explicit builder settings override defaults; use UseDaprApiToken to override the token.
Use UseJsonSerializationOptions for document serialization and UseGrpcChannelOptions for gRPC settings. When supplying channel options, retain ThrowOperationCanceledOnCancellation = true. Although the shared builder also exposes HTTP endpoint, HTTP client factory, and HTTP timeout settings, Search operations use gRPC; changing the HTTP endpoint does not switch their transport, and UseTimeout is not a per-RPC deadline. Pass a time-limited CancellationToken to bound how long the caller waits.
API coverage
All operations accept optional component-specific request metadata (IReadOnlyDictionary<string, string>) and a CancellationToken.
| Operation | Result |
|---|
CreateIndexAsync, DeleteIndexAsync | Create or remove an index. Creation metadata supplies provider-specific settings. |
GetIndexAsync | SearchIndex with Index, approximate DocumentCount, and component Properties. |
ListIndexesAsync | Index names in the component. |
IndexDocumentsAsync<T> | IndexDocumentsResult with Ack and FailedItems. |
GetDocumentsAsync<T> | Found SearchDocument<T> entries in request order. |
DeleteDocumentsAsync | IndexAck for the deletion. |
SearchAsync<T> | One SearchResult<T> page. |
Request metadata configures an operation; it is separate from each document’s stored, opaque Metadata.
Query options and results
SearchQuery is a record, so use with expressions when changing only the continuation token.
| Property | Behavior |
|---|
Text | Portable full-text query. Mutually exclusive with Native; neither means match all. |
Native | Provider-native query represented by a JSON object (JsonElement). |
Filter | Portable JSON-object filter addressing content fields, including dotted nested paths. |
TopK | Nullable uint page size; null or zero selects the component default. |
ContinuationToken | Opaque token from the previous response. Keep the rest of the query unchanged. |
SearchFields | Content fields to search. |
Sort | Ordered SortClause entries with Ascending, Descending, or provider-default Unspecified order. |
IncludeContent | Null or false omits content; returned Content is default(T). |
ReturnFields | Fields projected into returned content. Use with IncludeContent = true. |
HighlightFields | Fields for which highlighted fragments are requested, if the component supports them. |
Filters support $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $exists, $and, $or, and $not. Multiple conditions in an object are ANDed, and a direct field value is an equality test. Use serialized JSON field names, which are camelCase by default. See the portable filter language and your component’s requirements for filterable and sortable fields.
SearchResult<T>.Hits contains SearchHit<T> entries with a Document, unnormalized provider-specific Score, and a Highlights dictionary keyed by field. Higher scores indicate greater relevance within that result set; do not compare them across providers. TotalHits is nullable, and TotalHitsRelation is Unspecified, Exact, LowerBound, or Estimate. Stop paging when ContinuationToken is null or empty.
Projection and highlighting
Using the Article type and client from the how-to, the following query requires a component that supports highlighting, such as Meilisearch:
var page = await client.SearchAsync<Article>("searchstore", "articles", new SearchQuery
{
Text = "dapr",
IncludeContent = true,
ReturnFields = ["title", "category"],
HighlightFields = ["title"]
});
foreach (var hit in page.Hits)
{
if (hit.Highlights.TryGetValue("title", out var title))
{
Console.WriteLine(title);
}
}
Projection can omit members expected by your model. Choose a compatible result type or JsonElement for partial documents. Highlight fragments can contain provider-generated markup; treat them as untrusted content when rendering a UI.
Provider-native queries
Pass a JSON object in Native rather than Text for provider-specific features:
using System.Text.Json;
var page = await client.SearchAsync<JsonElement>("searchstore", "articles", new SearchQuery
{
Native = JsonSerializer.Deserialize<JsonElement>(
"""{"q":"dapr","matchingStrategy":"all"}"""),
IncludeContent = true,
TopK = 10
});
This example uses Meilisearch syntax and will not work with the in-memory component. Meilisearch pagination and sorting must still use the portable TopK, ContinuationToken, and Sort properties. Setting both Text and Native, or supplying a non-object Native or Filter, throws ArgumentException before sending an RPC.
Write acknowledgements
IndexDocumentsAsync<T> and DeleteDocumentsAsync accept Dapr.Search.IndexingOptions.
| Setting | Behavior |
|---|
Omitted options, ReturnOnAcceptance, or Unspecified mode | Return after acceptance. IndexAck.Queued means background work was accepted, not that indexing succeeded. Synchronous components can return Completed. |
WaitForCompletion | Requires a positive WaitTimeout and explicit OnWaitTimeout. Completed means the final write result is available, including any item failures. |
OnWaitTimeout = ContinueAsync | Return Queued if the wait expires; requires component support for queued acknowledgement. |
OnWaitTimeout = FailRequest | Fail with DeadlineExceeded if the wait expires. Provider-side work may still complete. |
Do not supply WaitTimeout or OnWaitTimeout with acceptance mode. WaitTimeout must be shorter than any remaining RPC deadline and must leave headroom below the store resiliency timeout of at least 10% of that timeout and at least 100 ms. See the runtime acknowledgement rules.
Always inspect both IndexDocumentsResult.Ack and FailedItems. A completed batch can contain failed documents without throwing for the whole request. Each SearchFailedItem includes Id, canonical numeric ErrorCode, ErrorMessage, and Details preserving protobuf TypeUrl and raw Value bytes. At a queued boundary, only failures already known can be reported; there is no client API to poll for a final indexing result.
Serialization
Content uses System.Text.Json with JsonSerializerDefaults.Web by default. Pass .NET objects to SearchDocument<T>; do not manually base64-encode them or pass an already serialized JSON string, which would serialize as a string rather than an object. Use JsonElement for document shapes without a fixed .NET model.
For source-generated JSON serialization, configure the client with a context covering the content type:
using System.Text.Json.Serialization;
using Dapr.Search.Extensions;
builder.Services.AddDaprSearchClient((_, clientBuilder) =>
clientBuilder.UseJsonSerializationOptions(SearchJsonContext.Default.Options));
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(Article))]
internal partial class SearchJsonContext : JsonSerializerContext
{
}
The generated index client below does not generate JSON serialization metadata. Configure JSON options separately when reflection-free serialization is required.
Generate a typed index client
The Dapr.Search package bundles a source generator. Declare a top-level, non-generic, non-file-local partial interface, extending exactly one IDaprSearchIndex<T>, without additional abstract members:
using Dapr.Search;
[SearchIndex("searchstore", "articles")]
public partial interface IArticleSearch : IDaprSearchIndex<Article>
{
}
The component and index names must be non-empty. The generator supplies AddArticleSearch, named after the interface with its leading I removed:
builder.Services.AddDaprSearchClient().AddArticleSearch();
Inject the interface instead of repeatedly specifying the component, index, and generic type:
public sealed class ArticleService(IArticleSearch articles)
{
public Task<SearchResult<Article>> FindAsync(string text, CancellationToken cancellationToken) =>
articles.SearchAsync(
new SearchQuery { Text = text, IncludeContent = true },
cancellationToken: cancellationToken);
}
The generated wrapper exposes the index-scoped operations in the API table, but not store-wide ListIndexesAsync. It forwards options, request metadata, and cancellation unchanged, inherits the registered client’s lifetime, and does not own or dispose that client. Registration does not create the index; call CreateIndexAsync explicitly.
Invalid declarations, bindings, or contracts produce build errors DAPR1801, DAPR1802, or DAPR1803, respectively.
Cancellation and errors
Invalid client arguments throw argument exceptions before an RPC. RPC failures throw DaprException with the original Grpc.Core.RpcException as InnerException, preserving its status and trailers:
using Dapr;
using Grpc.Core;
try
{
await client.CreateIndexAsync("searchstore", "articles");
}
catch (DaprException ex) when (
ex.InnerException is RpcException { StatusCode: StatusCode.AlreadyExists })
{
Console.Error.WriteLine("The index already exists; its settings were not changed.");
}
This is distinct from item failures returned by a successful indexing call. Caller cancellation can throw OperationCanceledException and does not guarantee that the provider stopped processing a write. Retry and timeout policies are configured through Dapr resiliency, not by this client.