Table of Contents

Introduction

Hey everyone!

You can find all the C# code samples here: MicrosoftAgentFramework GitHub Repo

Before reading this post it might be helpful to get familiar with the following posts as well:

The Neo4jContextProvider is a native context provider from Neo4j.AgentFramework.GraphRAG NuGet package that you can use in the Microsoft Agent Framework designed to automate graph database context injection, but relying on it blindly can introduce serious architectural rigidity in your GraphRAG pipelines. When you build an enterprise RAG solution, you usually start with vector search combined with BM25 full-text search. Everything seems to work perfectly for precise, localized queries. But what happens when your user asks an abstract question that requires “connecting the dots” across 15 different documents? Plain vector databases look at isolated text segments; they miss the structural relationships between your data points.

To fill this gap, you need a graph database like Neo4j to implement a true GraphRAG pattern.

When you build a C# AI agent, you have to decide exactly how to map and inject this additional graph database context. In this post, I will show you how to work with that native provider abstraction, and then I will show you a more flexible alternative: using an ordinary tool call to handle complex, multi-hop graph traversals dynamically.

Graph RAG in a Nutshell

A graph database visualization screenshot showing a complex network diagram of 227 interconnected nodes and 241 relationships. A right-side "Results overview" panel lists node types including AZURE_RESOURCE, LOGICAL_CONCEPT, and TECHNICAL_FEATURE, alongside relationship types such as IMPACTS, CONTAINS, and DEPENDS_ON.
Knowledge Graph built based on a single markdown file.

Before we look at the C# implementation, let’s do a very concise breakdown of how GraphRAG operates behind the scenes. For this setup, I populated a Neo4j instance with a foundational knowledge graph built entirely from a single markdown documentation page extracted from the official Azure Well-Architected Framework (WAF). You can find all the details about building such a knowledge graph in this post:

We can split Graph RAG retrieval phases into 2 sub-phases. The first phase is all about performing a search to find the appropriate nodes. In Neo4j, we could use the following methods to do that:

  • Full Text Search
  • Vector Search
  • Hybrid Search (Full-Text + Vector)

Pure vector search only finds the initial entry points, the “seed nodes” using cosine similarity. The real magic of GraphRAG happens during the second phase: graph neighborhood traversal. Once we hit our seed nodes, we traverse outward to capture related concepts. We can optimize this traversal inside our Cypher query by leveraging list processing with all() and reduce() functions to multiply relationship weights along the path, ensuring we filter out weak or irrelevant connections before the data ever reaches the LLM.

Example with reduce function

Let’s assume that one of our entry points found during the initial search phase is ‘Query performance‘. This is our entry point into the graph data structure. Now, we can use the following Cypher query to traverse its neighborhood:

MATCH p=(n:Entity)-[*1..3]-(m)
WHERE n.name IN ['Query performance'] AND reduce(score = 1.0, rel IN relationships(p) | score * rel.weight) >= 0.6
RETURN p

We could translate this query into the following question:

Return all paths between 1 and 3 hops deep starting from the ‘Query performance’ node, but only if the accumulated path score calculated by multiplying all relationship weights along that path, is greater than or equal to 0.6.

Result:

Neo4j Desktop interface showing a graph traversal Cypher query on the left that filters paths starting at the 'Query performance' node and calculates path weights using a reduce function. The right side displays the visual graph network of interconnected nodes, with a large blue arrow and text overlay pointing directly to 'Query performance' as the entry point.

Automated Ingestion with Neo4jContextProvider

The official Neo4jContextProvider NuGet package inherits directly from the framework’s foundational AIContextProvider base class, allowing it to integrate natively into the agent’s turn lifecycle.

To set this up, you need to provide an IEmbeddingGenerator using the exact same embedding model (like text-embedding-ada-002) that you used during data ingestion. This guarantees your query operates within a single vector space. You then pass your connection strings and define your baseline retrieval query.

public class Neo4jContextProviderExample
{
    private readonly AIAgent _mafAgent;

    public Neo4jContextProviderExample()
    {
        var defaultCredential = new DefaultAzureCredential();

        var openAiClient = new AzureOpenAIClient(
            new Uri(Environment.GetEnvironmentVariable("AZURE_OPEN_AI_RESPONSES_CLIENT_URI")!),
            defaultCredential);

        var responsesClient = openAiClient
            .GetResponsesClient()
            .AsIChatClientWithStoredOutputDisabled(Environment.GetEnvironmentVariable("AZURE_OPEN_AI_MODEL_DEPLOYMENT_NAME")!);

        var embeddingGenerator = new AzureOpenAIClient(new Uri(Environment.GetEnvironmentVariable("AZURE_OPEN_AI_URI")!), defaultCredential)
            .GetEmbeddingClient(Environment.GetEnvironmentVariable("AZURE_OPEN_AI_EMBEDDING_MODEL")!)
            .AsIEmbeddingGenerator();

        _mafAgent = new ChatClientAgent(responsesClient, new ChatClientAgentOptions()
        {
            Name = "Agent using Neo4j (as a context provider)",
            ChatOptions = new ChatOptions
            {
                Instructions = "You are a helpful assistant."
            },
            AIContextProviders = [Neo4jContextProvider.Create(
                uri: Environment.GetEnvironmentVariable("NEO4J_URI")!,
                username: Environment.GetEnvironmentVariable("NEO4J_USERNAME")!,
                password: Environment.GetEnvironmentVariable("NEO4J_PASSWORD")!,
                new Neo4jContextProviderOptions()
                {
                    IndexName = Environment.GetEnvironmentVariable("NEO4J_INDEX_NAME")!,
                    IndexType = IndexType.Vector,
                    EmbeddingGenerator = embeddingGenerator,
                    TopK = 3,
                    RetrievalQuery = """
                        WITH node, score, [label IN labels(node) WHERE label <> 'Entity'] AS filteredLabels
                        RETURN 
                            "Name: " + node.name + "\n" +
                            "Description: " + node.description + "\n" +
                            "Tags: " + reduce(s = "", x IN filteredLabels | s + (CASE WHEN s = "" THEN "" ELSE ", " END) + x) AS text, 
                            score
                        """
                })
            ]
        });
    }
}

The provider automatically overrides the ProvideAIContextAsync lifecycle hook. Every time a message turn occurs, it intercepts the user text, generates the embedding vector, queries Neo4j, and adds the retrieved records directly into the prompt context.

However, after testing this library thoroughly, I believe it is currently in a half-baked preview state and not ready for production environments:

  • Passive Execution Overhead: It runs blindly on every single turn. If the user says “Thanks!”, the provider still triggers a full database vector query and blocks the pipeline.
  • Rigid Column Parsers: The internal parser strictly expects a single column returned as text. If you want to return custom entity tags or structured weights, it breaks the clean formatting and falls back to dumping raw stringified node dictionaries into your prompt.
  • Lack of Customization: There are no virtual hooks to dynamically modify the query text rewrite or filter how many historical assistant messages are included in the embedding generation phase. There is no option to exclude assistant messages completely too.

Demo: Inspecting the Injected Context

Let’s look at how this behaves at runtime. If you pass the following question to the agent:

how to write a performant query?

The pipeline immediately converts the text into a query vector using the embedding generator we configured earlier and executes a vector search against Neo4j. Because TopK is set to 3, you will see exactly four new messages injected into the context with a ChatRole.User role:

  • 1 Context Header Message: A general instruction block (which you can customize via options).
  • 3 Node Content Messages: Individual text payloads containing the raw properties, descriptions, and similarity scores for each of the top three matching nodes retrieved from the knowledge graph.
IDE debugger window showing the expanded AI context messages collection generated by Neo4jContextProvider. A blue rectangle highlights the injected knowledge graph context data, displaying text strings with similarity scores, entity names like Query Performance, descriptions, and quality attribute tags formatted directly as user chat messages.
## Knowledge Graph Context Use the following information from the knowledge graph to answer the question:

[Score: 0.919] Name: Performance
Description: Effectiveness and speed of query and indexing operations.
Tags: QUALITY_ATTRIBUTE

[Score: 0.918] Name: Query performance
Description: The speed and efficiency of search queries.
Tags: QUALITY_ATTRIBUTE

[Score: 0.917] Name: Performance Tradeoffs
Description: Performance impact introduced by additional query load.
Tags: QUALITY_ATTRIBUTE

Shifting Gears to Custom Tool Calls

If you want to unlock the full potential of GraphRAG in the Microsoft Agent Framework, you should abandon passive context providers and expose your graph queries as an ordinary tool call.

When you register a method as an agentic tool with clear descriptions and parameter attributes, you hand total execution control over to the LLM.

public class CustomToolExample : IAsyncDisposable
{
    private readonly AIAgent _mafAgent;
    private readonly GraphDb _graphDb = new();
    private readonly IEmbeddingGenerator<string, Embedding<float>> _embeddingGenerator;

    public CustomToolExample()
    {
        var credential = new DefaultAzureCredential();

        var openAiClient = new AzureOpenAIClient(new Uri(Environment.GetEnvironmentVariable("AZURE_OPEN_AI_RESPONSES_CLIENT_URI")!), credential);

        var responesClient = openAiClient
            .GetResponsesClient()
            .AsIChatClientWithStoredOutputDisabled(Environment.GetEnvironmentVariable("AZURE_OPEN_AI_MODEL_DEPLOYMENT_NAME")!);

        _embeddingGenerator = new AzureOpenAIClient(new Uri(Environment.GetEnvironmentVariable("AZURE_OPEN_AI_URI")!), credential)
            .GetEmbeddingClient(Environment.GetEnvironmentVariable("AZURE_OPEN_AI_EMBEDDING_MODEL")!)
            .AsIEmbeddingGenerator();

        _mafAgent = new ChatClientAgent(responesClient, new ChatClientAgentOptions()
        {
            Name = "Context Provider test",
            ChatOptions = new ChatOptions
            {
                Instructions = "Agent using Neo4j (as an ordinary tool)",
                Reasoning = new ReasoningOptions() { Effort = ReasoningEffort.None, Output = ReasoningOutput.None },
                Tools = [AIFunctionFactory.Create(GetKnowledgeGraphContextAsync)]
            }
        });
   }

The search method:

[Description("Queries the knowledge graph to retrieve design principles, best practices, trade-offs, and structural recommendations from the Azure Well-Architected Framework (WAF).")]
public async Task<string> GetKnowledgeGraphContextAsync(
    [Description("The architectural topic, core concept, or specific Azure Well-Architected Framework pillar/keyword to search for.")] string query)
{
    var queryVector = await _embeddingGenerator.GenerateVectorAsync(query);
    var graphSearchResult = await _graphDb.GetTopEntitiesAsync(
        queryVector.ToArray(), 
        topK: 3, 
        traversalDepth: 5, 
        minPathScore: 0.85f);

    var context = FormatKnowledgeGraphAsContext(graphSearchResult);
    return context;
}

This structural shift provides two massive engineering benefits:

  1. On-Demand Execution: The agent only spends performance latency and token costs to query Neo4j when it explicitly detects that graph topology data is required to answer the prompt.
  2. Natural Query Expansion: The LLM automatically rewrites and expands vague user shorthand into an optimized, descriptive query string based on the active conversation history before passing it to your C# method.

Inside your dedicated tool repository class, you are free to run advanced, deeply nested Cypher traversal projections that calculate paths up to N levels deep based on your mathematical traversalDepth and minPathScore constraints.

var result = await _driver.ExecutableQuery($$"""
    // Phase 1: vector ANN search — find seed nodes closest to the query
    CALL db.index.vector.queryNodes('{{VectorIndexName}}', $topK, $queryVector)
    YIELD node AS seedNode, score
    WITH collect(seedNode) AS seedNodes

    // Phase 2: optional variable-depth traversal — seed nodes are kept even when no paths qualify
    UNWIND seedNodes AS seedNode
    OPTIONAL MATCH path = (seedNode)-[rels*1..{{traversalDepth}}]-(endNode)
    WITH seedNodes,
         collect(DISTINCT CASE
             WHEN rels IS NOT NULL
                  AND reduce(score = 1.0, r IN rels | score * coalesce(r.weight, 0.5)) >= $minPathScore
             THEN {nodes: nodes(path), rels: rels}
             ELSE null
         END) AS qualifiedPaths

    // Phase 3: flatten qualified paths into node and relationship lists
    WITH seedNodes,
         [p IN qualifiedPaths WHERE p IS NOT NULL | p.nodes] AS allPathNodes,
         [p IN qualifiedPaths WHERE p IS NOT NULL | p.rels]  AS allPathRels
    WITH seedNodes,
         reduce(acc = [], ns IN allPathNodes | acc + ns) AS flatNodes,
         reduce(acc = [], rs IN allPathRels  | acc + rs) AS flatRels

    RETURN
        [n IN seedNodes | {Name: n.name, Type: [l IN labels(n) WHERE l <> 'Entity'][0], Description: n.description}]                                                          AS Seeds,
        [n IN flatNodes | {Name: n.name, Type: [l IN labels(n) WHERE l <> 'Entity'][0], Description: n.description}]                                                          AS Traversed,
        [r IN flatRels  | {Source: startNode(r).name, Target: endNode(r).name, Label: type(r), Description: coalesce(r.description, ''), Weight: coalesce(r.weight, 0.5)}]    AS Relationships
    """)
    .WithParameters(new { queryVector, topK, minPathScore })
    .ExecuteAsync();

Your C# code can cleanly loop through the returned seed entities, traversed concepts, and explicit relationship weights, building a clean, readable Markdown layout to pass back to the model.

Summary

Choosing how to wire a graph database into your C# AI agent comes down to architectural intent. The built-in Neo4jContextProvider offers an automated, hands-off pipeline, but its lack of flexibility and rigid single-column parsing schema make it difficult to recommend for enterprise scenarios today.

By moving your retrieval logic into a standard tool call, you give the model the agency to rewrite queries on the fly and protect your application from unnecessary database call overhead on simple conversational turns. Keep your context injection lightweight, keep your traversal paths heavily tuned, and build your pipelines for production reality.

Thanks for reading and see you in the next post!

Categorized in:

Agents, RAG,