Table of Contents
This example writes a short support document into Qdrant, reads it back, and lets you run the same code again without creating another point for that document.
In Your First Qdrant Collection, we created support-documents-v1 with an unnamed dense vector and checked its embedding profile. That collection is ready for the Azure OpenAI path below. If you prefer local embeddings, the optional Ollama setup creates a separate collection on the same Qdrant server. Both paths use the same indexing code.
Continue from the collection example
Use the QdrantCollections console project from the collection article.
If you stopped the existing container, start it again:
docker start -a qdrant-local
Keep it running and use another terminal for the .NET commands. If you are starting here, follow the local setup and collection example first. It includes the Docker command, project creation, and collection validation.
Choose one embedding setup. Azure OpenAI continues with the existing collection; Ollama needs the preparation in the next section.
| Setting | Azure OpenAI | Ollama locally |
|---|---|---|
| Model | text-embedding-3-small | embeddinggemma:300m |
| Dimensions | 1,536 | 768 |
| Distance | Cosine | Cosine |
| Collection | support-documents-v1 | support-documents-embeddinggemma-v1 |
| Profile ID | support-text-v1 | support-embeddinggemma-v1 |
The local path uses a separate collection because EmbeddingGemma produces a different vector space. The later query must target the collection for its embedding profile.
Use Azure OpenAI in Microsoft Foundry
The package commands pin exact versions so you can reproduce the setup. The Microsoft.Extensions.AI dependencies stay at 10.9.0 to match the earlier embedding example. Newer compatible versions may also work.
In the project directory, add the Microsoft.Extensions.AI OpenAI integration used in Embeddings in .NET, plus the Azure OpenAI client:
dotnet add package Microsoft.Extensions.AI.OpenAI --version 10.9.0
dotnet add package Azure.AI.OpenAI --version 2.1.0
Deploy text-embedding-3-small in Microsoft Foundry. This example connects through the Azure OpenAI resource endpoint shown below. Keep its default 1,536-dimensional output. Set these environment variables in the terminal that runs the application:
| Variable | Value |
|---|---|
AZURE_OPENAI_ENDPOINT | Your Azure OpenAI resource endpoint, such as https://your-resource.openai.azure.com/ |
AZURE_OPENAI_EMBEDDING_DEPLOYMENT | The name you assigned to the text-embedding-3-small deployment, such as support-embeddings |
AZURE_OPENAI_API_KEY | A key for that Azure OpenAI resource |
Use the resource endpoint for this AzureOpenAIClient example, not a Foundry project URL or an endpoint with /openai/v1 appended. The client builds the deployment-specific request path. Keep the key outside source control. This example uses key authentication. The Azure SDK also supports Microsoft Entra credentials.
The embedding request sends the text to your Azure OpenAI deployment and incurs Azure usage charges. Start with the sample notes below.
Replace Program.cs with this setup block, then append the shared code under Index and read back the document. Together, the two blocks form the complete program.
using Microsoft.Extensions.AI;
using Azure.AI.OpenAI;
using System.ClientModel;
using Qdrant.Client;
using Qdrant.Client.Grpc;
const string collectionName = "support-documents-v1";
const string embeddingProfileId = "support-text-v1";
const int vectorSize = 1536;
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
?? throw new InvalidOperationException("Set AZURE_OPENAI_ENDPOINT.");
string deploymentName = Environment.GetEnvironmentVariable(
"AZURE_OPENAI_EMBEDDING_DEPLOYMENT")
?? throw new InvalidOperationException("Set AZURE_OPENAI_EMBEDDING_DEPLOYMENT.");
string apiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY")
?? throw new InvalidOperationException("Set AZURE_OPENAI_API_KEY.");
using IEmbeddingGenerator<string, Embedding<float>> generator =
new AzureOpenAIClient(new Uri(endpoint), new ApiKeyCredential(apiKey))
.GetEmbeddingClient(deploymentName)
.AsIEmbeddingGenerator();
Func<SourceDocument, string> prepareDocument = document => document.Text;
GetEmbeddingClient takes the Azure deployment name, which may differ from the model name. Confirm that the deployment uses text-embedding-3-small. Pointing the variable at another model does not make its vectors compatible.
The Azure OpenAI path embeds document.Text exactly as supplied. That keeps the existing support-text-v1 profile: plain text with the default 1,536-dimensional output. We store the title in the payload for display. The empty collection from the first article can be reused with this configuration. If you have already populated it through another endpoint, verify model-version compatibility before mixing vectors. Matching dimensions alone is insufficient.
Optional: generate embeddings locally with Ollama
Skip this section if you chose Azure OpenAI.
Install Ollama and start it. On a command-line installation, run ollama serve in a separate terminal. If the desktop app already runs the server, leave that instance running.
Download the embedding model:
ollama pull embeddinggemma:300m
EmbeddingGemma on Ollama requires Ollama 0.11.10 or later. After downloading the model, this path generates embeddings on your machine without an Azure subscription or cloud API key. Use the explicit 300m tag for this sample. Tags can change, so record the model digest shown by ollama list when reproducing results.
Before replacing the collection program, change its three constants to:
const string collectionName = "support-documents-embeddinggemma-v1";
const string embeddingProfileId = "support-embeddinggemma-v1";
const ulong vectorSize = 768;
Leave cosine distance and the rest of that program unchanged. Run dotnet run to create and validate the local model’s collection. The indexing program below does not create collections. It expects this setup step to have completed successfully. The different name preserves support-documents-v1 if it already contains Azure OpenAI vectors.
Here, support-embeddinggemma-v1 declares embeddinggemma:300m, the full 768-dimensional output, cosine distance, and the document/query formatting described below. For the local Ollama path, the sample checks the declared profile ID, but it does not enforce model-artifact identity. If the tag later points to a different digest, the collection check still passes. Before deploying this pipeline, record the expected model digest in the profile and compare it with the locally installed model before writing or querying. Stop on a mismatch and review whether the new artifact requires reindexing.
Add OllamaSharp and pin the abstraction package used by this sample:
dotnet add package Microsoft.Extensions.AI --version 10.9.0
dotnet add package OllamaSharp --version 5.4.30
OllamaSharp already depends on Microsoft.Extensions.AI. The explicit reference pins it to 10.9.0.
Some .NET 10 SDK versions may report CS9057 for OllamaSharp’s optional source generator. This example does not use its generated [OllamaTool] support.
Use this setup block instead of the Azure OpenAI block at the top of Program.cs. Append the same shared indexing code that follows.
using Microsoft.Extensions.AI;
using OllamaSharp;
using Qdrant.Client;
using Qdrant.Client.Grpc;
const string collectionName = "support-documents-embeddinggemma-v1";
const string embeddingProfileId = "support-embeddinggemma-v1";
const int vectorSize = 768;
using IEmbeddingGenerator<string, Embedding<float>> generator =
new OllamaApiClient(
new Uri("http://localhost:11434"),
"embeddinggemma:300m");
Func<SourceDocument, string> prepareDocument = document =>
$"title: {document.Title} | text: {document.Text}";
OllamaApiClient implements IEmbeddingGenerator<string, Embedding<float>> directly. The indexing code uses that interface in either setup.
The title: ... | text: ... prefix follows Google’s document format. Later, a search query should use task: search result | query: ... with the same model. These prefixes belong to the embedding profile. They are not part of the original document text stored in the payload.
EmbeddingGemma has a 2,048-token input limit, including the title and document prefix. Ollama’s embedding API defaults to truncate: true. This sample leaves that default in place, so an oversized input can lose its ending without the request failing. The short notes below fit, but before accepting larger documents, add token-aware size checks or chunking. You can also set the API’s truncate option to false to reject oversized inputs.
Give the source a stable identity
The first document is a short note about stopping a background service. Its source ID is worker-shutdown, and it has one chunk named whole-document. Keeping the entire note in one point makes the first write easy to inspect.
The Qdrant point ID is a fixed UUID stored with that source record. Qdrant accepts UUIDs or unsigned 64-bit integers for point IDs, so a source slug such as worker-shutdown belongs in the payload rather than directly in the point ID field.
For this one-chunk example, each document has one persistent point ID. If a document later produces several chunks, each chunk needs its own stable point ID. The document_id and chunk_id payload fields describe the relationship. They do not enforce uniqueness.
I would keep the first example this small. A file parser and a chunking strategy can come after we have a document we can write and retrieve.
Index and read back the document
Append this code directly after your chosen setup block. Do not keep the old collection program in the same file.
using var executionTimeout =
new CancellationTokenSource(TimeSpan.FromMinutes(2));
CancellationToken token = executionTimeout.Token;
using var client = new QdrantClient(host: "127.0.0.1", port: 6334);
CollectionInfo collection = await client.GetCollectionInfoAsync(
collectionName, token);
VectorsConfig? config = collection.Config.Params.VectorsConfig;
if (config is null ||
config.ConfigCase != VectorsConfig.ConfigOneofCase.Params ||
config.Params.Size != (ulong)vectorSize ||
config.Params.Distance != Distance.Cosine)
{
throw new InvalidOperationException(
$"Collection '{collectionName}' does not match the vector configuration.");
}
if (!collection.Config.Metadata.TryGetValue(
"embedding-profile-id", out Value? storedProfile) ||
storedProfile.StringValue != embeddingProfileId)
{
throw new InvalidOperationException(
$"Collection '{collectionName}' does not match profile '{embeddingProfileId}'.");
}
SourceDocument[] documents =
[
new(
PointId: Guid.Parse("19e8e940-0562-4b6b-bca5-1f99ea154f2e"),
DocumentId: "worker-shutdown",
Title: "Stop a background service cleanly",
Text: "Pass the stopping token to asynchronous I/O in a BackgroundService. " +
"When shutdown starts, let the operation observe cancellation " +
"and finish within the host shutdown timeout.")
];
foreach (SourceDocument document in documents)
{
if (string.IsNullOrWhiteSpace(document.Text))
{
throw new InvalidOperationException(
$"Document '{document.DocumentId}' has no text.");
}
ReadOnlyMemory<float> vector = await generator.GenerateVectorAsync(
prepareDocument(document), cancellationToken: token);
if (vector.Length != vectorSize)
{
throw new InvalidOperationException(
$"Expected {vectorSize} values, received {vector.Length}.");
}
var point = new PointStruct
{
Id = document.PointId,
Vectors = vector.ToArray(),
Payload =
{
["document_id"] = document.DocumentId,
["chunk_id"] = "whole-document",
["title"] = document.Title,
["text"] = document.Text
}
};
await client.UpsertAsync(
collectionName: collectionName,
points: [point],
wait: true,
cancellationToken: token);
var stored = await client.RetrieveAsync(
collectionName: collectionName,
id: document.PointId,
withPayload: true,
withVectors: false,
cancellationToken: token);
if (stored.Count != 1 ||
!stored[0].Payload.TryGetValue("text", out Value? storedText) ||
storedText.StringValue != document.Text ||
!stored[0].Payload.TryGetValue("title", out Value? storedTitle))
{
throw new InvalidOperationException(
$"Read-back failed for '{document.DocumentId}'.");
}
Console.WriteLine($"Indexed '{document.DocumentId}' into '{collectionName}'.");
Console.WriteLine($"Point ID: {document.PointId}");
Console.WriteLine($"Title: {storedTitle.StringValue}");
Console.WriteLine($"Text: {storedText.StringValue}");
}
public sealed record SourceDocument(
Guid PointId,
string DocumentId,
string Title,
string Text);
The program checks the existing collection before making an embedding request. A missing collection fails at GetCollectionInfoAsync. Create it with the setup program first. A wrong size, distance, or declared profile ID stops the write. Matching that ID does not prove which model artifact generated a vector. As in the collection article, these checks cover the simple unnamed dense-vector setup, not every schema option Qdrant supports.
GenerateVectorAsync returns ReadOnlyMemory<float>. The official Qdrant client accepts the array assigned to Vectors as the unnamed vector. Each point also carries the source text because an embedding cannot reconstruct the document when we need to display it later.
The upsert explicitly uses wait: true, so the read-back follows a completed update rather than merely an acknowledged request. This verifies that the point can be retrieved by ID and its text payload matches the source. The code also checks that the title field exists before printing it. It does not read the vector back or validate the other payload values. The check says nothing about search quality and does not require an HNSW index for this tiny collection.
The two-minute timeout bounds this small console run, including model loading for the local path. It is a sample setting, not a recommended ingestion deadline. The code passes the same cancellation token through the embedding and database calls.
Run it, then change the text
Run the program:
dotnet run
For the Azure OpenAI setup, expected output is:
Indexed 'worker-shutdown' into 'support-documents-v1'.
Point ID: 19e8e940-0562-4b6b-bca5-1f99ea154f2e
Title: Stop a background service cleanly
Text: Pass the stopping token to asynchronous I/O in a BackgroundService. When shutdown starts, let the operation observe cancellation and finish within the host shutdown timeout.
The Ollama setup prints support-documents-embeddinggemma-v1 instead. Open the local Qdrant dashboard, select that collection, and inspect the point’s payload.
Run the program again with the same input. Qdrant replaces the point with that ID, so the collection still has one point if it was empty before this exercise. Now change the note’s text and run it once more. The program generates a new vector and replaces the point’s vector and payload together.
Keep the UUID unchanged during that edit. Calling Guid.NewGuid() inside the indexing loop would turn each run into another point. In a real source system, persist the assigned UUID or derive point IDs reproducibly from stable source and chunk identities.
An upsert replaces the existing point, including its payload. Send the complete payload you want to retain. This sample does not use a partial payload update. Also, the repeat run still calls the embedding model. Stable IDs prevent duplicate points, but they do not skip embedding costs or implement change detection.
Add two more documents
Replace the documents array with this version. The loop stays unchanged:
SourceDocument[] documents =
[
new(
Guid.Parse("19e8e940-0562-4b6b-bca5-1f99ea154f2e"),
"worker-shutdown",
"Stop a background service cleanly",
"Pass the stopping token to asynchronous I/O in a BackgroundService. " +
"When shutdown starts, let the operation observe cancellation " +
"and finish within the host shutdown timeout."),
new(
Guid.Parse("81852b6d-d848-4a62-a777-e45b75f56755"),
"http-client-lifetime",
"Create HTTP clients through the factory",
"Use IHttpClientFactory to create named HTTP clients. " +
"The factory manages handler lifetimes so callers can dispose " +
"their clients without creating a new connection pool each time."),
new(
Guid.Parse("fcad478c-cdae-428e-8784-c07d7ec9b8c3"),
"bounded-work-queue",
"Limit queued background work",
"Use a bounded Channel to limit queued work. With FullMode set " +
"to Wait, producers await available capacity instead of letting " +
"an in-memory backlog grow without a limit.")
];
After a successful run, the collection contains these three points, plus any unrelated points already present. Repeating the run preserves the same three IDs.
Each pass through the loop embeds one note, writes it, and checks the stored text. If the second note fails, the first point remains in Qdrant. There is no transaction around the whole array. For a larger job, I would handle batching and recovery explicitly.
When to use this indexing path
Use this example to populate a local collection with a few short documents and verify that the application can write and retrieve their payloads. It also leaves a small corpus for the next step: embedding a question and searching the chosen collection.
Do not use the loop unchanged for long files or continuous source synchronization. Long documents need chunking and input-size checks. A changing corpus needs a policy for updates, removed documents, and interrupted runs. Removing an item from this array does not delete its existing Qdrant point.
When you add search, use the same embedding profile that produced these points. That includes the model identity, dimensions, distance, and the document/query formatting defined by the profile. Azure OpenAI queries stay with plain text. EmbeddingGemma queries need its retrieval query prefix. Then try a question whose answer you know is in one of the notes. Reading a point by ID cannot tell you whether semantic search will find it.
Related reading
Sources
- Qdrant: Points, IDs, and updates
- Qdrant: .NET client
- Microsoft Learn: Use the IEmbeddingGenerator interface
- Ollama: Embeddings
- Google: EmbeddingGemma model card and input formats