Glossary · Single-Cell and Spatial
kNN graph (shared nearest neighbor)
The graph you build before clustering decides what counts as similar, and no amount of resolution tweaking downstream fixes a k or PC count that's wrong upstream.
By Ming "Tommy" Tang, Director of Bioinformatics in Big Pharma · Reviewed September 2026 · 3 min read
Also: SNN graph, FindNeighbors
Definition
A kNN graph connects each cell to its k closest neighbors, usually measured by Euclidean distance in PCA-reduced space rather than raw gene expression. A shared nearest neighbor (SNN) graph is built on top of that kNN graph: two cells get an edge only if they share neighbors, and the edge is weighted by the Jaccard index of their overlapping neighborhoods. Seurat's FindNeighbors() builds both by default (k.param = 20, prune.SNN = 1/15), then hands the SNN graph to FindClusters() for modularity-based community detection. Scanpy's sc.pp.neighbors() does the equivalent, feeding a neighbor graph into Leiden clustering.
You hit this the moment you run FindNeighbors() in Seurat or sc.pp.neighbors() in Scanpy, right after PCA and right before clustering. It is the step nobody thinks about because the defaults just work, until they don't: a rare population disappears, or one cluster splits into three for no biological reason, and the actual cause sits upstream in the graph, not in the resolution parameter you keep re-running.
The graph is also where your PCA choices and your clustering choices meet. How many PCs you kept and what k you used to search for neighbors both shape the structure that Louvain or Leiden then cuts into communities. Get the graph wrong and no amount of resolution tuning downstream will fix it.
Why it matters
A rare population, say 40 cells out of 10,000, needs a k small enough that its neighbor search doesn't get swamped by the surrounding majority type. Push k too high (low variance, high bias) and those 40 cells pick up edges to neighboring but distinct cell types, smearing the population into a bigger cluster or erasing it from the UMAP entirely. Push k too low (low bias, high variance) for a large, dense population and you get spurious fragmentation, clusters that split on noise rather than biology, and you end up hand-tuning resolution to compensate for a problem that actually lives in the graph.
The kNN versus SNN choice matters too. Because SNN only connects cells that share neighbors, and Seurat prunes edges at or below a Jaccard index of 1/15, the SNN graph is sparser and more robust to a single noisy nearest-neighbor call than the raw kNN graph. That's why Seurat clusters on the SNN graph by default (compute.SNN = TRUE) instead of the unweighted kNN graph.
Where people get it wrong
The common mistake is treating k.param and resolution as interchangeable knobs. They are not: k.param, together with the number of PCs feeding it, decides what "similar" even means before any community detection happens; resolution only decides how aggressively to cut an already-fixed graph into pieces. If clustering looks wrong, check the graph first by re-running FindNeighbors() with a different k or PC count, before touching FindClusters(resolution = ...).
A quieter confusion is calling the SNN graph "the kNN graph" interchangeably. They're different objects: unweighted kNN edges are just 1s, SNN edges are Jaccard-weighted, and some kNN edges get pruned away entirely once you require shared neighbors. That means the two graphs can produce different cluster boundaries from the same underlying neighbor search, so knowing which one a plot or a downstream function is actually using matters.
A concrete example
Building the kNN and SNN graph on a PBMC dataset after PCA, using Seurat's defaults, then checking how a smaller k.param changes the neighbor search before touching resolution at all.
library(Seurat)
# after RunPCA(), using the first 15 PCs
pbmc <- FindNeighbors(pbmc, dims = 1:15, k.param = 20)
# k.param = 20 -> each cell linked to its 20 nearest neighbors in PC space
# compute.SNN = TRUE by default: also builds the Jaccard-weighted SNN graph
# prune.SNN = 1/15: drops SNN edges with Jaccard index <= 0.067
pbmc <- FindClusters(pbmc, resolution = 0.5)
# before touching resolution, test whether a rare population survives
# a smaller neighborhood
pbmc_k10 <- FindNeighbors(pbmc, dims = 1:15, k.param = 10)
pbmc_k10 <- FindClusters(pbmc_k10, resolution = 0.5)Related terms
Questions people ask
- What's the difference between a kNN graph and an SNN graph?
The kNN graph links each cell to its k closest neighbors in PCA space, using Euclidean distance, and every edge is unweighted (a 1 or nothing). The SNN graph is built on top of it: two cells get an edge only if they share neighbors, and that edge is weighted by the Jaccard index of the overlap. Seurat computes both by default and clusters on the SNN graph, not the raw kNN graph.
- What does k.param actually control in FindNeighbors?
k.param sets how many nearest neighbors each cell searches for when the kNN graph is built; Seurat's default is 20. It directly shapes what the downstream SNN graph and clustering see as 'similar', so it's a bigger lever on your final clusters than most people treat it as.
- Should I tune k.param or resolution first when clustering looks wrong?
Check the graph first. Resolution only decides how aggressively FindClusters cuts an already-fixed graph, so if a rare population is missing or a cluster is fragmenting for no biological reason, re-run FindNeighbors with a different k.param or PC count before touching resolution.
- What k value should I use for rare cell types?
There's no universal number in the documentation, small k is lower bias but higher variance and risks connecting a rare population's handful of cells to unrelated neighbors, while large k is lower variance but higher bias and can smear a rare population into a bigger cluster. Test a couple of k values around the default and check whether the population you care about survives as a distinct cluster.
- Does Scanpy's sc.pp.neighbors do the same thing as Seurat's FindNeighbors?
Functionally yes: both search for nearest neighbors in PCA-reduced space and hand a graph to a community-detection step (Leiden in Scanpy, Louvain/SLM/Leiden in Seurat). Scanpy uses brute-force search for small datasets and can swap in Annoy or PyNNDescent for larger ones, and different implementations have been shown to give qualitatively similar downstream clustering and UMAP results.
Related pages
- Guide · How to Detect Batch Effects in Single-Cell ATAC-seq
- Guide · How to Detect Integration Over-Correction in Single-Cell RNA-seq
- Guide · How to Choose a Normalization Method in Single-Cell RNA-seq
- Guide · How to Tell If You Overclustered in Single-Cell ATAC-seq
- Guide · Why Your UMAP Is Misleading You in Single-Cell ATAC-seq
Related reading on the blog
Sources
- (Shared) Nearest-neighbor graph construction, FindNeighbors • Seurat — SNN construction from Jaccard similarity, compute.SNN default
- FindNeighbors: (Shared) Nearest-neighbor graph construction in Seurat — default k.param = 20 and prune.SNN = 1/15
- Using other kNN libraries in Scanpy — PCA-based neighbor search, brute-force vs Annoy/PyNNDescent, SNN graphs being sparser than kNN
- Seurat - Guided Clustering Tutorial — FindNeighbors then FindClusters workflow, resolution 0.4-1.2 for ~3k cells