Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.helix-db.com/llms.txt

Use this file to discover all available pages before exploring further.

For the complete documentation index optimized for AI agents, see llms.txt.
The DSL exposes two kinds of search source steps that return scored hits instead of a plain label scan: vector search (approximate k-NN over a float-array property) and text search (BM25 over a string property). Both are available on nodes and edges, both expose a literal form and a parameter-bound _with form, and both can be the first step of a longer traversal — once you have hits, you can keep walking the graph. This page assumes the indexes exist. Index creation is covered first, then queries.

Creating indexes

Indexes are graph mutations, so they live in a writeBatch. The unified createIndexIfNotExists(IndexSpec.*) step covers every variant and is idempotent — safe to run on each deploy.
import { g, IndexSpec, writeBatch } from "@helixdb/enterprise-ql";

const createIndexes = writeBatch()
  .varAs(
    "username_uniq",
    g().createIndexIfNotExists(
      IndexSpec.nodeUniqueEquality("User", "username"),
    ),
  )
  .varAs(
    "post_embedding",
    g().createIndexIfNotExists(
      IndexSpec.nodeVector("Post", "embedding"),
    ),
  )
  .varAs(
    "post_body",
    g().createIndexIfNotExists(
      IndexSpec.nodeText("Post", "body"),
    ),
  );
IndexSpec covers every shape the runtime supports:
SpecWhat it is
nodeEquality(label, prop)Secondary equality index.
nodeUniqueEquality(label, prop)Equality index with a uniqueness constraint.
nodeRange(label, prop)Range / ordering index.
nodeVector(label, prop, tenant?)ANN vector index.
nodeText(label, prop, tenant?)BM25 text index.
edgeEquality / edgeRange / edgeVector / edgeTextSame shapes for edges.
tenant? is the multi-tenancy partition key — see the bottom of this page. vectorSearchNodes(label, property, queryVector, k, tenantValue?) returns the top k nodes whose property is closest to queryVector. Hits arrive in ascending distance order with a virtual $distance field projected onto each result.
import { g, PropertyProjection, readBatch } from "@helixdb/enterprise-ql";

const similarPosts = readBatch()
  .varAs(
    "hits",
    g()
      .vectorSearchNodes("Post", "embedding", [0.12, 0.85, -0.04], 5, null)
      .project([
        PropertyProjection.renamed("$id", "post_id"),
        PropertyProjection.renamed("$distance", "distance"),
        PropertyProjection.new("title"),
      ]),
  )
  .returning(["hits"]);
$distance is only present on the direct hit stream. The instant you step off it with .out(), .in(), .both(), .outN(), etc., the score is gone — project it before you traverse.

Following the graph from a hit

Vector hits are a normal node stream, so the next step can keep walking. The example below finds posts similar to a query vector, then returns their authors with the post’s distance projected as an annotation.
import { g, PropertyProjection, readBatch } from "@helixdb/enterprise-ql";

const authorsOfSimilar = readBatch()
  .varAs(
    "hits",
    g()
      .vectorSearchNodes("Post", "embedding", [0.12, 0.85, -0.04], 10, null)
      .project([
        PropertyProjection.renamed("$id", "post_id"),
        PropertyProjection.renamed("$distance", "distance"),
        PropertyProjection.new("title"),
      ]),
  )
  .varAs(
    "authors",
    g()
      .vectorSearchNodes("Post", "embedding", [0.12, 0.85, -0.04], 10, null)
      .in("AUTHORED")
      .dedup()
      .valueMap(["$id", "username"]),
  )
  .returning(["hits", "authors"]);
The two varAs blocks duplicate the search step because each is a fresh traversal binding. If you only want the authors and don’t need the hits themselves in the response, drop the first varAs and remove "hits" from returning(...).

Text search (BM25)

textSearchNodes(label, property, queryText, k, tenantValue?) runs BM25 over the named text-indexed property and returns the top k matching nodes. The interface mirrors vectorSearchNodes exactly, including the $distance virtual field (here it represents an inverted relevance score — smaller is more relevant, just like vector distance).
import { g, PropertyProjection, readBatch } from "@helixdb/enterprise-ql";

const postsAboutGraphs = readBatch()
  .varAs(
    "hits",
    g()
      .textSearchNodes("Post", "body", "graph database storage engine", 10, null)
      .project([
        PropertyProjection.renamed("$id", "post_id"),
        PropertyProjection.renamed("$distance", "score"),
        PropertyProjection.new("title"),
      ]),
  )
  .returning(["hits"]);
For routes that take the query vector / text and k from the request, switch to the _with siblings. They accept PropertyInput.param(...) for the query and a StreamBound (literal or Expr-wrapped) for k.
import {
  defineParams,
  Expr,
  g,
  param,
  PropertyInput,
  PropertyProjection,
  readBatch,
  StreamBound,
} from "@helixdb/enterprise-ql";

const params = defineParams({
  query_vector: param.array(param.f32()),
  k:            param.i64(),
});

function semanticSearch(p = params) {
  return readBatch()
    .varAs(
      "hits",
      g()
        .vectorSearchNodesWith(
          "Post",
          "embedding",
          PropertyInput.param("query_vector"),
          StreamBound.expr(Expr.param("k")),
          null,
        )
        .project([
          PropertyProjection.renamed("$id", "post_id"),
          PropertyProjection.renamed("$distance", "distance"),
          PropertyProjection.new("title"),
        ]),
    )
    .returning(["hits"]);
}
textSearchNodesWith(...) has the identical shape — just replace PropertyInput.param("query_vector") with PropertyInput.param("query_text") and declare the parameter as param.string().

Multi-tenancy

If the index was created with a tenantProperty, every search must supply a matching tenantValue (the partition key). Pass it as the fifth argument to vectorSearchNodes / textSearchNodes, or wrap it in PropertyInput.param("...") inside the _with variants.
import { g, IndexSpec, PropertyValue, readBatch, writeBatch } from "@helixdb/enterprise-ql";

// Create a tenant-scoped vector index.
const createTenantIndex = writeBatch().varAs(
  "idx",
  g().createIndexIfNotExists(IndexSpec.nodeVector("Post", "embedding", "tenant_id")),
);

// Query inside one tenant.
const tenantSearch = readBatch().varAs(
  "hits",
  g().vectorSearchNodes(
    "Post",
    "embedding",
    [0.12, 0.85, -0.04],
    5,
    PropertyValue.string("acme"),
  ),
);
A search that omits the tenant against a tenant-scoped index will fail to find any rows even if the data exists. See Multi-Tenancy for the full partitioning model.

Next Steps

Advanced patterns

repeat, union, choose, coalesce, optional, forEachParam.

Parameters and bundles

Declare parameters once, then expose them via dynamic requests or stored bundles.