Table of Contents
This example turns a question into an embedding, applies a payload filter, and asks Qdrant for up to three closest eligible points. It then prints the stored text and score for each result.
The code continues the same QdrantCollections console project and three support notes from Indexing Your First Documents. It reuses the collection, embedding profile, point IDs, and payload fields from that example. This time, the program reads instead of writing.
Continue from the indexed collection
Start the Qdrant container from Your First Qdrant Collection if it is not already running:
docker start -a qdrant-local
Keep it running and use another terminal for the .NET application. The collection should contain the three points added at the end of the indexing article:
| Document ID | Title |
|---|---|
worker-shutdown | Stop a background service cleanly |
http-client-lifetime | Create HTTP clients through the factory |
bounded-work-queue | Limit queued background work |
If the collection is empty, run the indexing program first. This query example does not create the collection or add points.
Continue with the same embedding setup that produced the stored vectors:
| Setting | Azure OpenAI | Ollama locally |
|---|---|---|
| Model | text-embedding-3-small | embeddinggemma:300m |
| Dimensions | 1,536 | 768 |
| Collection | support-documents-v1 | support-documents-embeddinggemma-v1 |
| Profile ID | support-text-v1 | support-embeddinggemma-v1 |
| Query format | Plain question text | task: search result | query: ... |
The query vector and stored document vectors must belong to the same embedding space. A 768-dimensional EmbeddingGemma query cannot search the 1,536-dimensional Azure OpenAI collection. Matching dimensions would not be enough either. Two models can return the same number of values while producing incompatible vectors.
Configure the query embedding
Replace Program.cs with one of the following setup blocks. Then append the shared query code from the next section.
Azure OpenAI
Use this block if you indexed the notes into support-documents-v1. It reuses the packages, deployment, and environment variables from the indexing article.
using Microsoft.Extensions.AI;
using Azure.AI.OpenAI;
using System.ClientModel;
using Qdrant.Client;
using Qdrant.Client.Grpc;
using static Qdrant.Client.Grpc.Conditions;
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<string, string> prepareQuery = query => query;
The query is embedded as plain text because the documents were embedded as plain text. Keep the deployment on the same text-embedding-3-small model and compatible model version used during indexing.
Ollama with EmbeddingGemma
Use this block if you indexed the notes into support-documents-embeddinggemma-v1:
using Microsoft.Extensions.AI;
using OllamaSharp;
using Qdrant.Client;
using Qdrant.Client.Grpc;
using static Qdrant.Client.Grpc.Conditions;
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<string, string> prepareQuery = query =>
$"task: search result | query: {query}";
The indexing example formatted EmbeddingGemma documents as title: ... | text: .... Retrieval queries use the corresponding task: search result | query: ... format from the model card. This is not decorative prompt text. It is part of the embedding profile and affects the vector the model produces.
Embed the question and query Qdrant
Append this shared code after the chosen setup block:
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 use the expected " +
$"unnamed {vectorSize}-dimension cosine vector.");
}
if (!collection.Config.Metadata.TryGetValue(
"embedding-profile-id", out Value? storedProfile) ||
storedProfile.StringValue != embeddingProfileId)
{
throw new InvalidOperationException(
$"Collection '{collectionName}' does not match profile '{embeddingProfileId}'.");
}
const string question =
"How do I stop a background service without abandoning I/O?";
ReadOnlyMemory<float> queryVector = await generator.GenerateVectorAsync(
prepareQuery(question), cancellationToken: token);
if (queryVector.Length != vectorSize)
{
throw new InvalidOperationException(
$"Expected {vectorSize} values, received {queryVector.Length}.");
}
string[] eligibleDocumentIds =
["worker-shutdown", "bounded-work-queue"];
Filter eligibilityFilter = Match("document_id", eligibleDocumentIds);
IReadOnlyList<ScoredPoint> results = await client.QueryAsync(
collectionName: collectionName,
query: queryVector.ToArray(),
filter: eligibilityFilter,
limit: 3,
payloadSelector: new[] { "document_id", "title", "text" },
vectorsSelector: false,
cancellationToken: token);
Console.WriteLine($"Question: {question}");
foreach (ScoredPoint result in results)
{
if (!result.Payload.TryGetValue(
"document_id", out Value? documentId) ||
!result.Payload.TryGetValue("title", out Value? title) ||
!result.Payload.TryGetValue("text", out Value? text))
{
throw new InvalidOperationException(
$"Point '{result.Id}' is missing the expected payload.");
}
Console.WriteLine();
Console.WriteLine($"Score: {result.Score:F4}");
Console.WriteLine($"Document: {documentId.StringValue}");
Console.WriteLine($"Title: {title.StringValue}");
Console.WriteLine($"Text: {text.StringValue}");
}
Run the program:
dotnet run
worker-shutdown is expected to rank first for this sample. I have not hardcoded the scores because they can differ between embedding providers and model versions. With the current filter, http-client-lifetime cannot appear even if its vector is close to the query.
The metadata check above has a deliberate limit. The profile ID says which embedding profile the collection is supposed to contain. It does not verify the model artifact that produced the stored vectors. That distinction matters for mutable model tags such as embeddinggemma:300m.
What the query does
The read path is short:
- Validate the expected unnamed vector size and distance, plus the declared embedding profile.
- Prepare the question according to that profile.
- Generate one query vector.
- Apply the payload eligibility constraint during vector search.
- Return the highest-scoring eligible points and the payload fields needed by the caller.
The call uses Qdrant’s QueryAsync API. Passing the float array as query requests nearest-neighbor search against the collection’s unnamed dense vector. The collection already defines cosine distance, so the request does not choose another metric.
payloadSelector returns only document_id, title, and text. vectorsSelector: false makes it explicit that the application does not want the stored 768- or 1,536-value vectors returned. Qdrant query responses already omit vectors by default, so the argument documents the intended response shape. A retrieval result normally needs a stable source identity and displayable content, not another copy of the indexed vector.
The payload guard in the loop checks that those three fields exist. It does not validate their Qdrant value types. That is enough for this controlled sample, where the indexing code owns the payload. If several writers can produce points, validate the payload schema at that boundary instead of assuming every stored value is a string.
The result count is an upper bound. limit: 3 does not guarantee three points. Only two of the sample documents pass the allowlist, so at most two can be returned.
Filters decide eligibility and vectors decide order
I keep the allowlist in the example instead of hiding it behind a helper method:
string[] eligibleDocumentIds =
["worker-shutdown", "bounded-work-queue"];
Filter eligibilityFilter = Match("document_id", eligibleDocumentIds);
In an application, that list would come from trusted policy and application state rather than raw user input. The same boundary can represent tenant membership, permissions, language, publication state, or document type.
Only points that satisfy the filter are eligible for the returned results. Qdrant integrates that constraint into its query planning and search instead of retrieving an unfiltered top-k and removing points afterward. With HNSW, Qdrant can use filter-aware graph traversal rather than building a filtered set and starting a separate vector search.
Filtering after retrieval is a different design. It can leave the caller with too few allowed results because ineligible points have already occupied places in the unfiltered top-k. It also puts a mandatory policy check outside the retrieval request.
This local collection has three points and no payload index, which is enough to demonstrate the request shape. For a larger collection, create payload indexes for fields used frequently in filters. A keyword index on document_id supports efficient exact matching and gives Qdrant better information for query planning.
Create those indexes before bulk ingestion when you already know the filter contract. Payload indexes also inform Qdrant’s filter-aware vector indexing, so defining them before the HNSW index is built avoids rebuilding that structure later. Qdrant Cloud also enables strict mode by default for new collections and rejects retrieval filters on unindexed fields.
The allowlist is an example, not a complete authorization system. The application still owns how it derives the eligible IDs and whether the caller may see the returned payload.
A score is not a relevance decision
ScoredPoint.Score lets you compare the returned points for this query. With the cosine collection used here, a higher score ranks ahead of a lower score. It does not prove that the first document answers the question.
Avoid copying a threshold from another project. For this dense-vector stage, its useful range depends on the embedding model, corpus, query distribution, and distance metric. Calibrate it with labeled queries and expected results, including cases where the correct outcome is no result.
Thresholds are stage-specific. A value calibrated for dense cosine search does not carry over to hybrid-fusion or reranker scores. Those stages produce different score spaces.
The three-note corpus is useful for checking the mechanics, not for measuring retrieval quality. Try at least these changes before building context for a model:
- Rephrase the shutdown question and confirm that
worker-shutdownremains near the top. - Ask about HTTP connection reuse, then add
http-client-lifetimeto the eligible IDs and inspect its rank. - Remove the relevant document from the allowlist and confirm that it does not appear.
- Ask an unrelated question and observe that Qdrant still returns the nearest eligible points unless a calibrated acceptance rule rejects them.
That last case matters. A vector search asks which eligible stored vectors are closest under the configured metric. Depending on the collection and search path, the returned points may be approximate nearest neighbors. This query has no acceptance criterion, so it cannot determine whether any result is relevant enough to use. Even with a calibrated score threshold, the cutoff remains an application decision rather than proof of relevance.
When to use this query path
Use this example when the application already has a small Qdrant collection populated with one compatible embedding profile and needs a direct dense-vector query with mandatory payload filtering. It is also a good boundary to wrap behind an application retrieval interface later.
Do not treat the code as a complete RAG pipeline. It does not chunk long documents, deduplicate overlapping results, group chunks by source, rerank candidates, calibrate a relevance threshold, or assemble a context budget for a model. It also does not create the payload indexes needed for a larger filtered workload.
The next implementation step is to replace the whole-document points with deliberate chunks and see how those boundaries change what the query can retrieve. After that, the ranked chunks still need to be selected, cited, and fitted into model context.
Related reading
- Indexing Your First Documents
- Your First Qdrant Collection
- Keep vector search filters separate from semantic ranking
- Stop RAG Hallucinations with the Short-Circuit Pattern
Sources
- Qdrant: Similarity search and the Query API
- Qdrant: Filtering
- Qdrant: Payload indexing
- Qdrant: Fast approximate search with HNSW
- Qdrant: Configure a Cloud cluster
- Qdrant: .NET client
- Microsoft Learn: Use the
IEmbeddingGeneratorinterface - Google: EmbeddingGemma model card and input formats