Knowledge Graph Search
Extract an entity and relationship graph from your documents, so agents can answer questions whose supporting facts are spread across several of them.
Why Use a Knowledge Graph
Vector search retrieves the passages that look most like the question. That works when one passage holds the answer, and fails when the answer is assembled from several.
Consider a knowledge base of company filings. "Which drugs does Aldous Corporation sell?" has its answer split in two: one document says Aldous acquired Bfarma Labs, another says Bfarma Labs makes Zalofen. The second document never mentions Aldous, so nothing about it looks like the question. In a corpus of any size it loses the top-k race to documents that merely repeat the question's wording.
A knowledge graph links the passages through the entities they share:
Aldous Corporation --[acquired]--> Bfarma Labs --[manufactures]--> Zalofen
Graph search starts from the entities your question mentions, walks those relationships, and returns the source passages behind every entity it reaches. The document is retrieved because it is connected to the question, not because it resembles it.
Overview
Turning the graph on changes two things:
- At ingest. Each chunk goes through an extra LLM pass that extracts entities (people, organizations, products, locations) and the relationships between them. Entities that appear in several documents are merged, so the graph spans the whole knowledge base rather than a single file.
- At search. A new retrieval mode,
searchMode: 'graph', seeds entities from your query with vector search, expands their relationships, maps the reached entities back to their source chunks, and fuses those results with a standard hybrid search. Nothing is lost relative to hybrid search: the graph channel adds passages, it does not replace them.
On top of the entity graph, Squid builds a concept layer: document nodes, themes (topics) clustered from the entity graph, and facet trees derived from your context metadata. That layer powers the queryGraph() operations, which let an agent ask structural questions ("what is in this knowledge base", "which documents sit under this topic", "how are these two things connected") instead of only retrieving passages.
When to use it
| Situation | Recommendation |
|---|---|
| Answers span several documents linked by shared entities | ✅ Knowledge graph |
| "How is A connected to B", "what is involved with X" | ✅ Knowledge graph |
| Agent needs to describe or navigate the corpus, not just quote it | ✅ Knowledge graph, via queryGraph() |
| Exact tokens: error codes, SKUs, file names | Keyword search |
| One passage answers the question | Default hybrid search is enough |
Requirements
- The knowledge base must be on the
'mongoAtlas'vector store. The store is immutable after creation, so a knowledge base created on'postgres'cannot gain a graph later. PassvectorDbType: 'mongoAtlas'explicitly rather than relying on the server default, which depends on the deployment;getKnowledgeBase()reports what an existing knowledge base got. See Creating a Knowledge Base. graphRagitself is mutable, so you can turn the graph on and off on an existing Atlas knowledge base at any time.- Graph extraction runs an LLM over every chunk at ingest, which costs tokens on top of embedding. Budget for it before enabling the graph on a large corpus, and track the actual spend with
getGraphStatus(). - The graph APIs are available in both the TypeScript and Python SDKs. The examples on this page use TypeScript; the Python client exposes the same surface as
get_graph_status(),rebuild_graph(),query_graph(),explore_graph(), andsearch_with_graph_context()onsquid.ai().knowledge_base(...).
Quick Start
Knowledge base management requires an API key, so run it from backend code and expose it to your frontend through an executable.
import { SquidService, executable } from '@squidcloud/backend';
export class KnowledgeGraphService extends SquidService {
@executable()
async createGraphKnowledgeBase(): Promise<void> {
await this.squid
.ai()
.knowledgeBase('filings-knowledgebase')
.upsertKnowledgeBase({
description: 'Company filings, acquisitions, and product lines',
embeddingModel: 'text-embedding-3-small',
chatModel: 'gpt-5.5',
metadataFields: [],
// Required: the graph is only built on the MongoDB Atlas vector store.
vectorDbType: 'mongoAtlas',
graphRag: {
enabled: true,
// Optional domain hint. Steers extraction toward the entity kinds you care about.
entityTypes: ['ORGANIZATION', 'PRODUCT', 'LOCATION'],
},
});
}
@executable()
async addFilings(): Promise<void> {
await this.squid
.ai()
.knowledgeBase('filings-knowledgebase')
.upsertContexts([
{
contextId: 'acquisition-2019',
type: 'text',
title: 'Acquisition',
text: 'Aldous Corporation acquired Bfarma Labs in 2019 for two billion dollars.',
},
{
contextId: 'product-line',
type: 'text',
title: 'Product line',
text: 'Bfarma Labs manufactures a migraine drug called Zalofen at its Dublin plant.',
},
]);
}
}
Entity extraction is triggered by ingestion and runs asynchronously off the request path, so no rebuild is needed to make the graph queryable, but upsertContexts() resolving does not mean the graph is ready. Poll getGraphStatus() until every context is indexed before relying on graph retrieval, as shown under Monitoring the graph. Extraction covers contexts up to a per-knowledge-base limit; see Graph indexing cap.
Searching needs no API key and can run from the client:
- TypeScript
- Python
// 'graph' is the default mode on a graph-enabled knowledge base, so searchMode is optional here.
const chunks = await squid.ai().knowledgeBase('filings-knowledgebase').search({
prompt: 'Which drugs does Aldous Corporation sell?',
searchMode: 'graph',
});
# 'graph' is the default mode on a graph-enabled knowledge base, so searchMode is optional here.
chunks = await squid.ai().knowledge_base('filings-knowledgebase').search(
'Which drugs does Aldous Corporation sell?',
options={'searchMode': 'graph'},
)
The traversal reaches Zalofen from Aldous Corporation through Bfarma Labs, so the product-line chunk is retrieved even though it never mentions Aldous Corporation. You can see the chain it walked with searchWithGraphContext().
Do not expect a two document knowledge base to show a difference between the search modes. Every mode returns everything when there is nothing to compete with. The graph earns its keep once enough documents are competing for the top-k that a connected but dissimilar passage would otherwise be crowded out.
Configuration
Every field of graphRag except enabled is optional.
| Field | Type | Description |
|---|---|---|
enabled | boolean | Turns extraction and searchMode: 'graph' on. Required. |
entityTypes | string[] | Domain taxonomy hint injected into the extraction prompt, for example ['ORGANIZATION', 'PRODUCT']. Guides extraction rather than restricting it. |
extractionModel | AiChatModelSelection | Chat model used for per-chunk extraction. Defaults to the server's graph model. |
autoBuildDebounceMs | number | Quiet window before an automatic build runs. Positive integer, defaults to the server's 5 minute window. |
concepts | object | Concept layer configuration. concepts.facets selects which metadata fields become facets ('auto' by default, [] to disable), and concepts.pathFacets builds a navigable tree out of a hierarchical path field. |
A path facet turns a folder path into a browsable hierarchy in the graph:
await this.squid
.ai()
.knowledgeBase('filings-knowledgebase')
.upsertKnowledgeBase({
description: 'Company filings, acquisitions, and product lines',
embeddingModel: 'text-embedding-3-small',
chatModel: 'gpt-5.5',
metadataFields: [],
vectorDbType: 'mongoAtlas',
graphRag: {
enabled: true,
concepts: {
// Build a folder tree from the folderPath metadata stamped by folder-aware uploads.
pathFacets: [{ field: 'folderPath', type: 'path' }],
},
},
});
Upserting graphRag replaces the whole object, so fields you omit are lost. Read the current configuration with getKnowledgeBase() and spread it if you want to change a single field.
Enabling the Graph on an Existing Knowledge Base
Flipping enabled to true on an Atlas knowledge base that already holds content backfills the graph for you. Squid waits for the knowledge base to go quiet, then runs a structural build that extracts every context not yet graph indexed and builds the concept layer on top. You do not need to call rebuildGraph().
const kb = this.squid.ai().knowledgeBase('filings-knowledgebase');
const existing = await kb.getKnowledgeBase();
// Spread the current configuration so enabling the graph does not drop any other field.
await kb.upsertKnowledgeBase({ ...existing!, graphRag: { enabled: true } });
Automatic builds
The concept layer (themes and facets) is derived from the whole corpus, so it is rebuilt in batches rather than per document. A build is scheduled automatically after graph activity: ingesting content, deleting content, or enabling the graph.
- The build waits for a quiet window measured from the completion of the last piece of graph work. Ingestion that is still in flight keeps pushing the deadline out, so a long bulk upload produces one build at the end rather than one per document.
- The window defaults to 5 minutes and is configurable per knowledge base with
graphRag.autoBuildDebounceMs. - A sweep checks for due knowledge bases every minute, so an elapsed window is picked up within roughly a minute of expiring.
- While a build is pending,
getGraphStatus()reportsnextAutoBuildAt. It moves forward as long as activity continues.
Entity extraction is not part of this schedule. It is triggered by ingestion itself, so searchMode: 'graph' covers freshly ingested documents once extraction settles, well before the concept layer catches up. What lags is the theme and facet structure that queryGraph() reads.
Forcing a build
Call rebuildGraph() when you do not want to wait for the quiet window. The default 'structural' mode is almost always what you want. See the warning on 'full' below.
// Runs a structural build (the default): folds newly ingested documents into topics and
// facets now, reusing the extracted entities. Same build the automatic schedule runs.
await this.squid.ai().knowledgeBase('filings-knowledgebase').rebuildGraph();
| Mode | What it recomputes | Cost |
|---|---|---|
'structural' (default) | Entity resolution, themes, and facets. Extraction runs only for contexts never graph indexed. | A small fraction of a full rebuild. |
'full' | Everything, including LLM re-extraction of every chunk. | Re-extraction dominates. Same order as the initial ingest. |
A 'full' rebuild is rarely necessary, and it is both destructive and slow. It wipes the existing graph up front and then re-extracts every chunk with the LLM, so it costs tokens and time on the same order as the initial ingest, and until the job completes, searchMode: 'graph' and queryGraph() run against an incomplete graph. Extraction already runs at ingest, so new or updated documents never need a full rebuild; a structural build folds them in. Request mode: 'full' explicitly only when the extraction itself must be redone, i.e. after changing entityTypes or extractionModel.
Only one rebuild runs per knowledge base at a time. Track it through getGraphStatus().buildJob.
Graph Indexing Cap
Graph extraction stops at a per-knowledge-base chunk cap. The cap is a deployment-level limit rather than a per-knowledge-base option, and its value can change, so detect it through the signals below instead of designing around a fixed number. Contexts ingested past the cap are stored normally and stay fully searchable in the vector, hybrid, and keyword modes; only graph coverage is absent, so a graph traversal cannot reach them and queryGraph() does not see them.
The skip is reported rather than silent:
upsertContexts()lists the affected contexts ingraphIndexingSkippedContextIds, and the single-contextupsertContext()setsgraphIndexingSkipped: true.getGraphStatus()shows the standing gap ascontextsIndexed < contextsTotal.
const result = await this.squid
.ai()
.knowledgeBase('filings-knowledgebase')
.upsertContexts([
{
contextId: 'annual-report-2026',
type: 'text',
title: 'Annual report',
text: 'Aldous Corporation reported record revenue in 2026.',
},
]);
// The listed contexts are stored and fully searchable; they are only absent from the graph.
const skippedIds = result.graphIndexingSkippedContextIds ?? [];
if (skippedIds.length > 0) {
console.warn(`${skippedIds.length} contexts were stored without graph coverage`);
}
Any ingestion path that does not hand you the upsert result, such as Console and CLI bulk uploads or connector-driven syncs, leaves the getGraphStatus() gap as the only signal.
Updating a document that is already in the graph does not evict it. A reupsert whose new content produces no more chunks than the context already contributed is exempt from the cap, so same-size replacements keep their graph coverage. A replacement that grows the context counts as new content, and the cap applies to the growth.
To keep full graph coverage over a corpus that exceeds the cap, split the corpus across several knowledge bases.
Searching with the Graph
searchMode: 'graph' is the default mode on a graph-enabled knowledge base, so an omitted searchMode uses the graph. The other modes stay available and are documented under Searching a Knowledge Base.
Tune the traversal with graphOptions:
| Option | Default | Limit | Description |
|---|---|---|---|
seedLimit | 8 | 25 | Entities seeded by vector search before expansion. |
maxHops | 2 | 3 | How far the traversal expands from each seed. |
includeGraphContext | false | Attaches the traversed subgraph to the response. |
const chunks = await squid
.ai()
.knowledgeBase('filings-knowledgebase')
.search({
prompt: 'How is Aldous Corporation connected to Dublin?',
searchMode: 'graph',
graphOptions: { seedLimit: 12, maxHops: 3 },
});
Raising maxHops widens the net and pulls in weaker associations, so raise it only for genuinely multi-hop questions.
Scoping a search to a concept
graphFilter restricts a search to the documents under one concept of the graph, which is useful when the user has already narrowed the subject. It composes with every search mode and with contextMetadataFilter, so you can combine a thematic scope with a metadata filter.
const chunks = await squid
.ai()
.knowledgeBase('filings-knowledgebase')
.search({
prompt: 'What was the purchase price?',
// A theme name, a facet value, or a facet nodeId. Names are resolved server side.
graphFilter: { underConcept: 'Acquisitions' },
});
Facet scopes filter by exact metadata membership. Theme scopes are thematic, so they are recall-safe but fuzzy: a document outside the theme's current membership is excluded even if it is relevant. An unresolvable concept fails with CONCEPT_NOT_FOUND and the nearest matching names.
Inspecting the traversal
search() returns chunks only. To see which entities and relationships produced them, use searchWithGraphContext() with includeGraphContext, which is useful for debugging retrieval and for showing users why a passage was returned.
const response = await squid
.ai()
.knowledgeBase('filings-knowledgebase')
.searchWithGraphContext({
prompt: 'Which drugs does Aldous Corporation sell?',
searchMode: 'graph',
graphOptions: { includeGraphContext: true },
});
for (const entity of response.graphContext?.entities ?? []) {
console.log(`${entity.name} (${entity.type}): ${entity.description}`);
}
for (const rel of response.graphContext?.relationships ?? []) {
console.log(`${rel.source} --[${rel.type}]--> ${rel.target}`);
}
graphContext is present only when the search ran in graph mode with includeGraphContext: true.
Querying the Graph Directly
queryGraph() reads the graph's structure rather than retrieving passages. Every operation is a single round trip that returns a complete answer.
| Op | Required input | Returns |
|---|---|---|
overview | A rendered map of the knowledge base: its themes, facets, and counts. | |
resolve | ref | Nodes matching a name, across documents, entities, themes, and facets. |
describe | ref | One node with its ancestors and immediate neighbors. |
subtree | ref | The node's subtree with document counts, to depth (default and cap 3). |
docsUnder | ref | Documents under the node's closure, with totalDocs reporting the full size. |
conceptsOf | contextId | The facets and themes one document belongs to. |
neighborhood | ref | The entity subgraph around an entity, expanded by hops (default 1, max 2). |
pathBetween | ref and refB | The relationship chain connecting two nodes, or the concepts they share. |
globalSummary | query | The knowledge base's theme summaries, ranked against a question. |
const kb = this.squid.ai().knowledgeBase('filings-knowledgebase');
// What is in this knowledge base?
const overview = await kb.queryGraph({ op: 'overview' });
console.log(overview.overview);
// How are two things connected?
const path = await kb.queryGraph({
op: 'pathBetween',
ref: 'Aldous Corporation',
refB: 'Zalofen',
});
if (path.pathBetween?.tier === 'entity') {
// An explanatory chain: Aldous Corporation acquired Bfarma Labs, which manufactures Zalofen.
for (const step of path.pathBetween.steps ?? []) {
console.log(`${step.from} --[${step.type}]--> ${step.to}`);
}
} else {
// No entity chain exists, so the answer falls back to the concepts both refs sit under.
console.log(path.pathBetween?.sharedConcepts);
}
ref accepts either a name, which Squid resolves server side by exact match, then alias, then similarity, or a facet nodeId. Only facet nodeIds and document contextIds are stable handles worth persisting. Theme and entity ids change on every rebuild, so resolve them by name each time.
Exploring the Graph
exploreGraph() returns a bounded slice of the entity graph for visualization: the highest degree entities and the relationships among them.
const subgraph = await this.squid.ai().knowledgeBase('filings-knowledgebase').exploreGraph({
nodeLimit: 100, // Default 200, max 1000.
});
console.log(`${subgraph.nodes.length} nodes, ${subgraph.edges.length} edges`);
// topics maps each node's topicPath ids to names, so a visualization can group by theme.
console.log(subgraph.topics?.map((topic) => topic.name));
Pass topicId to restrict the slice to one theme. Theme ids come from getGraphStatus().topics or from a previous exploreGraph() response, and are only valid until the next rebuild.
Monitoring the Graph
getGraphStatus() reports build state, coverage, and cost.
const status = await this.squid.ai().knowledgeBase('filings-knowledgebase').getGraphStatus();
console.log(`${status.contextsIndexed}/${status.contextsTotal} contexts graph indexed`);
console.log(`${status.entityCount} entities, ${status.relationshipCount} relationships`);
console.log(`Graph ingest cost so far: $${status.ingestUsage?.estCostUsd ?? 0}`);
if (status.buildJob?.status === 'in_progress') {
console.log(`Build running since ${new Date(status.buildJob.startedAt!).toISOString()}`);
} else if (status.structureStale) {
console.log(`${status.staleDocCount} documents changed since the last build`);
}
if (status.nextAutoBuildAt) {
console.log(`Next automatic build around ${new Date(status.nextAutoBuildAt).toISOString()}`);
}
Fields worth knowing:
| Field | Meaning |
|---|---|
enabled | Whether the graph is on for this knowledge base. |
contextsIndexed / contextsTotal | Extraction coverage. Equal values mean every context is in the graph. A standing gap means contexts were skipped at the graph indexing cap. |
entityCount / relationshipCount | Size of the entity graph. |
topics / facets | The concept layer, populated once a build has run. |
structureStale / staleDocCount | Documents graph indexed since the last concept build. Themes lag content until the next build. |
buildJob | The most recent build: status, startedAt, and error when it failed. |
lastStructureBuildAt | When a build last completed successfully. Absent if none ever has. |
nextAutoBuildAt | When the pending automatic build is due. Absent when none is scheduled. |
ingestUsage / lastRebuildUsage | LLM tokens and estimated cost, split per model under byModel. |
To wait for a graph to be fully ready, poll until contextsIndexed === contextsTotal and buildJob is not 'in_progress'. On a knowledge base that has hit the graph indexing cap, contextsIndexed stays below contextsTotal permanently and the status carries no skipped count to subtract, so bound the wait: treat the graph as settled once contextsIndexed stops advancing between polls while buildJob is not 'in_progress'.
Using the Graph from the Console
The Squid Console exposes the same feature without code:
- Create a knowledge base with Vector Store set to
mongoAtlas, then turn on the Knowledge Graph toggle. The toggle stays disabled for any other store, including Server default, because the store cannot be changed after creation. A knowledge base created with Server default that resolves to Atlas can have the graph enabled afterwards by editing it. - The knowledge base page shows a Knowledge Graph card with the last build time, how many documents changed since it, the estimated time of the next automatic build, and a Build now button that runs a structural build immediately.
Agent Behavior
Connected agents pick up the graph on their own. On a graph-enabled knowledge base:
- The knowledge base search tool offers
'graph'alongside the other modes and treats it as the default, so multi-hop questions use graph retrieval without any prompting on your part. - The tool description carries a compact overview of the graph, giving the agent the corpus themes and counts before it searches.
- The agent gets a
queryKnowledgeGraphtool backed by the operations above, so it can navigate the corpus structure rather than only retrieving passages. - The agent broadcasts status updates as it works: running
queryKnowledgeGraphreports the titleQuerying Knowledge Base Graph, distinct from theAccessing Knowledge Basetitle of an ordinary knowledge base search, so a chat UI can show graph navigation as its own step.
See Agent abilities for connecting a knowledge base to an agent.
Error Handling
| Error | Cause | Fix |
|---|---|---|
CONCEPT_NOT_FOUND | A queryGraph() ref matched nothing. The message lists the nearest names. | Resolve names with op: 'resolve' first, or use one of the suggested names. |
TOPIC_NOT_FOUND | An exploreGraph() topicId is unknown, usually because a rebuild churned the ids. | Re-read theme ids from getGraphStatus() after each rebuild instead of persisting them. |
JOB_ALREADY_EXISTS | A rebuild is already running for this knowledge base. | Wait for getGraphStatus().buildJob to leave 'in_progress'. |
| Graph calls succeed but return empty | The knowledge base is not on mongoAtlas, or graphRag.enabled is false. Graph operations are no-ops there, except rebuildGraph() (see below). | Check getGraphStatus().enabled and the knowledge base's vectorDbType. |
buildJob.status === 'failed' | The build hit an error, recorded in buildJob.error. | Fix the underlying cause and rerun rebuildGraph(). The next automatic build also retries. |
contextsIndexed stalls below contextsTotal | The knowledge base is over the graph indexing cap; upsertContexts() lists the skipped contexts in graphIndexingSkippedContextIds, and upsertContext() sets graphIndexingSkipped. | Not a failure: the contexts stay fully searchable outside the graph. Split the corpus across knowledge bases for full graph coverage. |
Disabling graphRag makes graph search unavailable but retains the extracted graph data, so re-enabling is cheap: a structural build folds in anything not yet indexed, reusing what was already extracted. To actually delete the graph data, call rebuildGraph() while graphRag.enabled is false: it is the one graph call that still acts on a disabled knowledge base, and on a disabled one it wipes the graph instead of building it. Deleting the knowledge base also removes it.
Best Practices
- Decide on the graph before creating the knowledge base, and pass
vectorDbTypeexplicitly. It is immutable, so a'postgres'knowledge base can never gain a graph. - Pass
entityTypesthat match your domain. Extraction is markedly more reliable for proper nouns such as organizations, people, and locations than for generic product categories. - Enable the graph before bulk ingesting. The graph is then queryable as ingestion lands, rather than only after the knowledge base goes quiet and the automatic backfill build finishes. Enabling it afterwards still works: the backfill runs a structural build over the backlog.
- Leave
maxHopsat its default. Raise it for questions that are genuinely several hops deep, and expect looser results when you do. - Watch
ingestUsage.estCostUsdon a small corpus first to project the cost of enabling the graph over everything. - Widen
autoBuildDebounceMsfor knowledge bases under continuous ingestion, so builds do not chase a moving target, and shorten it when you want themes to refresh quickly after small updates. - Resolve theme names, not ids, across rebuilds. Only facet nodeIds and document contextIds are stable.