In Embeddings in .NET, I stopped at the vector-store boundary. The article generated embeddings and treated the model, dimensions, distance, and input preparation as one contract. It did not create an index for those vectors.

This article picks up there with a smaller goal: run Qdrant locally, connect from .NET, create one collection, and verify the parts of its configuration that the application depends on. Nothing gets indexed yet.

What a collection owns

A Qdrant collection is the container for points that you want to search together. Each point can hold one or more vector representations and an optional payload. This article uses one unnamed dense vector. Later, the payload can carry application data such as the source ID, text, language, or tenant ID.

This first example will not write any points. It only creates the collection that will receive them later.

For one unnamed dense vector, the collection schema needs two decisions:

  • vector size
  • distance function

Both come from the embedding contract. The example uses 1,536 dimensions and cosine distance because that matches the default text-embedding-3-small profile from the previous article. If your model returns 768 or 3,072 values, use that number instead. If the model documentation recommends a different distance function, follow it.

Matching the vector length is necessary, but it does not prove that two vectors are compatible. Qdrant can reject a vector with the wrong length. It cannot tell whether a correctly sized vector came from the wrong embedding model.

Run Qdrant locally

The example uses Qdrant 1.19.0 in Docker. Create a named volume so the collection remains available after the container stops:

docker volume create qdrant_storage

docker run --name qdrant-local \
  -p 127.0.0.1:6333:6333 \
  -p 127.0.0.1:6334:6334 \
  -v qdrant_storage:/qdrant/storage \
  qdrant/qdrant:v1.19.0

Qdrant exposes its HTTP API and dashboard on port 6333. The gRPC API listens on 6334, which is the port used by the .NET client below. Both ports are bound to 127.0.0.1, so they are available only from the Docker host. Without that address, Docker publishes ports on every host interface by default.

Leave that terminal open and use a second one for the .NET commands. Open http://127.0.0.1:6333/dashboard to confirm that the server is available. With a newly created, empty volume, the Collections page should initially be empty. docker volume create reuses an existing volume with the same name, so old collections can reappear.

When you stop the container, start it again with:

docker start -a qdrant-local

This setup is for local development. It has no API key, TLS configuration, backups, or production-grade network controls. Do not expose it to an untrusted network.

Create the .NET project

Create a console application and add the official Qdrant client:

dotnet new console --framework net10.0 --name QdrantCollections
cd QdrantCollections
dotnet add package Qdrant.Client --version 1.19.0

I compiled the following sample with .NET 10 and Qdrant.Client 1.19.0. The client uses gRPC for its high-level API, so it connects to port 6334 rather than the dashboard port.

Replace Program.cs with:

using Qdrant.Client;
using Qdrant.Client.Grpc;

const string collectionName = "support-documents-v1";
const string embeddingProfileId = "support-text-v1";
const ulong vectorSize = 1536;

using var executionTimeout =
    new CancellationTokenSource(TimeSpan.FromSeconds(10));
using var client = new QdrantClient(host: "127.0.0.1", port: 6334);

if (!await client.CollectionExistsAsync(collectionName, executionTimeout.Token))
{
    await client.CreateCollectionAsync(
        collectionName: collectionName,
        vectorsConfig: new VectorParams
        {
            Size = vectorSize,
            Distance = Distance.Cosine
        },
        metadata: new()
        {
            ["embedding-profile-id"] = embeddingProfileId
        },
        cancellationToken: executionTimeout.Token);
}

CollectionInfo collection = await client.GetCollectionInfoAsync(
    collectionName,
    executionTimeout.Token);

VectorsConfig? vectorsConfig = collection.Config.Params.VectorsConfig;

if (vectorsConfig is null ||
    vectorsConfig.ConfigCase != VectorsConfig.ConfigOneofCase.Params)
{
    throw new InvalidOperationException(
        $"Collection '{collectionName}' does not use an unnamed " +
        "dense-vector configuration.");
}

VectorParams vectors = vectorsConfig.Params;

if (vectors.Size != vectorSize || vectors.Distance != Distance.Cosine)
{
    throw new InvalidOperationException(
        $"Collection '{collectionName}' does not match the expected " +
        $"{vectorSize}-dimension cosine configuration.");
}

if (!collection.Config.Metadata.TryGetValue(
        "embedding-profile-id",
        out Value? storedProfile) ||
    storedProfile.StringValue != embeddingProfileId)
{
    throw new InvalidOperationException(
        $"Collection '{collectionName}' does not declare the expected " +
        $"embedding profile '{embeddingProfileId}'.");
}

Console.WriteLine(
    $"Collection '{collectionName}' is {collection.Status} with " +
    $"{vectors.Size} dimensions, {vectors.Distance} distance, and " +
    $"embedding profile '{embeddingProfileId}'.");

Run it while the Qdrant container is available:

dotnet run

The output should look similar to this:

Collection 'support-documents-v1' is Green with 1536 dimensions, Cosine distance, and embedding profile 'support-text-v1'.

Refresh the Qdrant dashboard and the collection should appear there too.

Creating once is not enough

The existence check makes the sample convenient to run more than once. It does not assume that an existing collection is correct. This shorter version would be unsafe:

if (await client.CollectionExistsAsync(collectionName))
{
    return;
}

An older collection might have been created with 768 dimensions, dot-product distance, or a different embedding-profile ID. Returning early would allow the application to start against the wrong collection contract.

The sample reads the stored configuration back. It checks that the dense-vector configuration is unnamed and uses the expected size and distance. It also checks the application-defined embedding-profile ID stored in the collection metadata.

Qdrant does not infer which embedding model or preprocessing rules produced a vector. Collection metadata can hold an application-defined profile ID, but the application still owns what that ID means. Here, support-text-v1 refers to OpenAI text-embedding-3-small, 1,536 dimensions, cosine distance, and plain-text-v1 preprocessing.

The validation remains deliberately narrow. It does not reject additional sparse vectors, a multivector configuration, a non-default vector datatype, or every storage setting Qdrant supports. Add checks for those properties when application behavior depends on them. The current checks cover the unnamed dense-vector variant, size, distance, and profile ID.

Two application instances can also observe a missing collection and race to create it. That is acceptable for this local console app. For a deployed system, I would provision collections through a controlled deployment or migration step instead of letting every application instance own schema creation.

Keep the first collection boring

Qdrant exposes settings for shards, replication, HNSW, quantization, on-disk storage, named vectors, sparse vectors, and more. None of them is needed to learn the collection boundary.

The sample deliberately keeps Qdrant’s defaults and creates:

  • one collection
  • one unnamed dense vector
  • one vector size
  • one distance function

For this local exercise, the defaults are sufficient. In production, replication and sharding follow availability, scale, and isolation requirements. HNSW and quantization choices should come from measurements against a representative workload.

The collection name includes v1 because its vectors belong to a particular embedding space. If a later profile uses an incompatible vector space, a new collection such as support-documents-v2 gives the migration an explicit target. It also avoids the temptation to delete and recreate a populated collection during application startup.

Do not replace this with RecreateCollectionAsync as a general startup shortcut. Recreating a collection deletes the existing collection and its points. That can be handy in a disposable test, but it is the wrong default once the data matters.

Before indexing the first point

The application has verified the dense vector’s size and distance and the collection’s embedding-profile ID. It has not decided how documents become stable point IDs, which payload fields to store, or where the original text lives. Those choices belong to the indexing path.

Before adding that path, write down four answers:

  • support-text-v1 owns this collection and identifies the embedding contract defined above.
  • support-documents-v1 is the collection name and migration target, not the embedding profile.
  • In a deployed system, collection creation belongs to a controlled provisioning or migration step.
  • Original text and payload design are intentionally undecided in this article.

Qdrant is a reasonable fit when a dedicated vector database suits the workload and the application can own another data boundary. If the source data already lives in PostgreSQL and the retrieval workload is modest, keeping vectors beside that data with pgvector may be simpler. RAG with EF Core and pgvector covers that route.

The next implementation step is to define stable point IDs and payloads, then write the first documents. HNSW and quantization can stay unchanged until retrieval measurements give a reason to revisit them.

Sources