Graph RAG agent is able to execute cyfers, but needs more testing, something is wrong ;-;

This commit is contained in:
abhigyanpatwari 2026-01-06 05:12:11 +05:30
parent b154d6c2ef
commit 4bcb9fbc74
6 changed files with 263 additions and 60 deletions

View file

@ -105,8 +105,9 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved }: SettingsPane
// Fetch models when API key is entered (debounced)
useEffect(() => {
if (settings.activeProvider === 'gemini' && settings.gemini?.apiKey) {
const apiKey = settings.gemini.apiKey ?? '';
const timer = setTimeout(() => {
fetchModels(settings.gemini!.apiKey);
fetchModels(apiKey);
}, 500);
return () => clearTimeout(timer);
}
@ -260,7 +261,7 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved }: SettingsPane
{settings.gemini?.apiKey && !isLoadingModels && (
<button
type="button"
onClick={() => fetchModels(settings.gemini!.apiKey)}
onClick={() => fetchModels(settings.gemini?.apiKey ?? '')}
className="p-1 text-text-muted hover:text-text-primary transition-colors"
title="Refresh models"
>

View file

@ -275,10 +275,12 @@ export const semanticSearch = async (
const queryVecStr = `[${queryVec.join(',')}]`;
// Query the vector index on CodeEmbedding, then JOIN with CodeNode for metadata
// Note: KuzuDB requires WITH after YIELD before using WHERE
const cypher = `
CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx',
CAST(${queryVecStr} AS FLOAT[384]), ${k})
YIELD node AS emb, distance
WITH emb, distance
WHERE distance < ${maxDistance}
MATCH (n:CodeNode {id: emb.nodeId})
RETURN n.id AS nodeId, n.name AS name, n.label AS label,
@ -301,22 +303,24 @@ export const semanticSearch = async (
};
/**
* Semantic search with graph expansion
* Finds similar nodes AND their connections
* Semantic search with graph expansion (flattened results)
* Finds similar nodes AND their direct connections with relationship types
*
* Uses separate CodeEmbedding table and JOINs with CodeNode
* Uses separate CodeEmbedding table and JOINs with CodeNode.
* Returns flattened results: one row per (match, connected) pair.
* This format works with KuzuDB and preserves relationship type information.
*
* @param executeQuery - Function to execute Cypher queries
* @param query - Search query text
* @param k - Number of initial results
* @param hops - Number of hops to expand (default: 2)
* @returns Search results with connected nodes
* @param k - Number of initial semantic matches (default: 5)
* @param _hops - Unused (kept for API compatibility). Use execute_vector_cypher for multi-hop.
* @returns Flattened results: each row is a (match → connected) pair with relationship type
*/
export const semanticSearchWithContext = async (
executeQuery: (cypher: string) => Promise<any[]>,
query: string,
k: number = 5,
hops: number = 2
_hops: number = 1 // Currently only single-hop supported; multi-hop via execute_vector_cypher
): Promise<any[]> => {
if (!isEmbedderReady()) {
throw new Error('Embedding model not initialized. Run embedding pipeline first.');
@ -327,23 +331,22 @@ export const semanticSearchWithContext = async (
const queryVec = embeddingToArray(queryEmbedding);
const queryVecStr = `[${queryVec.join(',')}]`;
// Query embedding table, JOIN with CodeNode, then expand graph
// Query embedding table, JOIN with CodeNode, then expand to direct connections
// Using single-hop so we can access r.type (variable-length paths don't support this in KuzuDB)
// Note: KuzuDB requires WITH after YIELD before using WHERE
const cypher = `
CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx',
CAST(${queryVecStr} AS FLOAT[384]), ${k})
YIELD node AS emb, distance
WITH emb, distance
WHERE distance < 0.5
MATCH (match:CodeNode {id: emb.nodeId})
MATCH (match)-[r:CodeRelation*1..${hops}]-(connected:CodeNode)
MATCH (match)-[r:CodeRelation]-(connected:CodeNode)
RETURN match.id AS matchId, match.name AS matchName, match.label AS matchLabel,
match.filePath AS matchPath, distance,
collect(DISTINCT {
id: connected.id,
name: connected.name,
label: connected.label,
relationType: [rel IN r | rel.type]
}) AS connections
ORDER BY distance
connected.id AS connectedId, connected.name AS connectedName,
connected.label AS connectedLabel, r.type AS relationType
ORDER BY distance, matchId
`;
return executeQuery(cypher);

View file

@ -21,11 +21,12 @@ import type {
/**
* System prompt for the Graph RAG agent
*/
const SYSTEM_PROMPT = `You are Nexus AI, an intelligent code analysis assistant. You help developers understand codebases by querying a knowledge graph that contains code structure, relationships, and semantic embeddings.
const SYSTEM_PROMPT = `You are Nexus AI, an intelligent code analysis assistant. You help developers understand codebases by querying a knowledge graph (KuzuDB) that contains code structure, relationships, and semantic embeddings.
CAPABILITIES:
- Execute Cypher queries to explore code structure (functions, classes, files, imports, call graphs)
- Perform semantic search to find code by meaning (when embeddings are available)
- Combine semantic search + graph traversal in a SINGLE Cypher query via the vector index (when embeddings are available)
- Trace dependencies and relationships between code elements
- Explain code architecture and patterns
@ -33,12 +34,37 @@ APPROACH:
1. Start by understanding what the user wants to know
2. Choose the right tool(s) for the task:
- Use 'get_codebase_stats' first if you need an overview
- Use 'semantic_search' for concept-based queries ("find authentication logic")
- Use 'execute_cypher' for structural queries ("what functions does X call?")
- Use 'semantic_search' for simple concept-based lookup (find relevant nodes)
- Use 'semantic_search_with_context' for simple semantic + neighborhood expansion (prebuilt 1-3 hop expansion)
- Use 'execute_vector_cypher' when you need semantic search + CUSTOM traversal/filters/returns in ONE query
- Your Cypher MUST include the placeholder {{QUERY_VECTOR}} where a FLOAT[384] vector belongs
- The vector index is on CodeEmbedding (code_embedding_idx). You must JOIN back to CodeNode via emb.nodeId
- Use 'execute_cypher' for pure structural queries (no vector search)
- Use 'get_code_content' to show actual source code
3. Interpret results and explain them clearly
4. Suggest follow-up explorations when relevant
IMPORTANT NOTES ABOUT THE DATABASE:
- Nodes are stored in CodeNode(id, label, name, filePath, startLine, endLine, content)
- Edges are stored in CodeRelation(FROM CodeNode TO CodeNode, type) where type ∈ {CALLS, IMPORTS, CONTAINS, DEFINES}
- Embeddings are stored separately in CodeEmbedding(nodeId, embedding) for memory efficiency
- Vector index: code_embedding_idx on CodeEmbedding.embedding (cosine distance; smaller distance = more similar)
UNIFIED VECTOR + GRAPH QUERY PATTERN (ONE QUERY):
1) Vector search to get closest embeddings
2) JOIN to CodeNode
3) Traverse relationships / collect context
Example skeleton (note: WITH after YIELD is required in KuzuDB before WHERE):
CALL QUERY_VECTOR_INDEX('CodeEmbedding','code_embedding_idx', {{QUERY_VECTOR}}, 10)
YIELD node AS emb, distance
WITH emb, distance
WHERE distance < 0.5
MATCH (match:CodeNode {id: emb.nodeId})
MATCH (match)-[r:CodeRelation*1..2]-(ctx:CodeNode)
RETURN match.name, match.label, match.filePath, distance, collect(DISTINCT ctx.name) AS context
ORDER BY distance
STYLE:
- Be concise but thorough
- Use code formatting when showing results
@ -163,15 +189,16 @@ export async function* streamAgentResponse(
for await (const event of stream) {
const { event: eventType, data } = event;
const dataAny = data as any;
// Handle tool calls start
if (eventType === 'on_tool_start') {
yield {
type: 'tool_call',
toolCall: {
id: data.tool_call_id || Date.now().toString(), // fallback if ID missing
name: event.name,
args: data.input,
id: dataAny?.tool_call_id || dataAny?.toolCallId || Date.now().toString(), // fallback if ID missing
name: (event as any).name,
args: dataAny?.input ?? {},
status: 'running',
},
};
@ -182,10 +209,10 @@ export async function* streamAgentResponse(
yield {
type: 'tool_result',
toolCall: {
id: data.tool_call_id || '', // we might need to match by name if ID missing
name: event.name,
id: dataAny?.tool_call_id || dataAny?.toolCallId || '', // we might need to match by name if ID missing
name: (event as any).name,
args: {},
result: typeof data.output === 'string' ? data.output : JSON.stringify(data.output),
result: typeof dataAny?.output === 'string' ? dataAny.output : JSON.stringify(dataAny?.output),
status: 'completed',
},
};
@ -193,7 +220,7 @@ export async function* streamAgentResponse(
// Handle streamed LLM content
if (eventType === 'on_chat_model_stream') {
const content = data.chunk?.content;
const content = dataAny?.chunk?.content;
if (content && typeof content === 'string') {
yield {
type: 'content',

View file

@ -69,26 +69,56 @@ export const saveSettings = (settings: LLMSettings): void => {
export const updateProviderSettings = <T extends LLMProvider>(
provider: T,
updates: Partial<
T extends 'azure-openai' ? Omit<AzureOpenAIConfig, 'provider'> :
T extends 'gemini' ? Omit<GeminiConfig, 'provider'> :
T extends 'ollama' ? Omit<OllamaConfig, 'provider'> :
T extends 'azure-openai' ? Partial<Omit<AzureOpenAIConfig, 'provider'>> :
T extends 'gemini' ? Partial<Omit<GeminiConfig, 'provider'>> :
T extends 'ollama' ? Partial<Omit<OllamaConfig, 'provider'>> :
never
>
): LLMSettings => {
const current = loadSettings();
const providerKey = provider === 'azure-openai' ? 'azureOpenAI' : provider;
const updated: LLMSettings = {
...current,
[providerKey]: {
...current[providerKey as keyof LLMSettings],
...updates,
},
};
saveSettings(updated);
return updated;
// Avoid spreading unions like LLMSettings[keyof LLMSettings] (can be string/undefined)
switch (provider) {
case 'azure-openai': {
const updated: LLMSettings = {
...current,
azureOpenAI: {
...(current.azureOpenAI ?? {}),
...(updates as Partial<Omit<AzureOpenAIConfig, 'provider'>>),
},
};
saveSettings(updated);
return updated;
}
case 'gemini': {
const updated: LLMSettings = {
...current,
gemini: {
...(current.gemini ?? {}),
...(updates as Partial<Omit<GeminiConfig, 'provider'>>),
},
};
saveSettings(updated);
return updated;
}
case 'ollama': {
const updated: LLMSettings = {
...current,
ollama: {
...(current.ollama ?? {}),
...(updates as Partial<Omit<OllamaConfig, 'provider'>>),
},
};
saveSettings(updated);
return updated;
}
default: {
// Should be unreachable due to T extends LLMProvider, but keep a safe fallback
const updated: LLMSettings = { ...current };
saveSettings(updated);
return updated;
}
}
};
/**

View file

@ -8,6 +8,7 @@
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
import { GRAPH_SCHEMA_DESCRIPTION } from './types';
import { WebGPUNotAvailableError, embedText, embeddingToArray, initEmbedder, isEmbedderReady } from '../embeddings/embedder';
/**
* Tool factory - creates tools bound to the KuzuDB query functions
@ -59,6 +60,92 @@ export const createGraphRAGTools = (
}
);
/**
* Tool: Execute Vector Cypher Query (Unified Vector + Graph in ONE query)
*
* Lets the LLM write a Cypher query that includes a vector index call,
* while this tool handles embedding the natural-language query and injecting
* the vector into the Cypher safely.
*
* IMPORTANT:
* - The provided Cypher MUST include the placeholder {{QUERY_VECTOR}}
* - The placeholder will be replaced with: CAST([..384 floats..] AS FLOAT[384])
* - KuzuDB requires WITH after YIELD before using WHERE
*
* Example:
* CALL QUERY_VECTOR_INDEX('CodeEmbedding','code_embedding_idx', {{QUERY_VECTOR}}, 10)
* YIELD node AS emb, distance
* WITH emb, distance
* WHERE distance < 0.5
* MATCH (match:CodeNode {id: emb.nodeId}) ...
*/
const executeVectorCypherTool = tool(
async ({ query, cypher }: { query: string; cypher: string }) => {
if (!isEmbeddingReady()) {
return 'Vector Cypher is not available. Embeddings have not been generated yet.';
}
if (!cypher.includes('{{QUERY_VECTOR}}')) {
return "Invalid input: your Cypher must include the placeholder '{{QUERY_VECTOR}}' where a FLOAT[384] vector should go.";
}
try {
// Ensure embedder is loaded. If WebGPU isn't available, fall back to WASM.
if (!isEmbedderReady()) {
try {
await initEmbedder();
} catch (err) {
if (err instanceof WebGPUNotAvailableError) {
await initEmbedder(undefined, {}, 'wasm');
} else {
throw err;
}
}
}
// Embed the natural language query and inject into Cypher
const queryEmbedding = await embedText(query);
const queryVec = embeddingToArray(queryEmbedding);
const queryVecStr = `CAST([${queryVec.join(',')}] AS FLOAT[384])`;
const finalCypher = cypher.replace(/\{\{\s*QUERY_VECTOR\s*\}\}/g, queryVecStr);
const results = await executeQuery(finalCypher);
if (results.length === 0) {
return 'Query returned no results.';
}
const formatted = results.slice(0, 50).map((row, i) => {
if (Array.isArray(row)) {
return `[${i + 1}] ${row.join(', ')}`;
}
return `[${i + 1}] ${JSON.stringify(row)}`;
});
const resultText = formatted.join('\n');
const truncated = results.length > 50 ? `\n... (${results.length - 50} more results truncated)` : '';
return `Query returned ${results.length} results:\n${resultText}${truncated}`;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return `Vector Cypher error: ${message}\n\nTip: Ensure you're querying the vector index on CodeEmbedding and JOINing back to CodeNode via emb.nodeId.`;
}
},
{
name: 'execute_vector_cypher',
description:
"Execute a single Cypher query that combines vector similarity search and graph traversal. Provide a natural-language 'query' to embed, and a 'cypher' string containing the placeholder {{QUERY_VECTOR}}. Use this to do semantic search + traversal in ONE Cypher query. Remember: KuzuDB requires 'WITH emb, distance' after 'YIELD node AS emb, distance' before you can use WHERE.",
schema: z.object({
query: z.string().describe('Natural language query to embed (used to produce a FLOAT[384] vector)'),
cypher: z
.string()
.describe(
"Cypher query to execute. MUST contain {{QUERY_VECTOR}}. Pattern: CALL QUERY_VECTOR_INDEX('CodeEmbedding','code_embedding_idx', {{QUERY_VECTOR}}, 10) YIELD node AS emb, distance WITH emb, distance WHERE distance < 0.5 MATCH (n:CodeNode {id: emb.nodeId}) ..."
),
}),
}
);
/**
* Tool: Semantic Code Search
* Find code by meaning using vector embeddings
@ -99,36 +186,70 @@ export const createGraphRAGTools = (
/**
* Tool: Semantic Search with Graph Context
* Find similar code AND expand to connected nodes
* Find similar code AND expand to connected nodes (flattened format with relationship types)
*/
const semanticSearchWithContextTool = tool(
async ({ query, limit, hops }: { query: string; limit?: number; hops?: number }) => {
async ({ query, limit }: { query: string; limit?: number }) => {
if (!isEmbeddingReady()) {
return 'Semantic search is not available. Embeddings have not been generated yet. Please use execute_cypher tool for structured queries instead.';
}
try {
const results = await semanticSearchWithContext(query, limit ?? 5, hops ?? 2);
const results = await semanticSearchWithContext(query, limit ?? 5);
if (results.length === 0) {
return `No code found matching "${query}". Try a different search term.`;
}
const formatted = results.map((r, i) => {
// Results are flattened: one row per (match → connected) pair
// Group by match for cleaner output
const grouped = new Map<string, {
matchName: string;
matchLabel: string;
matchPath: string;
distance: number;
connections: Array<{ name: string; label: string; relType: string }>;
}>();
for (const r of results) {
const matchId = r.matchId ?? r[0];
const matchName = r.matchName ?? r[1];
const matchLabel = r.matchLabel ?? r[2];
const matchPath = r.matchPath ?? r[3];
const distance = r.distance ?? r[4];
const connections = r.connections ?? r[5] ?? [];
const connectedName = r.connectedName ?? r[6];
const connectedLabel = r.connectedLabel ?? r[7];
const relationType = r.relationType ?? r[8];
const connectedNames = Array.isArray(connections)
? connections.slice(0, 10).map((c: any) => c.name || c).join(', ')
: 'none';
if (!grouped.has(matchId)) {
grouped.set(matchId, {
matchName,
matchLabel,
matchPath,
distance,
connections: [],
});
}
return `[${i + 1}] ${matchLabel}: ${matchName}\n File: ${matchPath}\n Relevance: ${(1 - distance).toFixed(2)}\n Connected to: ${connectedNames}`;
grouped.get(matchId)!.connections.push({
name: connectedName,
label: connectedLabel,
relType: relationType,
});
}
// Format grouped results
const formatted = Array.from(grouped.values()).map((g, i) => {
const connectionsList = g.connections
.slice(0, 15)
.map(c => `${c.name} (${c.label}) via ${c.relType}`)
.join('\n ');
const more = g.connections.length > 15 ? `\n ... and ${g.connections.length - 15} more` : '';
return `[${i + 1}] ${g.matchLabel}: ${g.matchName}\n File: ${g.matchPath}\n Relevance: ${(1 - g.distance).toFixed(2)}\n Connections:\n ${connectionsList}${more}`;
});
return `Found ${results.length} code elements with context:\n\n${formatted.join('\n\n')}`;
return `Found ${grouped.size} code elements with ${results.length} total connections:\n\n${formatted.join('\n\n')}`;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return `Search with context error: ${message}`;
@ -136,11 +257,10 @@ export const createGraphRAGTools = (
},
{
name: 'semantic_search_with_context',
description: 'Search for code semantically AND expand to show connected code elements (callers, callees, imports). Use this to understand how code fits into the broader architecture.',
description: 'Search for code semantically AND show directly connected code elements with relationship types (CALLS, IMPORTS, DEFINES, CONTAINS). Shows what each match is connected to and how.',
schema: z.object({
query: z.string().describe('Natural language description of what you are looking for'),
limit: z.number().optional().describe('Number of initial matches to find (default: 5)'),
hops: z.number().optional().describe('Number of graph hops to expand (default: 2, max: 3)'),
limit: z.number().optional().describe('Number of semantic matches to find (default: 5)'),
}),
}
);
@ -258,6 +378,7 @@ export const createGraphRAGTools = (
return [
executeCypherTool,
executeVectorCypherTool,
semanticSearchTool,
semanticSearchWithContextTool,
getSchemaTool,

View file

@ -59,9 +59,13 @@ export type ProviderConfig = AzureOpenAIConfig | GeminiConfig | OllamaConfig;
*/
export interface LLMSettings {
activeProvider: LLMProvider;
azureOpenAI?: Omit<AzureOpenAIConfig, 'provider'>;
gemini?: Omit<GeminiConfig, 'provider'>;
ollama?: Omit<OllamaConfig, 'provider'>;
/**
* Provider settings are persisted to localStorage and may be partially configured.
* We validate required fields at runtime before creating a ProviderConfig.
*/
azureOpenAI?: Partial<Omit<AzureOpenAIConfig, 'provider'>>;
gemini?: Partial<Omit<GeminiConfig, 'provider'>>;
ollama?: Partial<Omit<OllamaConfig, 'provider'>>;
}
/**
@ -158,6 +162,7 @@ IMPORTANT QUERY PATTERNS:
3. SEMANTIC SEARCH (embeddings in separate table - MUST JOIN):
CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx', $queryVector, 10)
YIELD node AS emb, distance
WITH emb, distance -- KuzuDB requires WITH after YIELD before WHERE
WHERE distance < 0.4
MATCH (n:CodeNode {id: emb.nodeId}) -- JOIN required!
RETURN n.name, n.label, n.filePath, distance
@ -166,6 +171,7 @@ IMPORTANT QUERY PATTERNS:
4. Semantic search + graph expansion:
CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx', $queryVector, 5)
YIELD node AS emb, distance
WITH emb, distance
WHERE distance < 0.5
MATCH (match:CodeNode {id: emb.nodeId})
MATCH (match)-[r:CodeRelation*1..2]-(connected:CodeNode)
@ -179,6 +185,21 @@ IMPORTANT QUERY PATTERNS:
MATCH (f:CodeNode {name: $fileName})-[r:CodeRelation {type: 'IMPORTS'}]->(imported:CodeNode)
RETURN imported.name AS imports
7. Unified vector + graph traversal in ONE query (recommended pattern):
CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx', $queryVector, 10)
YIELD node AS emb, distance
WITH emb, distance
WHERE distance < 0.5
MATCH (match:CodeNode {id: emb.nodeId})
MATCH (match)-[r:CodeRelation*1..2]-(ctx:CodeNode)
RETURN match.name AS found, match.label AS label, match.filePath AS path,
distance, collect(DISTINCT ctx.name) AS context
ORDER BY distance
TOOLING NOTE (for execute_vector_cypher):
- When using the execute_vector_cypher tool, write Cypher containing {{QUERY_VECTOR}} where the vector should go.
- The tool will replace {{QUERY_VECTOR}} with a CAST([..] AS FLOAT[384]) literal.
NOTES:
- Always use WHERE clauses to filter by label when possible for performance
- Use LIMIT to avoid returning too many results