
This blog is written by AI for SEO
Microsoft GraphRAG Output: Query It in a Live Graph Database
To query Microsoft GraphRAG output from an agent, read the indexer's parquet tables and load them into a graph database where entities become nodes, relationships become edges and community reports become linked nodes. This guide does that with HelixDB, an open-source graph-vector database built in Rust. You can scope a vector search to one community, expand over edges, and update the graph without re-running the indexer.
Prerequisites: you have already run graphrag index and have an output/ folder, Python with pandas and pyarrow, Docker or Podman for the local server, and Node.js for the TypeScript SDK. The source side comes first, because the load only works if you know exactly what the indexer wrote.
Step 1: Know what the GraphRAG indexer actually wrote (and why static files fall short)
The default indexing pipeline writes a set of parquet tables that line up with GraphRAG's knowledge model: documents, text units, entities, relationships, communities and community reports, plus an optional covariates table if claim extraction is on. The pipeline composes text units, extracts entities and relationships with an LLM, summarizes them, runs Hierarchical Leiden for communities, writes a report per community, then embeds the artifacts that need vector search.
The catch is what you can do with those files. They are static snapshots. The library's local, global and drift search modes read them, and nothing else does. You can't add one entity without re-running the pipeline, and an agent can't traverse from an entity to its neighbours without loading everything into memory.
A database fixes both problems. Once the graph is live, an agent can read it, write to it, and combine similarity search with traversal in one query. Verify the column names below against your GraphRAG version, since output schemas can change between releases.
Step 2: Read the parquet tables: entities, relationships, communities, community_reports, text_units
Start by looking at the files. A typical output folder holds entities.parquet, relationships.parquet, communities.parquet, community_reports.parquet and text_units.parquet.
import pandas as pd
out = "output"
entities = pd.read_parquet(f"{out}/entities.parquet")
relationships = pd.read_parquet(f"{out}/relationships.parquet")
communities = pd.read_parquet(f"{out}/communities.parquet")
reports = pd.read_parquet(f"{out}/community_reports.parquet")
text_units = pd.read_parquet(f"{out}/text_units.parquet")
for name, df in [("entities", entities), ("relationships", relationships),
("communities", communities), ("reports", reports),
("text_units", text_units)]:
print(name, len(df), list(df.columns))The columns you care about:
entities.parquet:id,title,type,description,text_unit_idsrelationships.parquet:source,target(entity titles),description,weight,text_unit_idscommunities.parquet:community,level,parent,children,entity_ids, plustitlecommunity_reports.parquet:community,title,summary,full_content,findings,ranktext_units.parquet:id,text,document_ids
Two details trip people up. Relationships reference entities by title, not id, so your edge loader needs a title-to-node lookup. And the entity description embeddings aren't in these frames. Read them from LanceDB, or re-embed description with the model you want to query with. Re-embedding is often cleaner, because the query-time embedding model has to match the stored one anyway.
import lancedb
db = lancedb.connect(f"{out}/lancedb")
print(db.table_names()) # look for the entity description tableStep 3: Set up HelixDB locally with helix init and helix start dev
HelixDB runs locally as a Docker or Podman server on port 6969. Install the Helix CLI, then initialise and start a dev instance, following the HelixDB docs:
helix init local
helix start devInstall the TypeScript SDK in a fresh folder for the loader:
npm install @helix-db/helix-dbIf you'd rather skip the server, the SDK also opens an embedded database in your process with Client.embedded(...), and no CLI is needed. This guide uses the server so the loader and the agent can be separate processes. Queries are JSON sent to POST /v2/query. The SDK builds that JSON for you, so there is no separate query language to learn.
To keep the loader simple, export the parquet frames to JSON from Python first, with embeddings attached:
import json
# embed() is your embedding function returning list[float]
records = []
for r in entities.itertuples():
records.append({
"id": r.id, "title": r.title, "type": r.type,
"description": r.description,
"embedding": embed(r.description),
})
json.dump(records, open("entities.json", "w"))Do the same for relationships, communities and reports. Parquet reading stays in Python where it's easy, and the graph write happens in TypeScript.
Step 4: Load entities as nodes, with description embeddings as a vector property
In HelixDB a vector is a property on a node (or edge), so an entity node carries its description embedding alongside title, type and description. Check the exact builder calls against the live query guide and the @helix-db/helix-db 3.2.0 types before you run this, since the DSL is the part most likely to differ from what you remember.
import { readFileSync } from "node:fs";
import { Client, writeBatch, g } from "@helix-db/helix-db";
const client = Client.server("http://localhost:6969");
const entities = JSON.parse(readFileSync("entities.json", "utf8"));
// Load in batches so each write batch commits atomically.
for (let i = 0; i < entities.length; i += 500) {
const batch = writeBatch();
for (const e of entities.slice(i, i + 500)) {
batch.var("n" + e.id, g().addN("Entity", {
graphragId: e.id,
title: e.title,
type: e.type,
description: e.description,
embedding: e.embedding,
}));
}
await client.query(batch.toQueryRequest()).send();
}Create a vector index on embedding for the Entity label (cosine is the usual choice for text embeddings) as described in the vector index section of the docs. Vector properties must be top-level properties, which is why embedding sits directly on the node rather than nested. Also index graphragId and title, since the edge loader looks nodes up by title.
Graph and vector writes commit together, so a failed batch leaves no half-loaded entities with missing vectors.
Step 5: Load relationships as edges and community reports as nodes linked to member entities
Edges come from relationships.parquet. Resolve source and target titles to node ids, then add an edge labelled RELATED_TO carrying description and weight as properties. Keeping weight on the edge lets you rank neighbours during traversal later.
import { readFileSync } from "node:fs";
import { Client, writeBatch, g, Predicate } from "@helix-db/helix-db";
const client = Client.server("http://localhost:6969");
const rels = JSON.parse(readFileSync("relationships.json", "utf8"));
for (let i = 0; i < rels.length; i += 500) {
const batch = writeBatch();
for (const r of rels.slice(i, i + 500)) {
batch.var("e", g()
.nWhere(Predicate.eq("title", r.source))
.addE("RELATED_TO", { description: r.description, weight: r.weight },
g().nWhere(Predicate.eq("title", r.target))));
}
await client.query(batch.toQueryRequest()).send();
}The names above (nWhere, addE, Predicate.eq) follow the SDK's traversal style. Confirm each against the 3.2.0 types, because a one-word mismatch here is the most common load failure.
Community reports become Community nodes with community, level, title, summary, full_content and rank. Embed summary and store it as a vector property if you want to search reports directly. Then link each community to its members using entity_ids from communities.parquet: one HAS_MEMBER edge per entity id, from the community node to the entity node. Also store parent as a PARENT_OF edge between community nodes, so the Leiden hierarchy survives the move.
Finally, load text units as TextUnit nodes and connect entities to them through the text_unit_ids column with MENTIONED_IN edges. Every answer stays traceable back to source text, which the static files could only do by joining dataframes by hand.
Step 6: Query it: vector search scoped to a community, then expand over edges
This is the payoff. GraphRAG's local search finds entities by similarity and pulls in neighbours, but it runs inside the library over files. In HelixDB you can scope the vector search to the members of one community, so similarity runs over a graph-filtered candidate set instead of the whole index. Then you expand across RELATED_TO edges in the same query.
The shape of the query:
Start from the
Communitynode you care about (pick it by vector search over report summaries, or bycommunityid).Traverse
HAS_MEMBERto get candidate entities.Run vector search on
embeddingagainst that candidate set with your query vector.Expand one or two hops over
RELATED_TO, ordering by edgeweight.Return entity titles, descriptions and the linked text units.
import { Client, readBatch, g, Predicate } from "@helix-db/helix-db";
const client = Client.server("http://localhost:6969");
const queryVector: number[] = await embed("who funds the lab?");
// Sketch: confirm the vector-search and traversal calls against the
// query guide for @helix-db/helix-db 3.2.0 before running.
const q = readBatch().var("hits", g()
.nWhere(Predicate.eq("community", 12))
.out("HAS_MEMBER")
.vectorSearch("Entity", "embedding", queryVector, 10)
.out("RELATED_TO"));
const res = await client.query(q.toQueryRequest()).send();Two documented limits apply. An unrestricted vector search caps at 800 effective results, and a traversal-scoped search applies that ceiling after candidate intersection and errors rather than silently clamping. A community is small, so you'll stay well under it. If you want to see the same idea built end to end for a different corpus, the GraphRAG pipeline guide walks through option trade-offs, and when to use a knowledge graph instead of embeddings covers why the traversal step matters.
Step 7: Update the graph incrementally instead of re-indexing, plus troubleshooting
New document? Run your extraction on just that document, then upsert. Look up each extracted entity by title. If it exists, update description and re-embed. If not, add a node. Add the new RELATED_TO edges and MENTIONED_IN links. Each batch commits atomically, so an agent can write while another process reads. Community reports are the one thing that goes stale: after a meaningful change, regenerate the report for the affected community and overwrite its node, rather than re-running Leiden over everything.
Expect to rerun community detection occasionally, because incremental edits drift from the original Leiden partition. Treat the stored partition as a cache you refresh on a schedule, not a live invariant.
Troubleshooting:
Edges fail to load:
sourceandtargetare titles. Entity titles aren't guaranteed unique across types, so check for duplicates and indextitlebefore loading edges.Vector search returns nothing: the query embedding model must match the one used for stored descriptions. If you took vectors from LanceDB, use the same model GraphRAG used.
Column not found: schemas vary by GraphRAG version. Print
df.columnsfirst rather than trusting this guide's list.Server won't start:
helix start devneeds Docker or Podman running.Slow loads: batch 500 to 1,000 writes per request and avoid one request per row.
Load one community this week, then let the agent write back
Microsoft GraphRAG gives you good extraction and community summaries, then leaves them in files only its own search modes can read. Move them into a live graph and the same data becomes something an agent can query, scope, extend and correct. Start small: load a single community's entities, edges and report into a local instance with helix init local and helix start dev, run the scoped query from Step 6, then add one new document incrementally. The HelixDB docs cover the query guide and vector indexes, and the repo is at github.com/HelixDB/helix-db if you want to star it and follow along.