Approximate Nearest Neighbor (ANN) search is one of the most critical operations in modern information retrieval, recommendation systems, and machine learning pipelines. Given a query vector, ANN search finds the most similar vectors in a dataset without exhaustively comparing against every point—trading a small amount of accuracy for dramatic speedups. Among the family of tree-based ANN methods, Multi-Rotation Projection Trees (MRPT) offer a compelling combination of simplicity, speed, and automatic parameter tuning.
MRPT was introduced by Hyvönen et al. at the University of Helsinki. The core idea is to build an ensemble of randomized binary space partition trees, where each tree splits the data using random projections. A query traverses all trees simultaneously, and candidate neighbors are drawn from the intersection (or union) of the leaf nodes reached across the ensemble. The key innovation of MRPT is its ability to automatically select the optimal number of trees and voting threshold for a given recall target, eliminating the manual parameter tuning that plagues other ANN methods.
In a local semantic search system, MRPT provides an alternative indexing strategy for vector similarity search. While HNSW graphs dominate production ANN workloads, tree-based methods like MRPT are valuable for their simplicity, low memory overhead, and suitability for datasets that fit in memory but where index construction time must be minimized.
Random projection is based on the Johnson–Lindenstrauss lemma, which states that a set of points in high-dimensional space can be projected into a much lower-dimensional space while approximately preserving pairwise distances. MRPT uses random projection vectors to partition the data: for each internal node of a tree, a random vector r is drawn, and each data point x is assigned to the left or right child based on whether the dot product r · x falls below or above a splitting threshold.
Each tree in the MRPT ensemble recursively splits the dataset using random hyperplanes. Unlike k-d trees (which split along coordinate axes), random projections can capture correlations between dimensions. The randomness means each tree partitions the space differently, so nearby points separated by one tree’s split are likely to land in the same leaf in another tree. The ensemble compensates for individual tree errors.
A single random projection tree is unreliable: two nearby points may end up on opposite sides of a random hyperplane. MRPT addresses this by building multiple independent trees (typically 5–100). During a query, the search traverses all trees and collects the candidate sets from each tree’s leaf node. Points that appear in many trees’ candidate sets are more likely to be true nearest neighbors.
The voting threshold v determines how many trees must agree that a candidate is close to the query. A point is included in the final candidate set only if it appears in at least v out of T trees’ leaf nodes. Setting v = 1 (union of all candidates) maximizes recall but requires checking many candidates. Setting v = T (intersection) minimizes candidates but risks missing true neighbors. MRPT’s autotuning selects the optimal v for a target recall.
MRPT’s distinguishing feature is its automatic parameter selection. Given a target recall (e.g., 0.9), the algorithm determines the optimal number of trees and voting threshold by sampling queries from the training set and measuring empirical recall. This eliminates the painful grid search over parameters that other ANN methods require, and guarantees that the index meets the desired accuracy/speed tradeoff.
The depth of each tree (or equivalently, the maximum leaf size) controls the granularity of partitioning. Deeper trees create smaller leaves, reducing the number of candidates per tree but increasing the risk of splitting true neighbors. Shallower trees produce larger leaves with more candidates, improving recall at the cost of more distance computations. The tree depth is typically set so that leaf nodes contain 10–100 points.
Building an MRPT index proceeds in three phases:
T trees, recursively split the dataset. At each internal node, generate a random projection vector, compute the dot product of every point with this vector, find the median (or a quantile) of the projected values as the split threshold, and assign points to the left or right child. Recurse until leaf nodes contain at most l points.To find the k nearest neighbors of a query vector q:
r · q at each internal node and following the appropriate child.v votes.q to each remaining candidate.The tree traversal is O(T × depth × d) for the dot products. The candidate filtering and exact distance computation dominate runtime when many candidates survive the voting filter.
The basic MRPT query follows a “defeatist” strategy: at each internal node, the query goes left or right but never backtracks. This is fast but can miss neighbors near split boundaries. An enhanced strategy uses priority search, where the algorithm also explores the “wrong” child of nodes where the query’s projection value is close to the split threshold. This increases recall at the cost of examining more nodes and is similar to the spill tree approach.
Every ANN method operates on a recall–speed frontier. MRPT provides three knobs to move along this frontier: the number of trees T (more trees = higher recall, more memory), the voting threshold v (lower threshold = higher recall, more candidates to verify), and the leaf size (smaller leaves = fewer candidates per tree, but more risk of missing neighbors). The autotuning mechanism navigates this three-dimensional space to find the Pareto-optimal operating point for a given recall target.
A local semantic search platform uses MRPT as one of its ANN indexing strategies for searching over embedding vectors. When a user submits a semantic search query, the query text is encoded into a vector, and MRPT finds the most similar document vectors in the index. The autotuning feature is particularly valuable here: as the document collection grows, the index automatically adjusts its parameters to maintain the target recall level.
Content-based retrieval systems encode images or audio clips as feature vectors (often extracted from deep neural networks) and use ANN search to find similar items. MRPT’s low memory footprint makes it suitable for deployments where the index must fit in RAM alongside the application, such as on-device search in mobile or embedded systems.
Near-duplicate detection requires finding all pairs of vectors within a distance threshold. MRPT’s ensemble approach naturally supports range queries: instead of retrieving the top k, retrieve all candidates within a fixed distance. This is useful for deduplicating documents, images, or user profiles in large datasets before running more expensive exact comparisons.
Tree nodes are stored in a flat array using BFS (breadth-first) ordering, so that a node’s children are at indices 2i+1 and 2i+2. This avoids pointer chasing and improves cache locality during tree traversal. The projection vectors for each tree are stored contiguously in a matrix, enabling SIMD-accelerated dot products.
When multiple queries arrive simultaneously, batch processing amortizes the overhead of loading tree structure into cache. All queries traverse the first level of all trees, then the second level, and so on. This “breadth-first query” pattern keeps each tree level hot in L2/L3 cache across all queries, significantly improving throughput compared to processing queries independently.
Random projection trees support incremental insertion by routing new points down existing trees and appending them to leaf nodes. When a leaf exceeds the maximum size, it can be split with a new random projection. Deletion is handled by marking points as deleted and periodically rebuilding trees that have too many deleted entries. However, frequent updates degrade the balance of trees, so periodic full rebuilds are recommended for write-heavy workloads.
This example implements a simplified MRPT index demonstrating the core concepts of random projection trees and ensemble voting.
import numpy as np
from typing import List, Tuple
class MRPTIndex:
"""Multi-Rotation Projection Trees for approximate nearest neighbors."""
def __init__(self, n_trees: int = 10, max_leaf_size: int = 50):
self.n_trees = n_trees
self.max_leaf_size = max_leaf_size
self.trees: List[dict] = []
self.data: np.ndarray | None = None
def fit(self, data: np.ndarray) -> None:
"""Build the index from a dataset of shape (n, d)."""
self.data = data
n, d = data.shape
indices = np.arange(n)
for _ in range(self.n_trees):
tree = self._build_tree(indices, d)
self.trees.append(tree)
def _build_tree(self, indices: np.ndarray, dim: int) -> dict:
"""Recursively build a random projection tree."""
if len(indices) <= self.max_leaf_size:
return {"leaf": True, "indices": indices}
# Generate a random projection vector.
projection = np.random.randn(dim)
projection /= np.linalg.norm(projection)
# Project data points onto the random vector.
projected = self.data[indices] @ projection
# Split at the median.
threshold = np.median(projected)
left_mask = projected <= threshold
right_mask = ~left_mask
# Handle edge case where all points land on one side.
if not np.any(left_mask) or not np.any(right_mask):
return {"leaf": True, "indices": indices}
return {
"leaf": False,
"projection": projection,
"threshold": threshold,
"left": self._build_tree(indices[left_mask], dim),
"right": self._build_tree(indices[right_mask], dim),
}
def _query_tree(self, tree: dict, query: np.ndarray) -> np.ndarray:
"""Traverse a single tree to find the leaf for a query."""
if tree["leaf"]:
return tree["indices"]
proj_val = query @ tree["projection"]
if proj_val <= tree["threshold"]:
return self._query_tree(tree["left"], query)
else:
return self._query_tree(tree["right"], query)
def query(
self, q: np.ndarray, k: int = 10, voting_threshold: int = 1
) -> Tuple[np.ndarray, np.ndarray]:
"""
Find the k approximate nearest neighbors.
Args:
q: Query vector of shape (d,).
k: Number of neighbors to return.
voting_threshold: Minimum number of trees that must
agree on a candidate.
Returns:
(indices, distances) of the k nearest neighbors.
"""
# Collect votes across all trees.
votes: dict[int, int] = {}
for tree in self.trees:
candidates = self._query_tree(tree, q)
for idx in candidates:
votes[idx] = votes.get(idx, 0) + 1
# Filter by voting threshold.
filtered = [
idx for idx, count in votes.items()
if count >= voting_threshold
]
if not filtered:
# Fall back to all candidates if threshold too strict.
filtered = list(votes.keys())
# Compute exact distances for candidates.
candidate_indices = np.array(filtered)
distances = np.linalg.norm(
self.data[candidate_indices] - q, axis=1
)
# Return top k.
top_k = np.argsort(distances)[:k]
return candidate_indices[top_k], distances[top_k]
# Example usage:
np.random.seed(42)
data = np.random.randn(10000, 128).astype(np.float32)
query = np.random.randn(128).astype(np.float32)
index = MRPTIndex(n_trees=20, max_leaf_size=30)
index.fit(data)
indices, dists = index.query(query, k=5, voting_threshold=2)
print(f"Nearest neighbors: {indices}")
print(f"Distances: {dists}")
This snippet demonstrates how to automatically select the voting threshold for a target recall using a sample of queries.
def autotune_threshold(
index: MRPTIndex,
data: np.ndarray,
n_sample: int = 100,
k: int = 10,
target_recall: float = 0.9,
) -> int:
"""
Find the highest voting threshold that achieves the target recall.
Higher threshold = fewer candidates = faster queries.
"""
sample_idx = np.random.choice(len(data), n_sample, replace=False)
# Compute brute-force ground truth for samples.
ground_truth = {}
for idx in sample_idx:
q = data[idx]
dists = np.linalg.norm(data - q, axis=1)
dists[idx] = np.inf # exclude self
ground_truth[idx] = set(np.argsort(dists)[:k])
best_threshold = 1
for v in range(index.n_trees, 0, -1):
recalls = []
for idx in sample_idx:
result_idx, _ = index.query(data[idx], k=k, voting_threshold=v)
result_set = set(result_idx) - {idx}
recall = len(result_set & ground_truth[idx]) / k
recalls.append(recall)
avg_recall = np.mean(recalls)
if avg_recall >= target_recall:
best_threshold = v
break
return best_threshold
optimal_v = autotune_threshold(index, data, target_recall=0.9)
print(f"Optimal voting threshold for 90% recall: {optimal_v}")
A single random projection tree is unreliable for ANN search because nearby points can be separated by an unlucky hyperplane split. By building many independent trees and requiring consensus (voting), MRPT achieves high recall. This is the same principle behind random forests in machine learning—the ensemble is far stronger than any individual member.
MRPT’s signature feature is automatic parameter selection for a target recall level. Instead of manually tuning the number of trees, leaf size, and voting threshold through grid search, the autotuning procedure samples queries, measures empirical recall, and finds the Pareto-optimal configuration. This makes MRPT practical for teams that lack the expertise or time to hand-tune ANN index parameters.
While HNSW graphs generally offer better recall–speed tradeoffs for large-scale production workloads, MRPT has advantages in specific scenarios: faster index construction time, lower memory overhead (no graph edges to store), and better suitability for moderate-dimensional data where random projections are effective. Understanding both approaches lets you choose the right tool for the job.