Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Navigate a Proximity Graph with NSW

Day 3

Complete Narrow the Search with IVFFlat first. You will replace centroid/list selection with graph reachability while keeping the SQL matcher, row lookup, and final top-k sort supplied.

Move from Lists to a Graph

Day 2 ended with a five-row cosine query running through IVFFlat. From the repository root, run that product path once more:

cargo run -p vector-db-from-scratch-datafusion-starter --example ivfflat_sql

The seeded IVFFlat plan contains index=ivf_flat, and its LIMIT 3 result is:

(1, one)
(2, two)
(3, three)

Today only the source of candidate row offsets changes. IVFFlat opens selected centroid lists; navigable small world (NSW) search follows edges between nearby vectors. The table, query, matcher, row lookup, and final SortExec stay put.

The cumulative starter already contains the two files you will change:

vector-db-starter/core/src/graph.rs
vector-db-starter/core/src/nsw.rs

Four TODOs form one path through the index: search_layer explores a graph, prune_neighbors bounds its degree, NswIndex::try_new inserts the stored rows, and NswIndex::search_with_ef queries the result. Leave the starter’s greedy_search, HNSW, and IVF-PQ TODOs for later days. The crate-internal tests can exercise graph helpers without making them public.

Checkpoint 1: Search One Supplied Layer

An NSW graph has no centroid that points directly at the query. Search starts at one or more supplied entry points and discovers only vertices connected to them.

The walk needs three pieces of state. C is nearest-first, so its next item is the vertex to expand. W is bounded and worst-first, so its top item is the first result to evict when a closer row arrives. visited ensures that each row is measured and expanded at most once. Seed all three from the valid, unique entry points.

Here is a concrete trace. Suppose rows 0, 1, and 2 store the one-dimensional values 0, 1, and 2, with edges 0—1—2. Rows 3 and 4 form a separate component. For query 0, entry row 2, and width 3, the search first retains row 2 at distance 2. Expanding row 2 discovers row 1 at distance 1; expanding row 1 then discovers row 0 at distance 0. W finally returns rows 0, 1, and 2 in that order.

Revisiting row 2 through row 1 does nothing because it is already in visited. An expansion that adds nothing does not end the whole search; another pending candidate may still open a useful path.

No choice of width can make that entry at row 2 reach rows 3 and 4. A second entry point or an edge into their component is required.

Whenever a closer row arrives, keep only the nearest ef rows in W.

Once W is full, stop only when the nearest pending candidate is strictly worse than W.worst.

A candidate equal to W.worst in public (distance, row) order must still be expanded because it may lead somewhere better. Even this strict rule is approximate: a worse intermediate vertex can hide a path to a closer one.

C = valid unique entry points as a min-heap by distance
W = the same points as a bounded max-heap by distance
visited = the same row offsets

while C is not empty:
    candidate = C.pop_nearest()
    if W is full and candidate is strictly worse than W.worst:
        break

    for neighbor in candidate.neighbors:
        if neighbor is outside allowed_rows or already visited:
            continue
        mark neighbor visited
        measure its distance once
        if W is not full or neighbor is better than W.worst:
            C.push(neighbor)
            W.push(neighbor)
            trim W to the search width

return W from nearest to farthest

Implement search_layer in graph.rs. Clamp ef to at least one and at most allowed_rows, and return no rows when none are allowed. Ignore duplicate or out-of-range entry points. During insertion, allowed_rows = r means that only the earlier rows 0..r exist.

cargo xtask test day_03::checkpoint_1

Before the implementation, this command reaches the traversal TODO. Afterward it covers the trace above, row bounds, disconnected components, duplicate and invalid entries, nearest-first uniqueness, and strict stopping.

Checkpoint 2: Keep a Bounded Neighbor List

Rows enter the graph one at a time. Before row r can connect, search the graph of earlier rows with width ef_construction, then select at most max_connections of the nearest candidates.

Each connection is reciprocal, so adding a new row can push an older endpoint past the degree cap.

Implement prune_neighbors in graph.rs. Deduplicate the supplied row offsets, order them by distance from the owner, break distance ties by row offset, and truncate to max_connections. In the focused fixture, owner row 0 sees candidate rows [2, 1, 1, 3]; rows 1 and 2 are equally distant, so a cap of two keeps [1, 2].

cargo xtask test day_03::checkpoint_2

The fixture is already self-free and isolates deduplication, ordering, tie-breaking, and the cap. The graph builder owns the separate rule that a row never appears in its own adjacency list.

Checkpoint 3: Build a Reciprocal Graph

Implement NswIndex::try_new in nsw.rs. Validate the stored vectors for the selected metric, then reject a graph budget unless max_connections > 0, ef_construction >= max_connections, and ef_search > 0.

The first row becomes the initial entry point without a search. For every later row r, call search_layer with allowed_rows = r, connect r to the nearest selected candidates in both directions, prune r and the older endpoints, then make r the entry point for the next insertion. For example, if row 4 connects to rows 1 and 3, first add 4—1 and 4—3. If pruning row 1 then rejects row 4, remove the reverse 4 -> 1 edge as well.

The finished graph must be deterministic, duplicate-free, self-free, reciprocal, and within the degree cap:

cargo xtask test day_03::checkpoint_3

This checkpoint covers stored-vector and configuration validation without calling search_with_ef, so construction failures remain local.

Checkpoint 4: Query with a Width Budget

Implement NswIndex::search_with_ef. Validate the query dimension, finite values, and selected metric, and reject a zero search width. Start from the graph entry point and call search_layer with width ef_search.max(k). Return at most k neighbors, nearest-first.

The .max(k) floor keeps the result request separate from the exploration hint. A request for five rows with ef_search = 1 still needs room for five retained results. More width can expose more of the connected graph, but it cannot cross a missing edge.

cargo xtask test day_03::checkpoint_4

The connected high-width fixture matches FlatIndex. Treat that as one observed result: NSW is not generally exact for arbitrary data, widths, or disconnected graphs.

Return to the Same SQL Product

With all four TODOs complete, run the supplied comparison:

cargo run -p vector-db-from-scratch-datafusion-starter --example nsw_sql

It executes the same five-vector cosine query twice. The first plan contains:

VectorIndexScanExec: index=ivf_flat, metric=Cosine, query_dim=3, fetch=Some(3), ordered=false

The second contains:

VectorIndexScanExec: index=nsw, metric=Cosine, query_dim=3, fetch=Some(3), ordered=false

Both retain the supplied SortExec and show the same three rows:

(1, one)
(2, two)
(3, three)

Only the candidate route changed. The attachment chooses the index, the matcher recognizes the supported top-k shape, row lookup resolves the returned offsets, and DataFusion performs the final sort. Equal rows in this example do not establish general recall, work, or performance.

The Checkpoint 4 command also runs a separate SQLLogicTest with eight rows and LIMIT 5. It verifies the index=nsw plan leaf, the supplied final sort, and its own five expected rows. Unsupported SQL shapes continue to use the supplied exact DataSourceExec path.

Finish Day 3

Run the focused Day 3 gate and then the cumulative course through Day 3:

cargo xtask test day_03
cargo xtask test-through day_03

The insertion and query traces now meet at the same graph boundary: opposite heap orderings choose what expands and what survives; strict stopping bounds exploration; reciprocal pruning keeps both endpoints consistent; ef_search.max(k) leaves room for the requested result; and connectivity decides which rows can be reached at all.

Day 3 deliberately builds one immutable graph layer. Hierarchy arrives with HNSW on Day 4. Deletion, concurrent mutation, persistence, filtering pushdown, general DDL/catalog behavior, benchmarking, and neighbor diversification are separate problems and do not change this checkpoint.

Your feedback is greatly appreciated. Join our Discord community.
Found an issue? Open an issue or pull request at github.com/skyzh/vector-db-from-scratch.
vector-db-from-scratch-book © 2024-2026 by Alex Chi Z is licensed under CC BY-NC-SA 4.0.