Back to Embedding Research

Data Exploration with Qdrant's Distance Matrix API

Andrey Vasnetsov

·

March 11, 2025

Data Exploration with Qdrant's Distance Matrix API

Hidden Structure

When working with large collections of documents, images, or other arrays of unstructured data, it often becomes useful to understand the big picture. Examining data points individually is not always the best way to grasp the structure of the data.

A table of data points with no visible structure

Datapoints without context, pretty much useless

As numbers in a table obtain meaning when plotted on a graph, visualizing distances (similar/dissimilar) between unstructured data items can reveal hidden structures and patterns.

The same data points plotted on a chart, forming visible groups

Visualized chart, very intuitive

There are many tools to investigate data similarity, and Qdrant’s 1.12 release made it much easier to start this investigation. With the new Distance Matrix API, Qdrant handles the most computationally expensive part of the process: calculating the distances between data points.

In many implementations, the distance matrix calculation was part of the clustering or visualization processes, requiring either brute-force computation or building a temporary index. With Qdrant, however, the data is already indexed, so the server can use that index to find the nearest neighbors of each sampled point.

In this article, we will use the Distance Matrix API for dimensionality reduction, clustering, and graph exploration.

Dimensionality Reduction

Initially, we might want to visualize an entire dataset, or at least a large portion of it, at a glance. However, high-dimensional data cannot be directly visualized. We must apply dimensionality reduction techniques to convert data into a lower-dimensional representation while preserving important data properties.

We will use UMAP as our dimensionality reduction algorithm.

Here is a very simplified but intuitive explanation of UMAP: it finds which points are close to each other in the high-dimensional space, then places them on a 2D plane so that the same points stay close.

UMAP of the Fashion-MNIST dataset, with each clothing category forming its own region
UMAP on the Fashion-MNIST dataset, recreated from the example in the UMAP documentation, source

UMAP emphasizes neighborhood structure. Distances between groups and their apparent sizes can be distorted, so interpret them cautiously. It only needs the nearest neighbors of each point, which is what the sparse matrix from Qdrant holds.

Let’s use Qdrant to calculate the distance matrix and apply UMAP. We will use one of the default datasets perfect for experimenting in Qdrant: Midjourney Styles dataset.

This example uses Qdrant 1.16.1, which supports the Midlib snapshot’s storage format. Start a local instance:

docker run --rm -p 6333:6333 qdrant/qdrant:v1.16.1

Download and import the dataset into Qdrant:

PUT /collections/midlib/snapshots/recover
{
  "location": "http://snapshots.qdrant.io/midlib.snapshot"
}
We also need to prepare our python environment:
pip install umap-learn seaborn matplotlib qdrant-client==1.16.1

Import the necessary libraries:

# Used to talk to Qdrant
from qdrant_client import QdrantClient
# Package with original UMAP implementation
from umap import UMAP
# Python implementation for sparse matrices
from scipy.sparse import csr_matrix
# For visualization
import seaborn as sns

Establish connection to Qdrant:

client = QdrantClient("http://localhost:6333")

After this is done, we can request the matrix. For each of the 1000 sampled points, Qdrant returns the scores of its 20 closest points within the sample, which makes a sparse 1000 by 1000 matrix. The Midlib collection uses the Cosine metric, so the scores are similarities, where a larger score means a closer point.

# Request the matrix from Qdrant.
# The `_offsets` suffix defines the format of the output matrix.
result = client.search_matrix_offsets(
  collection_name="midlib",
  # Select a subset of the data, as the whole dataset might be too large
  sample=1000,
  # For performance reasons, limit the number of closest neighbors to consider
  limit=20,
)

# Convert the matrix to a python-native format.
# An explicit shape keeps the matrix square.
n = len(result.ids)
similarity = csr_matrix(
    (result.scores, (result.offsets_row, result.offsets_col)),
    shape=(n, n),
)

# Make the matrix symmetric, as UMAP expects it.
# A pair often appears in one direction only.
# The maximum fills in the missing direction.
similarity = similarity.maximum(similarity.T)

# UMAP expects distances, where a smaller value means a closer point.
# For the Cosine metric, the distance is 1 - similarity.
distances = similarity.copy()
distances.data = 1 - distances.data

With the Euclid metric, the scores are already distances and the last step is not needed.

Now we can apply UMAP to the distances:

umap = UMAP(
    # We provide a ready-made distance matrix
    metric="precomputed",
    # Output dimension
    n_components=2,
    # Same as the limit in the search_matrix_offsets
    n_neighbors=20,
)

vectors_2d = umap.fit_transform(distances)

That’s all that is needed to get the 2d representation of the data.

UMAP map of 1000 Midlib items, colored by ten KMeans clusters
UMAP determines the positions of 1000 sampled Midlib items. KMeans colors them by clusters found in the similarity rows.

UMAP isn’t the only algorithm compatible with our distance matrix API. For example, scikit-learn also offers the following, each with its own expectations about the matrix:

  • Isomap - Non-linear dimensionality reduction through Isometric Mapping. Use metric="precomputed" with distances. It needs n_neighbors smaller than limit, such as 19.
  • SpectralEmbedding - Forms an affinity matrix given by the specified function and applies spectral decomposition to the corresponding graph Laplacian. Use affinity="precomputed" with similarity, as an affinity is larger for closer points.
  • TSNE - well-known algorithm for dimensionality reduction. Use metric="precomputed" with distances, init="random", and a perplexity of at most (limit - 2) / 3, which is 6 for limit=20.

Clustering

Another approach to data structure understanding is clustering, which groups similar items.

Note that there’s no universally best clustering criterion or algorithm.

Six clustering algorithms applied to six two-dimensional datasets
Six of the clustering algorithms compared in the scikit-learn example, recreated on the same toy datasets. The full example compares eleven. Each color represents a cluster; yellow points represent noise.

Let’s consider a simple example of clustering the Midlib dataset with the KMeans algorithm.

KMeans does not take a distance matrix. Its fit() method takes a table of features with shape (n_samples, n_features), and a sparse matrix is accepted. We pass similarity as that table. Each row contains scores for neighbors selected in either direction, with zeros where neither point selected the other.

This is a different representation of the data, not the original 512-dimensional embeddings. KMeans groups the points that have similar neighbors, so the clusters can differ from the ones KMeans would find in the embeddings themselves, and a cluster center is a point in the similarity space, not a vector from the collection.

We use similarity rather than distances because a missing entry reads as zero, which means “not similar” in the first matrix and “identical” in the second.

from sklearn.cluster import KMeans

# Initialize KMeans with 10 clusters
kmeans = KMeans(n_clusters=10)

# Generate index of the cluster each sample belongs to
cluster_labels = kmeans.fit_predict(similarity)

With this simple code, we have clustered the data into 10 clusters, while Qdrant did the neighbor search. Algorithms that work on the neighbor graph directly, such as SpectralClustering with affinity="precomputed", accept similarity as is.

The Midlib map in the Dimensionality Reduction section shows these clusters on the UMAP layout. Select a cluster to highlight it.

How to plot this chart
sns.scatterplot(
    # Coordinates obtained from UMAP
    x=vectors_2d[:, 0], y=vectors_2d[:, 1],
    # Color datapoints by cluster
    hue=cluster_labels,
    palette=sns.color_palette("pastel", 10),
    legend="full",
)

Graphs

Clustering and dimensionality reduction both aim to provide a more transparent overview of the data. However, they share a common characteristic: they require a training step before the results can be visualized.

This also means that new data points require re-running the training step, which can be expensive.

Graphs are an alternative approach to data exploration: they show the relationships between data points directly and interactively. In a graph representation, each data point is a node, and similarities between data points are represented as edges connecting the nodes.

Such a graph can be rendered in real-time using force-directed layout algorithms, which aim to minimize the system’s energy by repositioning nodes dynamically. The more similar the data points are, the stronger the edges between them.

Adding new data points to the graph is as straightforward as inserting new nodes and edges without the need to re-run any training steps.

In practice, rendering a graph for an entire dataset at once may be computationally expensive and overwhelming for the user. Therefore, let’s explore a few strategies to address this issue.

Expanding from a single node

This is the simplest approach, where we start with a single node and expand the graph by adding the most similar nodes to the graph.

Graph

Graph representation of the data

Sampling from a collection

Expanding a single node works well if you want to explore neighbors of a single point, but what if you want to explore the whole dataset? If your dataset is small enough, you can render relations for all the data points at once. But it is a rare case in practice.

Instead, we can sample a subset of the data and render the graph for this subset. This way, we can get a good overview of the data without overwhelming the user with too much information.

Let’s try to do so in Qdrant’s Graph Exploration Tool:

{
  "limit": 5, # node neighbors to consider
  "sample": 100 # nodes
}
A sampled graph of the collection, with points linked to their nearest neighbors

Graph representation of the data (Qdrant’s Graph Exploration Tool)

This graph captures some high-level structure of the data, but as you might have noticed, it is quite noisy. This is because the differences in similarities are relatively small, and they might be overwhelmed by the stretches and compressions of the force-directed layout algorithm.

To make the graph more readable, let’s concentrate on the most important similarities and build a so-called Minimum/Maximum Spanning Tree.

{
  "limit": 5,
  "sample": 100,
  "tree": true
}
A spanning tree of the sampled graph, with one path connecting all points

Spanning tree of the graph (Qdrant’s Graph Exploration Tool)

This algorithm will only keep the most important edges and remove the rest while keeping the graph connected. By doing so, we can reveal clusters of the data and the most important relations between them.

In some sense, this is similar to hierarchical clustering, but with the ability to interactively explore the data. Another analogy might be a dynamically constructed mind map.

Conclusion

Vector similarity goes beyond looking up the nearest neighbors. It also gives you a tool for data exploration. Many algorithms can construct human-readable data representations, and Qdrant supplies the neighbor data they need.

The Qdrant Web UI includes data exploration tools (Visualization and Graph Exploration Tools), and for more advanced use cases, you can call the Distance Matrix API directly.

The same distances also help to find mislabeled and out-of-place items in a categorized dataset, as shown in Detecting Dataset Errors with Similarity Search.

Try it on your own collection and see which groups show up.

Was this page useful?

Thank you for your feedback! 🙏

We are sorry to hear that. 😔 You can edit this page on GitHub, or create a GitHub issue.