Core Java

Vector Search for Java Developers: HNSW Intuition and pgvector in Practice

Search used to mean matching words. Today it often means matching meaning, and that is where vectors come in. An embedding model turns a sentence, an image or a product into a list of numbers, and similar things end up close together.

The hard part is speed. Comparing a query against every stored vector works, but it does not scale. Fortunately, Postgres can now do this with the pgvector extension, and its main index is called HNSW. In this article, we will build an intuition for it, run real code from Java, and look at measurements I ran myself.

The Problem: Nearest Neighbors at Scale

Finding the closest vectors is called nearest neighbor search. The exact method scans every row, so cost grows with the table. As a result, an approximate method is usually the better trade. You accept that the answer may miss a true neighbor now and then, and in return you get a much faster query.

HNSW in Plain Language

Think of a road map

HNSW stands for Hierarchical Navigable Small World. The original paper by Malkov and Yashunin describes a multilayer graph. Imagine driving across a country. First you take highways to get near your destination, and then you switch to local streets to find the exact house.

The index works the same way. The top layer has few points with long links, so a search can jump far in a few steps. Each lower layer adds more points and shorter links, and the bottom layer contains everything. The paper draws this idea from skip lists, which use the same coarse-to-fine trick.

The three knobs that matter

pgvector exposes three settings. Knowing what each one does makes tuning far less mysterious.

SettingDefaultWhat it controls
m16Maximum connections per layer
ef_construction64Candidate list size while building
hnsw.ef_search40Candidate list size per query

Source: pgvector README (checked through a documentation mirror of version 0.8.x).

Of the three, ef_search is the one you can change at query time, so it is the natural dial for recall against speed. Raising ef_construction improves the graph but slows builds and inserts.

pgvector in Practice

A first query in SQL

Let us start small. The commands below create a table, add three vectors, build an HNSW index and ask for the two nearest rows. I ran them on PostgreSQL 16 with pgvector 0.6.0, and the same syntax works in newer releases.

psql -d postgres -c "CREATE EXTENSION IF NOT EXISTS vector;"
psql -d postgres -c "CREATE TABLE docs (id bigserial PRIMARY KEY, embedding vector(3));"
psql -d postgres -c "INSERT INTO docs (embedding) VALUES ('[1,2,3]'), ('[4,5,6]'), ('[1,2,4]');"
psql -d postgres -c "CREATE INDEX ON docs USING hnsw (embedding vector_l2_ops) WITH (m = 16, ef_construction = 64);"
psql -d postgres -c "SET hnsw.ef_search = 100; SELECT id FROM docs ORDER BY embedding <-> '[1,2,3.5]' LIMIT 2;"

The operator <-> means Euclidean distance, and the vector_l2_ops class tells the index to use it. Both rows 1 and 3 sit exactly 0.5 away from the query, so they come back first.

Calling it from Java

No special client is required. The standard PostgreSQL JDBC driver works if you pass the vector as text and cast it. Download the driver jar from Maven Central, create the docs table above, and replace the password with your own.

cat > Demo.java <<'EOF'
import java.sql.*;

public class Demo {
    public static void main(String[] args) throws Exception {
        String url = "jdbc:postgresql://localhost/postgres?user=postgres&password=secret";
        try (Connection c = DriverManager.getConnection(url);
             PreparedStatement ps = c.prepareStatement(
                 "SELECT id, embedding <-> ?::vector AS dist FROM docs ORDER BY dist LIMIT 2")) {
            ps.setString(1, "[1,2,3.5]");
            try (ResultSet rs = ps.executeQuery()) {
                while (rs.next()) {
                    System.out.println(rs.getLong("id") + " " + rs.getDouble("dist"));
                }
            }
        }
    }
}
EOF
java -cp postgresql.jar Demo.java

This prints 1 0.5 and 3 0.5. I verified it with Java 21 and driver 42.7.2, and the source-file launch needs Java 11 or later. In a real application you would also use a connection pool, but the query itself stays the same.

What the Numbers Say

Toy tables prove nothing about speed, so I built a larger test. It holds 100,000 synthetic 128-dimension vectors grouped into 100 clusters, with 200 held-out queries and the top 10 neighbors requested. Recall means the share of the true top 10, found by an exact scan, that the index returned.

Recall climbs fast, then flattens

Data: my own run, PostgreSQL 16 with pgvector 0.6.0, HNSW m=16 and ef_construction=64. Synthetic clustered data, so real embeddings will differ.
ef_searchRecall@10Latency (ms)
1070.70%0.56
2084.25%0.52
40 (default)92.35%0.71
10097.15%1.20
20098.40%1.47
40099.40%1.96

Latency includes a local round trip from a Python client. The small gap between ef_search 10 and 20 is within noise.

Notice the shape. Going from 40 to 100 buys almost five points of recall for roughly half a millisecond, yet the last point between 98.4% and 99.4% costs about as much again. Therefore, tune upward until recall meets your need, and stop there.

Against an exact scan

Data: same run. Exact scan averaged over 20 queries, HNSW over 200. Logarithmic axis.

The exact scan took about 45.6 ms per query, while HNSW at the default setting took 0.71 ms, which is roughly 64 times faster. Even at ef_search 400, it stayed around 23 times faster. The price is memory and build time: the index was 83.2 MB next to a 58.9 MB table, and it took 37.9 seconds to build.

Read these results with care

This is one machine, one synthetic dataset and an older pgvector release, so please treat the figures as a shape rather than a promise. Real embeddings, other dimensions and filters will change the curve. Run the same experiment on your own data before you decide.

Pitfalls Worth Knowing

First, ef_search must be at least as large as your LIMIT, otherwise the index cannot return enough candidates. Second, the plain vector type is limited to 2,000 dimensions for HNSW indexes, as a dbi services guide points out, so larger embeddings need halfvec. Finally, filtered queries can return fewer rows than expected, and pgvector 0.8.0 added iterative scans to address that.

Checklist before you ship

  • Pick the distance operator that matches your embedding model.
  • Build the index with the same operator class you query with.
  • Measure recall against an exact scan on your own data.
  • Tune ef_search first, and touch m only if recall plateaus.
  • Check memory, since the index can exceed the table size.

What We Have Learned

Exact nearest neighbor search scans every row, so approximate search is the practical choice at scale. HNSW makes it fast by searching a layered graph from coarse to fine, much like highways before streets. In pgvector, three settings matter, and ef_search is the quickest dial for trading recall against latency. In my 100,000-vector test, the default reached 92.35% recall at 0.71 ms, against 45.6 ms for an exact scan, and raising it to 100 lifted recall to 97.15%. From Java, plain JDBC with a ?::vector cast is enough. Even so, the index costs memory and build time, and results depend on your data, so measure before you commit.

Eleftheria Drosopoulou

Eleftheria is an Experienced Business Analyst with a robust background in the computer software industry. Proficient in Computer Software Training, Digital Marketing, HTML Scripting, and Microsoft Office, they bring a wealth of technical skills to the table. Additionally, she has a love for writing articles on various tech subjects, showcasing a talent for translating complex concepts into accessible content.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Back to top button