
This blog is written by AI for SEO
Replacing Kuzu's Vector and Full-Text Search Indexes
To replace a Kuzu vector index, store each embedding as a property on a HelixDB node, create a vector index on that property, add a BM25 text index for the keyword side, and swap your Cypher joins for a vector search scoped to a graph traversal. This guide maps each Kuzu vector and fts extension call you run today to its HelixDB TypeScript equivalent, in order, with the documented limits stated.
Prerequisites: you built GraphRAG or agent memory on Kuzu's extensions, you know HNSW, BM25 and Cypher, and you have Node.js installed. HelixDB is a graph database with native vector and full-text search, so the retrieval job Kuzu's two extensions did lives in one engine. If you also need to move the rest of the graph, the Kuzu migration guide covers export and loading.
Step 1: Write down what your Kuzu vector and FTS setup actually does
Before touching any code, list the retrieval jobs your Kuzu extensions perform. The replacement is per job, not per function name.
For most GraphRAG and agent-memory apps the list is short. One: semantic recall, meaning a nearest-neighbour lookup over an embedding column on one node table. Two: keyword recall, meaning BM25 over a handful of string columns. Three: the join, where hits from either lookup are matched back into a graph pattern, usually to pull neighbours, filter by relationship, or restrict results to one user's or one document's subgraph. Four: the fusion, where you merge the two ranked lists in application code.
Write each one down as a sentence: "find the 5 chunks nearest this query vector, then return the entities they MENTION." That sentence is what you will rebuild. Note the node table, the property, the vector dimension, the FTS columns and the k you pass. You need all of them in Step 4 and Step 5.
Step 2: Pin the Kuzu side: INSTALL/LOAD, CREATE_VECTOR_INDEX, CREATE_FTS_INDEX and the Cypher joins back to graph patterns
Pin the exact calls you run now so you can check each one off against its replacement. In Kuzu's vector and fts extensions the setup looks like this (in recent Kuzu releases both extensions ship pre-installed, but older versions need the INSTALL and LOAD lines):
INSTALL vector;
LOAD vector;
INSTALL fts;
LOAD fts;
CALL CREATE_VECTOR_INDEX('Chunk', 'chunk_vec_idx', 'embedding');
CALL CREATE_FTS_INDEX('Chunk', 'chunk_text_idx', ['title', 'body']);Queries call procedures and then join the returned node back into a pattern:
CALL QUERY_VECTOR_INDEX('Chunk', 'chunk_vec_idx', $query_vector, 5)
WITH node AS c, distance
MATCH (c)-[:MENTIONS]->(e:Entity)
RETURN c.title, e.name, distance
ORDER BY distance;
CALL QUERY_FTS_INDEX('Chunk', 'chunk_text_idx', 'quantum machine')
WITH node AS c, score
MATCH (c)-[:MENTIONS]->(e:Entity)
RETURN c.title, e.name, score
ORDER BY score DESC;Two properties of this setup matter for the port. The vector index needs a node-table property holding a FLOAT or DOUBLE array, and the query vector must match its dimensions. The FTS index only searches the properties you listed at creation time. Both queries search the whole table first and traverse second: the index finds top-k, then Cypher expands from those k nodes. Keep that ordering in mind, because HelixDB lets you reverse it.
Step 3: Start HelixDB locally with helix init and helix start dev (or embedded mode)
You have two ways to get a local HelixDB, and neither needs a Kuzu-style extension step.
For a server, install the Helix CLI, then run helix init local followed by helix start dev (this needs Docker or Podman and serves on port 6969). The open-source core is at github.com/HelixDB/helix-db under Apache-2.0.
For in-process use, which is closest to how you ran Kuzu, open an embedded database from the TypeScript client. Embedded mode needs no CLI:
import { Client } from "@helix-db/helix-db";
// in-memory, for tests
// on disk: kind "disk" with a root path
const client = Client.embedded({
kind: "disk",
root: "./helix-data",
database: "memory",
});To talk to the dev server instead, use Client.server("http://localhost:6969"), with .withApiKey(...) when you need one. Queries are built with g(), readBatch() and writeBatch() and only run when you call .send(). To run a built query, use client.query(query.toQueryRequest()).send(). Closing the writer persists embedded data. There is no flush().
Confirm the exact option names against the package's published types before you commit them. The client is versioned, so match the version you install.
Step 4: Move embeddings onto nodes as a property and create the vector index (cosine, euclidean or manhattan)
In HelixDB a vector is a property on a node (or edge). Your Kuzu embedding column carries over as a property with the same name, and the index is created on that property.
Three rules apply as you load data. The vector property has to be top-level, not nested inside another object. Values normalise to float32. And the query vector must match the dimension the index declares, the same constraint you had in Kuzu.
Choose the distance metric when you create the index. Kuzu's vector index gave you one metric choice at creation; HelixDB supports cosine, euclidean and manhattan. If your embedding model expects cosine similarity, pick cosine and you will match your old ranking behaviour. An index can be global or tenant-partitioned, which is worth using if your Kuzu app kept per-user memory in separate tables or filtered by a user id on every query. It can also live on edges, which Kuzu's node-table-only index could not.
Then the query. The Kuzu QUERY_VECTOR_INDEX('Chunk', 'chunk_vec_idx', $query_vector, 5) call becomes a vector search over the property:
import { g, readBatch, VectorDistanceMetric } from "@helix-db/helix-db";
const nearest = g()
.nodes("Chunk")
.vectorSearch("embedding", queryVector, {
metric: VectorDistanceMetric.Cosine,
limit: 5,
});Check the builder method names and option shape against the types in the version you installed. The mapping to keep: table plus index name plus column plus k in Kuzu becomes label plus property plus metric plus limit in HelixDB. See the data model docs for how nodes, properties and indexes are declared.
Step 5: Replace QUERY_FTS_INDEX with a BM25 text index on top-level string properties
A BM25 text index on your string properties replaces CREATE_FTS_INDEX and QUERY_FTS_INDEX, and it lives in the same engine as the vector index.
If your Kuzu FTS index covered ['title', 'body'], create text indexes on title and body. Kuzu's index searched only the columns you named at creation, and the same discipline applies here, so decide up front which properties are searchable.
The query side swaps the procedure call for a search step on the property:
import { g } from "@helix-db/helix-db";
const keyword = g()
.nodes("Chunk")
.search("body", "quantum machine")
.limit(10);Results rank by BM25 score. Pre-filters can run before full-text results are ranked, which is how you reproduce "BM25 within one document's chunks" without post-filtering a global list.
Expect one behavioural difference. BM25 scores depend on corpus statistics, so absolute score values will not equal what Kuzu returned, even on identical data. Compare rank order, not raw scores. That is exactly what Step 7 does.
Step 6: Replace the Cypher join with vector search scoped to a graph traversal (pre-filtering)
The Cypher join becomes vector search scoped to a traversal. The order of operations flips from "search everything, then expand" to "narrow by relationship, then rank."
In Kuzu, if you wanted the 5 nearest chunks belonging to one user's documents, you either searched the whole index and filtered the k results afterwards (risking fewer than 5 survivors) or over-fetched and trimmed. HelixDB's vector pre-filtering runs the similarity search over a graph-filtered candidate set instead of the whole index. You traverse first to define the candidates, then rank only those by distance.
In shape, it looks like this: start from the user node, walk OWNS to documents and HAS_CHUNK to chunks, then apply the vector search over that candidate stream. Verify the exact traversal and scoping method names against the published @helix-db/helix-db types. This is the part of the DSL where you want the types in front of you, not a blog snippet.
Three documented limits apply here. A traversal-scoped search applies the 800 effective results ceiling after candidate intersection, and it rejects rather than silently clamping if you ask for more. A candidate stream of more than 1,000,000 unique entities is a query error, so scope tightly. And the usual rule holds: the query vector's dimension must match the index.
For GraphRAG and agent memory, per-user, per-document or per-thread recall stops being a post-filter and becomes the query itself. For retrieval patterns built on top of this, see Knowledge Graph RAG: How to Retrieve Over Entities and Edges. If your remaining Cypher is simple, the Kuzu Cypher on HelixDB guide lists which queries run unchanged.
Step 7: Check results against Kuzu, mind the 800 effective results limit, and fuse ranks in your app
Validate by comparing rankings on a fixed query set, then rebuild any rank fusion in your application. HelixDB does not fuse the vector and text lists for you.
Pick 20 to 50 representative queries from your logs and run each against Kuzu and HelixDB. For vector queries, compare the top-k ID sets (overlap at 5 and at 10) rather than raw distances. Small differences are expected, since index construction and normalisation to float32 can reorder near-ties. For BM25, compare rank order for the reason given in Step 5. If overlap is poor on cosine, check that you chose the same metric your embedding model assumes.
Mind the ceiling. Whole-index vector searches cap effective results at 800, and traversal-scoped searches apply 800 after candidate intersection. If any Kuzu query asked for thousands of neighbours to re-rank later, restructure it: scope the candidates with a traversal or paginate.
Finally, fusion. If you merged vector and keyword lists in Kuzu-side code with reciprocal rank fusion or a weighted sum, keep that code and feed it HelixDB's two result lists. Run them in parallel:
const [textResults, vectorResults] = await Promise.all([
client.query(keyword.toQueryRequest()).send(),
client.query(nearest.toQueryRequest()).send(),
]);
// fuse ranks in your app, e.g. reciprocal rank fusionAdjust the wrapping to match how your installed SDK version expects a built query to be passed. If you want to measure retrieval quality end to end after the move, How to Evaluate AI Agent Memory: LongMemEval and LoCoMo is a good harness.
What to do next: troubleshooting and moving the rest of the Kuzu graph
If a search returns nothing, check the usual causes first: the query vector's dimension does not match the index, or the vector property is nested instead of top-level. If a scoped search errors, you have likely exceeded the 800 result ceiling or the 1,000,000 candidate limit, so tighten the traversal. With retrieval working, move the rest of the graph using the Kuzu migration guide, and port your application code with the Node.js and Python guides. Then run helix init local, start dev, and star HelixDB on GitHub if the mapping held up on your own queries.