From ba5c9a8910eec780a01d8586f7427030c073e4e9 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 23 Jun 2026 17:11:49 +0100 Subject: [PATCH] fix: graph rag ui improvements --- application/api/user/sources/routes.py | 12 +- application/app.py | 1 + frontend/package-lock.json | 2 + frontend/package.json | 2 + frontend/src/components/GraphView.tsx | 292 +++++++++++------- .../src/components/graphViewUtils.test.ts | 32 ++ frontend/src/components/graphViewUtils.ts | 25 ++ frontend/src/locale/en.json | 20 +- frontend/src/models/misc.ts | 8 + frontend/src/settings/EnableGraphRAGModal.tsx | 13 +- frontend/src/settings/SourceConfigModal.tsx | 26 ++ frontend/src/settings/Sources.tsx | 60 ++-- .../components/RetrievalOptions.test.tsx | 89 +++++- .../settings/components/RetrievalOptions.tsx | 252 +++++++++++++-- .../src/settings/graphragEnableUtils.test.ts | 30 ++ frontend/src/settings/graphragEnableUtils.ts | 32 ++ frontend/src/upload/Upload.tsx | 45 ++- tests/test_app_routes.py | 20 ++ 18 files changed, 787 insertions(+), 174 deletions(-) diff --git a/application/api/user/sources/routes.py b/application/api/user/sources/routes.py index f70e97f0..134deb58 100644 --- a/application/api/user/sources/routes.py +++ b/application/api/user/sources/routes.py @@ -1104,14 +1104,18 @@ class EnableSourceGraphRAG(Resource): if not decoded_token: return make_response(jsonify({"success": False}), 401) if not graphrag_available(): + current_app.logger.warning( + "GraphRAG enable rejected for %s: unavailable " + "(VECTOR_STORE=%s, GRAPHRAG_ENABLED=%s)", + source_id, + settings.VECTOR_STORE, + settings.GRAPHRAG_ENABLED, + ) return make_response( jsonify( { "success": False, - "message": ( - "GraphRAG requires VECTOR_STORE=pgvector and " - "GRAPHRAG_ENABLED." - ), + "message": "GraphRAG isn't available on this workspace.", } ), 400, diff --git a/application/app.py b/application/app.py index a1f5f4e9..ad00d0b6 100644 --- a/application/app.py +++ b/application/app.py @@ -132,6 +132,7 @@ def get_config(): "auth_type": settings.AUTH_TYPE, "requires_auth": settings.AUTH_TYPE in ["simple_jwt", "session_jwt", "oidc"], "graphrag_available": graphrag_available(), + "hybrid_available": settings.VECTOR_STORE == "pgvector", } if settings.AUTH_TYPE == "oidc": response["oidc"] = { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b783020e..2db56d93 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,6 +21,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "copy-to-clipboard": "^3.3.3", + "d3-force": "^3.0.0", "date-fns": "^4.2.1", "i18next": "^26.0.4", "i18next-browser-languagedetector": "^8.2.1", @@ -49,6 +50,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.2.4", + "@types/d3-force": "^3.0.10", "@types/lodash": "^4.17.20", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/frontend/package.json b/frontend/package.json index 2b97bb44..e108a09e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -34,6 +34,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "copy-to-clipboard": "^3.3.3", + "d3-force": "^3.0.0", "date-fns": "^4.2.1", "i18next": "^26.0.4", "i18next-browser-languagedetector": "^8.2.1", @@ -62,6 +63,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.2.4", + "@types/d3-force": "^3.0.10", "@types/lodash": "^4.17.20", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/frontend/src/components/GraphView.tsx b/frontend/src/components/GraphView.tsx index f995779c..d823eec2 100644 --- a/frontend/src/components/GraphView.tsx +++ b/frontend/src/components/GraphView.tsx @@ -1,8 +1,15 @@ +import { forceCollide, type SimulationNodeDatum } from 'd3-force'; import { Network, X } from 'lucide-react'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; -import ForceGraph2D from 'react-force-graph-2d'; +import ForceGraph2D, { type ForceGraphMethods } from 'react-force-graph-2d'; import userService from '../api/services/userService'; import ArrowLeft from '../assets/arrow-left.svg'; @@ -14,9 +21,11 @@ import { GraphNode, GraphNodeDetail, GraphOverview, + collideRadius, maxDegree, nodeLabelEl, nodeRadius, + pointerAreaRadius, toForceGraphData, } from './graphViewUtils'; @@ -44,7 +53,17 @@ const GraphView: React.FC = ({ const [loadingNode, setLoadingNode] = useState(false); const containerRef = useRef(null); + const hoveredNodeIdRef = useRef(null); + const fgRef = useRef(undefined); const [size, setSize] = useState({ width: 0, height: 480 }); + // Bumped after zoom/pan settles to re-supply nodePointerAreaPaint, which makes + // force-graph repaint the hit-test (pick) canvas immediately. The engine + // otherwise throttles that repaint by 800ms, leaving the pick discs at their + // pre-zoom positions — so just after a zoom the cursor reads stale/empty + // pixels and hover/click silently fail. The miss scales with the screen + // distance a node moved (largest for the biggest, highest-degree hubs) and is + // doubled on Retina, where each pick reads a single device pixel. + const [pickRefresh, setPickRefresh] = useState(0); useEffect(() => { let cancelled = false; @@ -82,6 +101,17 @@ const GraphView: React.FC = ({ const maxNodeDegree = useMemo(() => maxDegree(data.nodes), [data.nodes]); + useEffect(() => { + if (!fgRef.current || data.nodes.length === 0) return; + fgRef.current.d3Force( + 'collide', + forceCollide((node) => + collideRadius(nodeRadius((node as GraphNode).degree, maxNodeDegree)), + ), + ); + fgRef.current.d3ReheatSimulation(); + }, [data, maxNodeDegree, size.width]); + const handleNodeClick = (node: GraphNode) => { setLoadingNode(true); setSelectedNode(null); @@ -95,6 +125,24 @@ const GraphView: React.FC = ({ .finally(() => setLoadingNode(false)); }; + // Paints the hit-test (pick) disc for a node. Its identity is keyed on + // `pickRefresh` so re-supplying it after a zoom/pan settles forces force-graph + // to repaint the pick canvas immediately rather than after its 800ms throttle. + const nodePointerAreaPaint = useCallback( + (node: object, color: string, ctx: CanvasRenderingContext2D) => { + const graphNode = node as GraphNode & { x?: number; y?: number }; + if (graphNode.x == null || graphNode.y == null) return; + const r = pointerAreaRadius(nodeRadius(graphNode.degree, maxNodeDegree)); + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(graphNode.x, graphNode.y, r, 0, 2 * Math.PI); + ctx.fill(); + }, + // pickRefresh is an intentional dependency: bumping it re-supplies this + // callback, which makes force-graph flush the pick canvas after a zoom/pan. + [maxNodeDegree, pickRefresh], + ); + const isEmpty = !loading && data.nodes.length === 0; return ( @@ -138,111 +186,147 @@ const GraphView: React.FC = ({

{t('settings.sources.graphrag.view.empty')}

) : ( -
-
- {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)'} - cooldownTicks={80} - onNodeClick={(node) => handleNodeClick(node as GraphNode)} - nodeCanvasObjectMode={() => 'after'} - nodeCanvasObject={(node, ctx, globalScale) => { - const graphNode = node as GraphNode & { - x?: number; - y?: number; - }; - if (globalScale < 1.2 || graphNode.x == null) return; - const label = graphNode.name ?? ''; - ctx.font = `${10 / globalScale}px sans-serif`; - ctx.fillStyle = 'rgba(120,120,120,0.95)'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'top'; - ctx.fillText( - label, - graphNode.x, - (graphNode.y ?? 0) + - nodeRadius(graphNode.degree, maxNodeDegree) / - globalScale + - 1, - ); - }} - /> - )} -
+
+

+ {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); + } + }} + /> + )} +
- + ) : ( +

+ {t('settings.sources.graphrag.view.selectNode')} +

+ )} + +
)}
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(), + })} +