+
+ {t('settings.sources.graphrag.view.stats', {
+ nodes: data.nodes.length,
+ edges: data.links.length,
+ })}
+
+
+
+ {size.width > 0 && (
+
+ nodeRadius((node as GraphNode).degree, maxNodeDegree)
+ }
+ nodeLabel={(node) =>
+ nodeLabelEl(
+ (node as GraphNode).name,
+ ) as unknown as React.ReactHTMLElement
+ }
+ linkColor={() => 'rgba(150,150,150,0.35)'}
+ linkPointerAreaPaint={() => {}}
+ cooldownTicks={80}
+ onEngineStop={() => {
+ const fg = fgRef.current;
+ if (!fg) return;
+ fg.zoom(fg.zoom());
+ setPickRefresh((n) => n + 1);
+ }}
+ onZoomEnd={() => setPickRefresh((n) => n + 1)}
+ onNodeClick={(node) => handleNodeClick(node as GraphNode)}
+ onNodeHover={(node) => {
+ hoveredNodeIdRef.current = node
+ ? (node as GraphNode).id
+ : null;
+ if (containerRef.current) {
+ containerRef.current.style.cursor = node
+ ? 'pointer'
+ : 'default';
+ }
+ }}
+ nodePointerAreaPaint={nodePointerAreaPaint}
+ nodeCanvasObject={(node, ctx, globalScale) => {
+ const graphNode = node as GraphNode & {
+ x?: number;
+ y?: number;
+ };
+ if (graphNode.x == null) return;
+ const r = nodeRadius(graphNode.degree, maxNodeDegree);
+ const hovered = hoveredNodeIdRef.current === graphNode.id;
+ ctx.beginPath();
+ ctx.arc(graphNode.x, graphNode.y ?? 0, r, 0, 2 * Math.PI);
+ ctx.fillStyle = hovered ? '#7aa7d9' : '#4a7fb5';
+ ctx.fill();
+ if (globalScale >= 1.2) {
+ const label = graphNode.name ?? '';
+ const x = graphNode.x;
+ const y = (graphNode.y ?? 0) + r + 1;
+ ctx.font = `${10 / globalScale}px sans-serif`;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'top';
+ ctx.lineWidth = 3 / globalScale;
+ ctx.lineJoin = 'round';
+ ctx.strokeStyle = 'rgba(10,10,10,0.85)';
+ ctx.strokeText(label, x, y);
+ ctx.fillStyle = hovered
+ ? 'rgba(255,255,255,0.98)'
+ : 'rgba(210,210,210,0.95)';
+ ctx.fillText(label, x, y);
+ }
+ }}
+ />
+ )}
+
-
diff --git a/frontend/src/components/graphViewUtils.test.ts b/frontend/src/components/graphViewUtils.test.ts
index 9e723054..05a515b1 100644
--- a/frontend/src/components/graphViewUtils.test.ts
+++ b/frontend/src/components/graphViewUtils.test.ts
@@ -2,9 +2,11 @@ import { describe, expect, it } from 'vitest';
import {
GraphOverview,
+ collideRadius,
maxDegree,
nodeLabelEl,
nodeRadius,
+ pointerAreaRadius,
toForceGraphData,
} from './graphViewUtils';
@@ -80,3 +82,33 @@ describe('nodeRadius', () => {
expect(nodeRadius(10, 10)).toBeCloseTo(12);
});
});
+
+describe('pointerAreaRadius', () => {
+ it('enforces a forgiving minimum hit target for small nodes', () => {
+ const smallest = nodeRadius(0, 0);
+ expect(pointerAreaRadius(smallest)).toBe(9);
+ });
+
+ it('always pads beyond the visual radius for larger nodes', () => {
+ const largest = nodeRadius(10, 10);
+ expect(pointerAreaRadius(largest)).toBeGreaterThan(largest);
+ expect(pointerAreaRadius(largest)).toBeCloseTo(14);
+ });
+});
+
+describe('collideRadius', () => {
+ it('keeps centres farther apart than any node could be picked', () => {
+ // The picking guarantee: with forceCollide(collideRadius), two centres
+ // are >= 2*collideRadius apart, which must exceed the largest pick disc
+ // so a centre is never covered by a neighbour's pick disc.
+ const smallVisual = nodeRadius(0, 0);
+ const largeVisual = nodeRadius(10, 10);
+ const minCentreSpacing = 2 * collideRadius(smallVisual);
+ expect(minCentreSpacing).toBeGreaterThan(pointerAreaRadius(largeVisual));
+ });
+
+ it('always exceeds the pick disc for the same node', () => {
+ const visual = nodeRadius(3, 57);
+ expect(collideRadius(visual)).toBeGreaterThan(pointerAreaRadius(visual));
+ });
+});
diff --git a/frontend/src/components/graphViewUtils.ts b/frontend/src/components/graphViewUtils.ts
index a91ed836..432ddd74 100644
--- a/frontend/src/components/graphViewUtils.ts
+++ b/frontend/src/components/graphViewUtils.ts
@@ -44,6 +44,9 @@ export function toForceGraphData(overview: GraphOverview): ForceGraphData {
const MIN_NODE_RADIUS = 3;
const MAX_NODE_RADIUS = 12;
+const MIN_POINTER_RADIUS = 7;
+const POINTER_RADIUS_PAD = 2;
+const COLLIDE_PAD = 2;
export function nodeRadius(degree: number, maxDegree: number): number {
if (maxDegree <= 0) return MIN_NODE_RADIUS;
@@ -51,6 +54,28 @@ export function nodeRadius(degree: number, maxDegree: number): number {
return MIN_NODE_RADIUS + scale * (MAX_NODE_RADIUS - MIN_NODE_RADIUS);
}
+/**
+ * Hit-test disc radius for a node in the color-picking canvas.
+ *
+ * Kept deliberately close to the visual radius: the engine resolves
+ * hover/click by reading a single pixel at the node centre, so a pick disc
+ * larger than the inter-node spacing lets a neighbour's disc cover this
+ * node's centre and steal its picks. Pair with {@link collideRadius}, which
+ * spaces centres farther apart than the largest pick disc so a centre is
+ * never covered by another node.
+ */
+export function pointerAreaRadius(visualRadius: number): number {
+ return Math.max(visualRadius, MIN_POINTER_RADIUS) + POINTER_RADIUS_PAD;
+}
+
+/**
+ * Collision radius keeping node centres farther apart than any pick disc,
+ * so every centre pixel stays clean for the engine's exact-colour lookup.
+ */
+export function collideRadius(visualRadius: number): number {
+ return pointerAreaRadius(visualRadius) + COLLIDE_PAD;
+}
+
export function maxDegree(nodes: GraphNode[]): number {
return nodes.reduce((acc, node) => Math.max(acc, node.degree || 0), 0);
}
diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json
index f498395b..65fe5f63 100644
--- a/frontend/src/locale/en.json
+++ b/frontend/src/locale/en.json
@@ -159,7 +159,7 @@
"introGeneric": "Build a knowledge graph over this source so agents can reason across linked entities and relationships.",
"costExtraction": "Entities and relationships are extracted by running an LLM over this source's chunks.",
"costCost": "This makes one LLM call per chunk, so it consumes tokens and may take a while on large sources.",
- "costPgvector": "GraphRAG is available only with the pgvector store.",
+ "estimate": "~1 LLM call per chunk · ~{{lo}}–{{hi}} total tokens (about half input, half output); output varies with how entity-dense the document is.",
"confirm": "Enable GraphRAG",
"inProgress": "Building graph…",
"summaryNodes_one": "{{count}} entity",
@@ -179,6 +179,7 @@
"action": "View graph",
"title": "Knowledge graph",
"explainer": "Nodes are entities and edges are their relationships. Click a node to see its description and the chunks it was extracted from.",
+ "stats": "{{nodes}} entities · {{edges}} relationships",
"empty": "No graph yet — extraction may still be running.",
"selectNode": "Select a node to see its details.",
"linkedChunks": "Linked chunks",
@@ -224,14 +225,15 @@
"chunks": "Chunks to retrieve (top-k)",
"scoreThreshold": "Score threshold",
"scoreThresholdPlaceholder": "None",
- "scoreThresholdHint": "Best-effort: only pgvector and MongoDB honor this; other vector stores ignore it.",
+ "scoreThresholdHint": "Only return chunks above this relevance score (0–1); higher is stricter.",
"rephraseQuery": "Rephrase query before search",
"retriever": "Retriever",
"retrievers": {
"classic": "Classic",
- "hybrid": "Hybrid"
+ "hybrid": "Hybrid",
+ "graphrag": "GraphRAG"
},
- "retrieverHint": "Hybrid (keyword + vector) search is supported on pgvector; other vector stores fall back to vector-only.",
+ "retrieverHint": "Hybrid combines keyword + vector search. GraphRAG builds a knowledge graph over this source for multi-hop questions.",
"exposure": "Search exposure",
"exposures": {
"prefetch": "Pre-fetch into context",
@@ -248,6 +250,16 @@
"model": "Model (optional)",
"modelPlaceholder": "Defaults to the request model"
},
+ "graph": {
+ "title": "Graph extraction",
+ "tag": "re-ingest to apply",
+ "extractionModel": "Extraction model",
+ "extractionModelHint": "LLM used to extract entities and relationships from each chunk.",
+ "defaultModel": "Use default model",
+ "maxChunks": "Max chunks to extract",
+ "maxChunksHint": "Cap how many chunks are sent for extraction. Leave empty to use the instance default.",
+ "maxChunksPlaceholder": "Default"
+ },
"chunking": {
"title": "Chunking",
"note": "Ingest-time settings. Changing these requires re-ingesting the source.",
diff --git a/frontend/src/models/misc.ts b/frontend/src/models/misc.ts
index 7b821f2b..fc8b0782 100644
--- a/frontend/src/models/misc.ts
+++ b/frontend/src/models/misc.ts
@@ -42,10 +42,18 @@ export type SourceRetrievalConfig = {
prescreen?: SourcePrescreenConfig | null; // null = off
};
+// Ingest-time GraphRAG extraction knobs (only used when kind === 'graphrag').
+export type SourceGraphConfig = {
+ extraction_model?: string | null; // null → instance default model
+ max_chunks?: number | null; // null → GRAPHRAG_MAX_CHUNKS_FOR_EXTRACTION
+ gleanings?: number; // extra extraction passes per chunk, default 0
+};
+
export type SourceConfig = {
kind?: string; // default 'classic'
chunking?: SourceChunkingConfig;
retrieval?: SourceRetrievalConfig;
+ graph?: SourceGraphConfig;
};
export type Doc = {
diff --git a/frontend/src/settings/EnableGraphRAGModal.tsx b/frontend/src/settings/EnableGraphRAGModal.tsx
index d7694d0c..ccad352b 100644
--- a/frontend/src/settings/EnableGraphRAGModal.tsx
+++ b/frontend/src/settings/EnableGraphRAGModal.tsx
@@ -11,6 +11,7 @@ import { selectToken } from '../preferences/preferenceSlice';
import {
GraphRAGSummary,
+ estimateGraphTokens,
pollTaskOnce,
startGraphRAG,
} from './graphragEnableUtils';
@@ -35,6 +36,11 @@ export default function EnableGraphRAGModal({
const { t } = useTranslation();
const token = useSelector(selectToken);
+ const estimate = estimateGraphTokens(
+ Number(document?.tokens) || 0,
+ document?.config?.chunking?.max_tokens,
+ );
+
const [phase, setPhase] = useState
('confirm');
const [error, setError] = useState(null);
const [summary, setSummary] = useState(null);
@@ -161,8 +167,13 @@ export default function EnableGraphRAGModal({
- {t('settings.sources.graphrag.enable.costExtraction')}
- {t('settings.sources.graphrag.enable.costCost')}
- - {t('settings.sources.graphrag.enable.costPgvector')}
+
+ {t('settings.sources.graphrag.enable.estimate', {
+ lo: estimate.lo.toLocaleString(),
+ hi: estimate.hi.toLocaleString(),
+ })}
+