Use C# models for document serialization and schema generation, compose queries with the YQL builder, and feed mixed document operations with bounded concurrency.
Quick Start · Documents · Search · Grouping · Feed · Configuration · Guides
| Schema | Code-first .sd generation from attributes, multi-type deploy, custom services.xml |
| Documents | CRUD, conditional writes, field-level ops, resumable visit pages, group/number addressing (read+write), selection-based update/delete/copy with continuation auto-loop + crash-safe manual paging |
| Search | Full-text, nearest-neighbor, hybrid, streaming-mode queries and lazy result pagination |
| YQL | Fluent type-safe builder with boolean composition, grouping, ranking DSL |
| Feed | Mixed puts/updates/deletes across schemas and namespaces, per-document ordering, bounded backpressure, progress callbacks |
| Models | Attribute-based field names, tensor formats and [VespaExtraFields] for fields outside the C# model |
| Cloud | mTLS (PEM, PFX, in-memory cert), Bearer token auth |
| Ops | OpenTelemetry traces + metrics, ASP.NET Core health checks, Testcontainers |
| Performance | HTTP/2 multiplexing, connection pooling, GZip/Deflate, ResponseHeadersRead |
| Resilience | HTTP retry + circuit breaker through DI, feed-level retry and source-generated logging |
The example targets a local Vespa 8 instance with the document/query endpoint on
port 8080 and the config server on port 19071. It deploys a generated
application package; use a separate development instance for this example.
docker run --detach --name vespa --publish 8080:8080 --publish 19071:19071 vespaengine/vespa:8Wait for the config server at http://localhost:19071/ApplicationStatus to respond
before deploying. If your application is already deployed, configure Endpoint
and skip the deployment call.
dotnet new console --name VespaQuickstart --framework net10.0
cd VespaQuickstart
dotnet add package Vespa.NETUse --framework net8.0 to target .NET 8.
| Package | Use |
|---|---|
Vespa.NET |
Client, DI integration, YQL and schema builders; includes Vespa.NET.Models transitively |
Vespa.NET.Models |
DTOs, attributes and tensor types without client dependencies |
Vespa.NET.Testcontainers |
Optional Docker helper for integration tests |
Add Product.cs:
using Vespa.Models.Attributes;
using Vespa.Models.Schema;
using Vespa.Models.Tensors;
[VespaDocument("product", Namespace = "myapp")]
[VespaRankProfile("semantic", Inherits = "default", FirstPhase = "closeness(field, embedding)")]
[VespaRankProfile("hybrid", Inherits = "default",
FirstPhase = "closeness(field, embedding) + nativeRank(product_name)")]
public record Product
{
[VespaId]
public string Id { get; init; } = "";
[VespaField(Name = "product_name", IndexingMode = IndexingMode.IndexAttributeSummary)]
public string Name { get; init; } = "";
[VespaField(Name = "price", IndexingMode = IndexingMode.AttributeSummary)]
public decimal Price { get; init; }
[VespaField(Name = "category", IndexingMode = IndexingMode.IndexAttributeSummary)]
public string Category { get; init; } = "";
[VespaTensor("tensor<float>(x[4])", EnableIndex = true, DistanceMetric = DistanceMetric.Euclidean)]
public VespaTensor Embedding { get; init; } = null!;
}Add the following to Program.cs. The four-dimensional vectors keep the example
self-contained; in an application, use your embedding model's vectors and dimension.
using Vespa;
using Vespa.Documents;
using Vespa.Models;
using Vespa.Models.Tensors;
using Vespa.Search;
var options = new VespaClientOptions
{
Endpoint = "http://localhost:8080",
ConfigServerEndpoint = "http://localhost:19071",
DefaultNamespace = "myapp"
};
using var client = new VespaClient(options);
// Deploy schema from C# attributes
await client.Admin.DeploySchemaAsync<Product>();
// Allow the application container to become ready after activation
using var startup = new CancellationTokenSource(TimeSpan.FromMinutes(2));
while (!await client.IsReadyAsync(startup.Token))
await Task.Delay(TimeSpan.FromMilliseconds(500), startup.Token);
// Index a document
var product = new Product
{
Id = "p-1",
Name = "Laptop",
Price = 999.99m,
Category = "electronics",
Embedding = VespaTensor.FromDenseValues<float>([0.1f, 0.2f, 0.3f, 0.4f])
};
await client.Documents.PutAsync(product.Id, product);
// Retrieve it directly, or search with the generated semantic rank profile
var document = await client.Documents.GetAsync<Product>(product.Id);
Console.WriteLine(document?.Fields?.Name);
var queryEmbedding = VespaTensor.FromDenseValues<float>([0.1f, 0.2f, 0.3f, 0.5f]);
var results = await client.Search.NearestNeighborSearchAsync<Product>(
queryEmbedding, p => p.Embedding, topK: 10, rankProfile: "semantic");
foreach (var hit in results.Root.Children)
Console.WriteLine($"{hit.Fields?.Name}: {hit.Relevance:F3}");Writes can become visible to search after the write response; a direct document GET and a search do not have the same visibility guarantees.
The options-only constructor configures its HTTP handler and owns its lifetime.
For factory-managed HTTP retry and circuit breaking, use
AddVespaClient. Feed-level retry is also available with
a directly constructed client.
The following examples are independent and reuse the model and client above.
Document extensions infer the schema and namespace from [VespaDocument];
search extensions infer the schema. [VespaField(Name)] drives both schema
generation and JSON serialization.
// CRUD — no documentType needed
await client.Documents.PutAsync(product.Id, product);
var doc = await client.Documents.GetAsync<Product>(product.Id);
// Request options with model-inferred schema and namespace
var readOptions = new DocumentRequestOptions { Cluster = "content", Timeout = TimeSpan.FromSeconds(5) };
var routedDoc = await client.Documents.GetAsync<Product>(readOptions, product.Id);
// Field-level update with typed lambda builder
await client.Documents.UpdateFieldsAsync<Product>(product.Id, ops => ops
.Field(p => p.Name, FieldOp.Assign("New Name"))
.Field(p => p.Price, FieldOp.Multiply(0.9)));
// Nearest-neighbor — field name resolved via lambda
var results = await client.Search.NearestNeighborSearchAsync<Product>(
queryEmbedding, p => p.Embedding, topK: 10, rankProfile: "semantic");
// Visit
await foreach (var d in client.Documents.VisitAsync<Product>(selection: "product.price > 100"))
Console.WriteLine(d.Fields?.Name);
// One visit page; persist Continuation after processing to resume later
var page = await client.Documents.VisitPageAsync<Product>(cluster: "content");
foreach (var visited in page.Documents)
Console.WriteLine(visited.Fields?.Name);
if (!page.IsComplete)
page = await client.Documents.VisitPageAsync<Product>(
cluster: "content", continuation: page.Continuation);
// Selection-based bulk ops — typed builder, auto-loops on Vespa's continuation token
var resp = await client.Documents.UpdateBySelectionAsync<Product>(
"product.price > 100",
ops => ops.Field(p => p.Price, FieldOp.Multiply(0.9)),
cluster: "content");
// resp.DocumentCount is the total across all chunks
await client.Documents.DeleteAsync<Product>(product.Id);Persist a visit continuation after processing that page. An empty page may
still have a continuation; use IsComplete to decide whether visiting has ended.
Pass a bare ID such as "p-1" or a full ID such as "id:myapp:product::p-1".
Full IDs retain their namespace, type and group/number addressing; incompatible
targets throw. Returned documents expose FullId as well as the user-specified Id.
using Vespa.Query;
var yql = YqlBuilder<Product>
.Select(p => p.Name, p => p.Price)
.Where(w =>
{
w.Field(p => p.Price).GreaterThan(10);
w.Field(p => p.Name).Contains("laptop");
})
.OrderBy(p => p.Price, descending: true)
.Limit(20)
.Build();Fluent request builder captures YQL + ranking in one chain:
var request = YqlBuilder<Product>
.Select()
.Where(w => w.HybridSearch(p => p.Embedding, "query_embedding", "text", totalTargetHits: 100))
.Limit(20)
.ToSearchRequest()
.WithRankProfile("hybrid")
.WithQueryTensor("query_embedding", queryEmbedding)
.WithUserInput("text", "laptop");
var results = await client.Search.SearchAsync<Product>(request);HybridSearch retrieves with nearest-neighbor search and uses text matches for
ranking through Vespa's rank() operator. The example's hybrid rank profile is
declared on Product above. embed(...) inputs are also supported when you have
configured an embedder in Vespa.
Additional query parameters can be sent with request.WithParameter(name, value).
For example, request.WithParameter("ranking.matching.anntimebudget", "25ms")
sets an ANN time budget on Vespa 8.684.9 or newer.
SearchStreamAsync paginates ordinary search results lazily and is subject to
Vespa's maxHits/maxOffset limits. Use document visiting to export a corpus.
Vespa's separate streaming search mode requires a streaming-mode content cluster
and request properties such as StreamingGroupName.
Full predicate reference, boolean composition, and validation rules in docs/yql-builder.md
using Vespa.Query;
var request = new VespaSearchRequest
{
Yql = YqlBuilder<Product>
.Select()
.GroupBy(
GroupingBuilder.All()
.Group("category")
.Max(10)
.OrderByDescending(GroupingAgg.Count())
.Each(e => e.Output(GroupingAgg.Count(), GroupingAgg.Avg("price"))))
.Build(),
Hits = 0
};
var result = (await client.Search.GroupByAsync<Product>(request)).AsTyped();
foreach (var group in result.GroupingResults[0].Groups)
Console.WriteLine($"{group.Value}: {group.GetAggregation<long>("count()")} documents");Typed grouping, exact aggregation values, buckets, summaries and pagination in docs/grouping.md
using Vespa.Feed;
// Mixed operations with per-document ordering
FeedOperation[] operations =
[
FeedOperation.Put("p-1", product),
FeedOperation.Update<Product>("p-1", update => update.Field(p => p.Price, FieldOp.Assign(49.99m))),
FeedOperation.Delete<Product>("p-2")
];
var mixed = await client.Feed.FeedAsync(operations, new FeedOptions { MaxConcurrency = 32 });
Console.WriteLine($"{mixed.SuccessCount}/{mixed.TotalOperations} operations succeeded");
foreach (var error in mixed.Errors)
Console.WriteLine($"{error.OperationIndex}: {error.DocumentId} — {error.Message}");The mixed feed accepts both IEnumerable<FeedOperation> and
IAsyncEnumerable<FeedOperation>. FeedOptions.BoundedCapacity limits buffered
work; operations for the same document preserve source order, including retries.
Mixed operations, ordering, per-document conditions, BulkPut/Update/Delete, and FeedResult details in docs/feed.md
Use [VespaExtraFields] to preserve Vespa fields without declaring a C# property
for each one. These fields must still exist in your deployed Vespa schema.
using System.Text.Json;
using Vespa.Models.Attributes;
using Vespa.Models.Schema;
public record ProductFields
{
// App-owned fields — strongly typed, used for ranking
[VespaField(IndexingMode = IndexingMode.AttributeSummary)]
public double Popularity { get; init; }
// Tenant-defined fields — captured dynamically
[VespaExtraFields]
public Dictionary<string, JsonElement>? TenantFields { get; init; }
}- Unmapped JSON fields are captured in the dictionary during deserialization
- Serialized flat alongside declared properties (not nested)
- Values are retained as
JsonElementfor round trips - The schema builder skips the dictionary; declare its fields in the Vespa schema separately
// Deploy from C# attributes
await client.Admin.DeploySchemaAsync<Product>();
// Multi-tenant: override tenant per-call
await client.Admin.DeploySchemaAsync<Product>(tenant: "acme");
// Deploy raw ZIP
using var zipStream = File.OpenRead("application.zip");
await client.Admin.DeployAsync(zipStream);
// Application status and readiness
var status = await client.Admin.GetApplicationStatusAsync();
var ready = await client.IsReadyAsync();
var version = await client.GetVersionAsync();
var metrics = await client.GetMetricsAsync();Note:
ApiKey,DefaultRequestHeaders, compression, timeout, and mTLS settings are applied to both the data-plane client and the admin/config-server client.
Note: Retry and circuit-breaker options are enforced by the DI registrations (
AddVespaClient). A directly constructedVespaClientapplies feed-level retry only; usenew VespaClient(options)(options-only constructor) to get a fully configured handler (mTLS, compression, pooling) without DI.
Tip: Health/state helpers such as
HealthCheckAsync,IsReadyAsync,GetMetricsAsync,GetVersionAsync, andGetHistogramsAsyncare best-effort on ordinary failures, but they still propagateOperationCanceledExceptionwhen the request is cancelled.
Schema attributes,
[VespaExtraFields],ApplicationPackageOptions, andCustomServicesXmlin docs/schema.md
DI Integration
In an ASP.NET Core application, register the client before building the app:
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Vespa;
using Vespa.Documents;
using Vespa.Models;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddVespaClient(new VespaClientOptions
{
Endpoint = "http://localhost:8080",
DefaultNamespace = "myapp",
EnableRetry = true
});
// Named client (multi-cluster)
builder.Services.AddVespaClient("analytics", new VespaClientOptions
{
Endpoint = "http://analytics-vespa:8080"
});
// Inject
public class ProductService(IVespaClient vespa)
{
public Task<VespaDocument<Product>?> GetAsync(string id)
=> vespa.Documents.GetAsync<Product>(id);
}Vespa Cloud (mTLS)
var options = new VespaClientOptions
{
Endpoint = "https://myapp.vespa-cloud.com",
CertificatePath = "/path/to/cert.pem",
ClientKeyPath = "/path/to/key.pem",
// or: ClientCertificate = myCert,
// or: ApiKey = "bearer-token"
};Observability (OpenTelemetry)
Install OpenTelemetry.Extensions.Hosting and register the SDK's sources in your
OpenTelemetry setup:
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
builder.Services.AddOpenTelemetry()
.WithTracing(b => b.AddSource("Vespa.NET"))
.WithMetrics(m => m.AddMeter("Vespa.NET"));Spans: vespa.search, vespa.document.*, vespa.feed.pipeline, etc.
Query text (YQL) and document ids are excluded from span tags by default; opt in with VespaClientOptions.RecordQueryTextInTraces = true.
Metrics: vespa.client.requests, vespa.client.request_duration, vespa.client.documents_written, etc.
Health Checks
builder.Services.AddHealthChecks().AddVespa();Testcontainers
using Vespa.Testcontainers;
await using var vespa = new VespaContainer();
await vespa.StartAsync();
var client = vespa.CreateClient("test");
await client.Admin.DeploySchemaAsync<Product>();Install Vespa.NET.Testcontainers and have Docker running. The container owns the
client created by CreateClient. To run this repository's integration suite,
set VESPA_INTEGRATION_TESTS=1; this flag is not required by the helper itself.
Full configuration reference, exceptions, metrics table, and more in docs/configuration.md
| Guide | Content |
|---|---|
| Schema & Attributes | Code-first generation, [VespaExtraFields], ApplicationPackageOptions |
| Document Operations | CRUD, field ops, conditional writes, visit, addressing |
| Search | Basic, nearest-neighbor, hybrid, streaming, request options |
| YQL Builder | Fluent query builder, predicates, boolean composition |
| Grouping | Aggregations, buckets, nested grouping, pagination |
| Ranking DSL | RankingBuilder, code-first rank profiles |
| Feed | Bulk operations, streaming pipeline, backpressure |
| Configuration | Options, DI, mTLS, observability, health checks, testing |
The sample app uses the local Vespa instance described in Quick Start:
dotnet run --project samples/Vespa.NET.Samples| Package | Purpose |
|---|---|
Microsoft.Extensions.Http |
IHttpClientFactory |
Microsoft.Extensions.Http.Resilience |
Retry + circuit breaker (Polly) |
Microsoft.Extensions.Diagnostics.HealthChecks |
ASP.NET Core health check |
System.Text.Json |
JSON serialization (built-in) |
Testcontainers (optional helper package) |
Docker fixtures through Vespa.NET.Testcontainers |
MIT License — see LICENSE for details.