HNSW-IF: Hybrid Graph and Inverted-File Vector Search

Embeddings & Vector Search advanced Building with AI ~35 min read

Overview

Pure in-memory HNSW is excellent for low-latency approximate nearest-neighbor search, but it becomes expensive when the corpus grows to hundreds of millions or billions of dense vectors. A billion 768-dimensional float vectors require roughly three tebibytes for vector payloads alone, and the HNSW graph adds more memory on top. If the application needs high recall over a very large corpus but only moderate query throughput, keeping every vector resident in RAM can be the wrong tradeoff.

HNSW-IF is a hybrid ANN design that keeps only a selected centroid set in an in-memory HNSW graph and stores the larger non-centroid population behind an inverted file. A query first searches the centroid graph to find promising clusters, converts those centroid ids into an inverted-index lookup, retrieves the vectors assigned to those clusters, and reranks the resulting candidates. Vespa’s public billion-scale experiment used this shape to search the SPACEV-1B dataset with most vectors outside the in-memory graph.

The transferable lesson is not specific to one engine: graph search can be used as a routing layer rather than the full index. By making HNSW identify neighborhoods and letting a disk-backed inverted file hold the larger candidate pool, teams can trade query latency for dramatically lower memory pressure while still exposing familiar recall knobs such as number of clusters, pruning thresholds, and reranking depth.

Core Concepts

Centroid Graph

The centroid graph is a smaller HNSW index over representative vectors. In the Vespa design, a fraction of vectors act as centroids and are indexed with ordinary HNSW settings. At query time, the system searches this graph to identify nearby centroids instead of walking through every stored vector.

Inverted File Assignments

Each non-centroid vector is assigned to several nearby centroid ids. Those ids become tokens in an inverted file: looking up a centroid id returns the vectors that belong to that neighborhood. Assigning each vector to multiple centroids improves recall because a vector can still be found when the closest route is not the only promising route.

Candidate Reranking

The first phase retrieves candidate ids cheaply; the second phase evaluates vector distance more carefully and reranks the shortlist. Reranking depth is a major quality knob. A larger candidate set costs more I/O and distance computation but can recover true neighbors that would otherwise be missed by centroid pruning.

Key Concept: HNSW as a Router, Not the Whole Index

HNSW-IF keeps HNSW where it is strongest: navigating high-dimensional neighborhoods quickly. The inverted file carries the bulk storage responsibility, so memory scales with centroid count and graph metadata rather than the full vector population.

How It Works

Index Construction

The index build starts by selecting centroid vectors. Those centroids are inserted into an HNSW graph. For each remaining vector, the system searches the centroid graph and stores the ids of the nearest centroids in an inverted-file field. Vespa’s experiment indexed the twelve closest centroid ids for each non-centroid vector, which means one vector can be retrieved by several centroid neighborhoods.

def build_hnsw_if(vectors, centroid_selector, centroid_fanout):
    centroids = centroid_selector(vectors)
    graph = HNSW(max_links_per_node=18, neighbors_to_explore_at_insert=100)
    inverted_file = defaultdict(list)

    for cid, vector in centroids:
        graph.insert(cid, vector)

    for doc_id, vector in vectors:
        if doc_id in centroids:
            continue
        nearest = graph.search(vector, top_k=centroid_fanout)
        for centroid_id, closeness in nearest:
            inverted_file[centroid_id].append((doc_id, vector, closeness))

    return graph, inverted_file

Query Execution

A query vector is searched against the centroid HNSW graph. The returned centroids are pruned by count or distance threshold, then converted into an inverted-file lookup. The resulting candidate vectors are scored and reranked. In Vespa, the serving layer materializes this as a dot-product query over centroid tokens and then runs a reranking phase over the candidate set.

def search_hnsw_if(query_vector, graph, inverted_file, centroid_k, prune, rerank_k, top_k):
    centroid_hits = graph.search(query_vector, top_k=centroid_k)
    selected = [
        centroid_id for centroid_id, distance in centroid_hits
        if distance <= prune
    ]

    candidates = []
    for centroid_id in selected:
        candidates.extend(inverted_file.get(centroid_id, []))

    scored = []
    for doc_id, vector, _ in candidates:
        scored.append((cosine_distance(query_vector, vector), doc_id))

    shortlist = sorted(scored)[:rerank_k]
    return exact_or_high_fidelity_rerank(query_vector, shortlist)[:top_k]

Serving with Engine Components

Vespa implements the pattern with a custom query component that obtains centroids, prunes them, emits a token query against the inverted file, and merges centroid results with the reranked candidate result. A companion document processor assigns each incoming vector to its nearest centroid tokens during ingestion, making the inverted file usable through the same search infrastructure that handles ordinary lexical posting lists.

Practical Applications

Low-QPS Billion-Scale Retrieval

Some applications need to search enormous embedding corpora but do not need hundreds of thousands of queries per second. Media archives, deduplication systems, catalog discovery, compliance review, and offline candidate generation may tolerate tens of milliseconds of additional latency if the memory bill drops substantially.

Cost-Aware RAG Candidate Generation

Retrieval-augmented generation systems often need broad recall from historical corpora, but only a small set of final passages is sent to a model. HNSW-IF can act as the first-stage candidate generator, with downstream text filters, metadata filters, or neural rerankers applied after the vector shortlist is retrieved.

Hybrid Ranking Pipelines

Search engines already know how to evaluate inverted-index queries, apply attribute filters, and run multi-phase ranking. Encoding centroid membership as inverted-file tokens lets dense vector retrieval participate in the same pipeline as lexical matching, business rules, and result blending.

Implementation Patterns

Choose the Centroid Fraction Deliberately

More centroids produce smaller posting lists and better routing precision, but they enlarge the in-memory graph. Fewer centroids reduce graph memory but force each centroid token to retrieve more candidates. Treat centroid fraction as a capacity-planning decision, not just an accuracy parameter.

Assign Multiple Centroids per Vector

A single centroid assignment is fragile around cluster boundaries. Multi-assignment increases index size but gives each vector more chances to appear in the candidate set. The right fanout depends on dimensionality, dataset shape, acceptable write amplification, and recall target.

Separate Routing Recall from Final Ranking

The centroid search should optimize candidate coverage, while the reranker should optimize final ordering. Keep metrics for both stages: centroid recall tells you whether routing reaches the right neighborhoods, while final recall@k tells you whether reranking returns the correct neighbors.

class HNSWIFQueryPlan:
    def __init__(self, centroid_k=128, assignment_fanout=12, prune_threshold=0.6, rerank_k=4000):
        self.centroid_k = centroid_k
        self.assignment_fanout = assignment_fanout
        self.prune_threshold = prune_threshold
        self.rerank_k = rerank_k

    def estimate_work(self, avg_posting_list):
        routed_lists = self.centroid_k
        candidate_reads = routed_lists * avg_posting_list
        rerank_work = min(candidate_reads, self.rerank_k)
        return {"candidate_reads": candidate_reads, "rerank_work": rerank_work}

Performance Considerations

Common Pitfalls

Further Reading