Skip to content

Chapter 10: Building a Custom LINQ Provider

Theoretical Foundations

The core concept of this section is understanding the architecture of IQueryable<T> and the Expression Tree API as the foundational mechanism for translating high-level, declarative LINQ queries into domain-specific execution logic, such as vector similarity operations. This is not merely about using LINQ, but about extending it.

The Declarative vs. Imperative Pipeline

In imperative programming, you explicitly define the control flow: loops, branches, and temporary variables. In functional data pipelines, you define a sequence of transformations on data. LINQ (Language Integrated Query) is the embodiment of this declarative style in C#.

Consider a real-world analogy: The Assembly Line vs. The Recipe.

  • Imperative (Assembly Line): You stand at a conveyor belt. You pick up a raw item, inspect it, modify it, and place it on the next belt. You control every physical step. If the item is defective, you throw it away yourself.
  • Declarative (Recipe): You write a list of instructions: "Take all vegetables, wash them, chop them, and toss with oil." You don't execute the steps immediately; you hand the recipe to a chef (the execution engine). The chef decides the optimal way to execute the steps, perhaps chopping all vegetables simultaneously (parallelism) or skipping rotten ones (filtering).

In C#, LINQ queries are the "recipe." They are definitions of intent, not immediate actions.

Deferred vs. Immediate Execution

This distinction is the most critical architectural concept in building efficient data pipelines.

Deferred Execution means that the query definition does not trigger data processing. It merely constructs a plan (an object graph). The execution is postponed until the results are actually enumerated.

Immediate Execution forces the query to run right now and typically stores the results in memory.

using System;
using System.Collections.Generic;
using System.Linq;

public class ExecutionDemo
{
    public static void ShowDifference()
    {
        var numbers = new List<int> { 1, 2, 3, 4, 5 };

        // DEFERRED EXECUTION
        // No processing happens here. 'query' is just an object describing what to do.
        // It holds a reference to 'numbers' and a lambda, but hasn't iterated yet.
        var query = numbers.Where(n => n % 2 == 0).Select(n => n * 2);

        // The data changes AFTER the query definition.
        numbers.Add(6); 

        // IMMEDIATE EXECUTION
        // .ToList() forces iteration. It iterates over { 1, 2, 3, 4, 5, 6 }.
        // The result includes the newly added '6'.
        List<int> results = query.ToList(); 

        // Output: 4, 6, 8, 10, 12 (Notice 6*2=12 is included)
        Console.WriteLine(string.Join(", ", results));
    }
}

Architectural Implication: If you are building an AI data preprocessing pipeline, deferred execution allows you to define a complex chain of cleaning and normalization steps without duplicating the dataset in memory at every step. The data flows through the pipeline only when you finally call .ToList() or iterate.

The IQueryable<T> Abstraction

While IEnumerable<T> is the standard for in-memory collections, IQueryable<T> is designed for external data sources (databases, vector stores, custom providers). The key difference lies in the Expression tree.

  • IEnumerable<T>: Lambdas are compiled into executable code (delegates).
  • IQueryable<T>: Lambdas are compiled into data structures representing the code (Expression Trees).

This allows a custom provider to inspect the code structure and translate it into a different language (like SQL or, in our case, vector similarity math).

Building a Custom LINQ Provider

To map LINQ to vector operations (like Cosine Distance), we must intercept the Expression Tree before execution. We need two main components:

  1. IQueryable<T> Implementation: Holds the data source and the expression.
  2. IQueryProvider Implementation: The factory that builds queries and executes them.

1. The Expression Visitor

We use the ExpressionVisitor class to traverse the tree. This is where we map standard LINQ patterns (like Where) to our domain logic (vector filtering).

using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;

// Represents a vector embedding in our domain
public class Embedding
{
    public string Id { get; set; }
    public float[] Vector { get; set; }
}

// The core logic: Translating Expression Trees to Vector Operations
public class VectorQueryTranslator : ExpressionVisitor
{
    private float[] _targetVector;
    private double _threshold;

    public VectorQueryTranslator()
    {
        // Defaults
        _targetVector = Array.Empty<float>();
        _threshold = 1.0;
    }

    // We override the VisitMethodCall to intercept calls like .Where()
    protected override Expression VisitMethodCall(MethodCallExpression node)
    {
        // Check if this is a Where call on IQueryable
        if (node.Method.Name == "Where" && node.Arguments.Count == 2)
        {
            // The second argument is the lambda (e.g., x => x.IsSimilarTo(target))
            var lambda = (LambdaExpression)StripQuotes(node.Arguments[1]);

            // Here we would parse the lambda body to extract the target vector
            // For simplicity, we simulate extracting parameters from a closure
            // In a real provider, we'd inspect the Expression structure deeply.
            Visit(lambda.Body);

            // Return the modified expression or a constant result
            // In a real provider, this returns a new Expression representing the filtered result
            return Expression.Constant(new List<Embedding>()); 
        }

        return base.VisitMethodCall(node);
    }

    private static Expression StripQuotes(Expression e)
    {
        while (e.NodeType == ExpressionType.Quote)
        {
            e = ((UnaryExpression)e).Operand;
        }
        return e;
    }
}

2. The IQueryProvider

The provider is the engine. It decides how to interpret the tree.

public class VectorQueryProvider : IQueryProvider
{
    private readonly IEnumerable<Embedding> _source;

    public VectorQueryProvider(IEnumerable<Embedding> source)
    {
        _source = source;
    }

    public IQueryable CreateQuery(Expression expression)
    {
        // Generic type inference logic
        throw new NotImplementedException();
    }

    public IQueryable<TElement> CreateQuery<TElement>(Expression expression)
    {
        return new VectorQueryable<TElement>(this, expression);
    }

    public object Execute(Expression expression)
    {
        // Non-generic execution
        throw new NotImplementedException();
    }

    public TResult Execute<TResult>(Expression expression)
    {
        // CRITICAL: This is where the translation happens.
        // We inspect the expression tree and convert it to executable code.

        if (typeof(TResult).IsGenericType && typeof(TResult).GetGenericTypeDefinition() == typeof(IEnumerable<>))
        {
            // We are being asked to return a sequence (Deferred Execution)
            // Translate the expression to filter the in-memory source
            var translator = new VectorQueryTranslator();
            // In a real scenario, we would compile the expression or map it manually
            // For this example, we simulate the execution
            return (TResult)_source; 
        }

        // If we were asked for a single value (e.g., .Count()), we execute immediately
        return default(TResult);
    }
}

3. The IQueryable<T> Implementation

This is the entry point for the LINQ syntax.

public class VectorQueryable<T> : IOrderedQueryable<T>
{
    public VectorQueryable(VectorQueryProvider provider, Expression expression)
    {
        Provider = provider;
        Expression = expression;
    }

    public Type ElementType => typeof(T);
    public Expression Expression { get; }
    public IQueryProvider Provider { get; }

    public IEnumerator<T> GetEnumerator()
    {
        // TRIGGERING EXECUTION
        // When you iterate, the Provider.Execute method is called.
        // This transforms the Expression Tree into actual results.
        var result = Provider.Execute<IEnumerable<T>>(Expression);
        return result.GetEnumerator();
    }

    System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    }
}

Application in AI Data Pipelines

In AI, specifically when handling high-dimensional embeddings (vectors), we often need to perform operations like:

  1. Filtering: Find vectors within a certain cosine distance of a query vector.
  2. Normalization: Scale vectors to unit length.
  3. Batching: Group vectors for parallel processing.

Using a custom LINQ provider allows us to write declarative queries that look like standard LINQ but execute optimized vector math.

Example Usage:

// Assume 'embeddings' is our custom VectorQueryable<Embedding>
var queryVector = new float[] { 0.1f, 0.5f, 0.9f };

// Declarative Pipeline
var similarItems = embeddings
    .Where(e => VectorMath.CosineDistance(e.Vector, queryVector) < 0.1f)
    .Select(e => new { e.Id, Score = 1 - VectorMath.CosineDistance(e.Vector, queryVector) })
    .OrderByDescending(x => x.Score);

// Execution happens here (Immediate)
var results = similarItems.ToList(); 

Why this matters for AI: If we used standard IEnumerable with in-memory lists, the .Where lambda would be compiled to IL code that runs on the CPU for every single vector. If we have millions of vectors, this is slow.

With a custom IQueryable provider:

  1. The Where clause is captured as an Expression Tree.
  2. The VectorQueryProvider can inspect this tree.
  3. It can recognize the CosineDistance pattern and translate it into a call to a GPU-accelerated library (like CUDA or a specialized SIMD instruction set) or a vector database query (like Pinecone or Milvus).
  4. The heavy computation happens off the main CPU thread or on specialized hardware, yet the C# code remains clean and functional.

Visualization of the Pipeline

The flow of data and logic through the custom provider architecture:

A pipeline diagram illustrates how heavy AI computations are offloaded from the main C# thread to specialized hardware, ensuring the application remains responsive and the code stays clean.
Hold "Ctrl" to enable pan & zoom

A pipeline diagram illustrates how heavy AI computations are offloaded from the main C# thread to specialized hardware, ensuring the application remains responsive and the code stays clean.

Summary of Concepts

  1. Deferred Execution: Queries are blueprints. They don't run until enumerated.
  2. Expression Trees: Code represented as data. This is the magic that allows us to inspect and translate C# logic into other domains (like vector math).
  3. IQueryProvider: The translator. It bridges the gap between the abstract syntax tree and the concrete execution engine.
  4. Functional Purity: In this architecture, the lambdas inside .Where or .Select must be pure functions. They should not modify external state. This ensures that the provider can safely translate the query without unpredictable side effects.

Basic Code Example

using System;
using System.Collections.Generic;
using System.Linq;

// Define a simple class to represent a data point with an embedding vector
public class DataPoint
{
    public string Id { get; set; }
    public double[] Embedding { get; set; }
    public string Category { get; set; }
}

public class Program
{
    public static void Main()
    {
        // --- 1. Data Simulation (The "Real-World" Context) ---
        // Imagine a RAG (Retrieval-Augmented Generation) system where we have
        // thousands of document chunks represented as high-dimensional vectors.
        // We need to preprocess this data: filter out noise, normalize values, 
        // and prepare it for a vector similarity search.

        // Raw, unprocessed dataset
        IEnumerable<DataPoint> rawData = new List<DataPoint>
        {
            new DataPoint { Id = "A", Embedding = new[] { 1.0, 0.5, 0.0 }, Category = "Tech" },
            new DataPoint { Id = "B", Embedding = new[] { 1.1, 0.6, 0.1 }, Category = "Tech" }, // Similar to A
            new DataPoint { Id = "C", Embedding = new[] { 0.0, 0.0, 0.0 }, Category = "Noise" }, // Zero vector (bad data)
            new DataPoint { Id = "D", Embedding = new[] { 5.0, 8.0, 2.0 }, Category = "Finance" }
        };

        // --- 2. The Functional Pipeline (Declarative Style) ---
        // We define the transformation steps. Note: Nothing executes yet.
        // This is "Deferred Execution". We are building a recipe, not cooking the meal.

        var preprocessingQuery = 
            // Step A: Filter out "Noise" categories and zero vectors (Data Cleaning)
            rawData.Where(dp => dp.Category != "Noise" && dp.Embedding.Any(v => v > 0))

            // Step B: Normalize the vectors (Data Normalization)
            // We project the existing object into a NEW object with modified data.
            // CRITICAL: We do not modify the original 'rawData'. This is immutability.
            .Select(dp => 
            {
                double magnitude = Math.Sqrt(dp.Embedding.Sum(v => v * v));
                return new DataPoint 
                { 
                    Id = dp.Id, 
                    Category = dp.Category,
                    // Functional transformation: Map vector -> normalized vector
                    Embedding = dp.Embedding.Select(v => v / magnitude).ToArray() 
                };
            })

            // Step C: Group by Category (Aggregation)
            .GroupBy(dp => dp.Category);

        // --- 3. Triggering Execution ---
        // The query is still just a definition. 
        // We force execution by iterating or converting to a list.

        Console.WriteLine("--- Preprocessing Results ---");

        // Immediate Execution: .ToList() forces the pipeline to run now.
        var processedGroups = preprocessingQuery.ToList();

        foreach (var group in processedGroups)
        {
            Console.WriteLine($"Category: {group.Key}");
            foreach (var dp in group)
            {
                // Formatting the vector for display
                string vecStr = string.Join(", ", dp.Embedding.Select(v => v.ToString("F2")));
                Console.WriteLine($"  - ID: {dp.Id}, Vector: [{vecStr}]");
            }
        }
    }
}

Explanation of the Code

  1. Contextual Setup (The Problem): We simulate a scenario common in AI engineering: handling "Embeddings" (vectors of numbers) representing text or images. Raw data is rarely perfect; it contains "noise" (like the zero vector in ID "C") or needs mathematical transformation (normalization) before we can perform similarity searches.

  2. Deferred Execution (The Recipe): The preprocessingQuery variable does not contain data. It holds the instructions for how to retrieve and transform data. You can assign this variable, pass it to other methods, or build upon it further without ever touching the database or the raw list. This is the core of IQueryable<T> and IEnumerable<T> efficiency.

  3. The Functional Pipeline:

    • .Where(...): Acts as a gatekeeper. It filters the stream of data based on predicates. In this case, we remove items where Category is "Noise" or the Embedding is all zeros.
    • .Select(...): This is the transformation engine. It takes an input and returns a new shape. Here, we calculate the Euclidean magnitude and divide every element in the array by it. This creates a "Unit Vector," essential for Cosine Similarity calculations later in the book.
    • .GroupBy(...): Organizes the stream into buckets based on a key. This is useful for statistical analysis or batch processing.
  4. Immediate Execution (The Result): The line preprocessingQuery.ToList() is the "trigger." It forces the C# compiler to execute the entire chain of logic immediately and store the results in memory. Without this (or a foreach loop), no code inside the lambdas (like the Math.Sqrt calculation) would ever run.

Visualizing the Data Flow

The pipeline transforms data from a raw list into grouped, normalized vectors.

A diagram would illustrate a linear pipeline where a raw list of data flows sequentially through stages of transformation, culminating in grouped and normalized vectors.
Hold "Ctrl" to enable pan & zoom

A diagram would illustrate a linear pipeline where a raw list of data flows sequentially through stages of transformation, culminating in grouped and normalized vectors.

Common Pitfalls

1. Confusing Deferred vs. Immediate Execution A frequent mistake is assuming a query runs just because it is defined.

var query = rawData.Where(x => x.Id == "A"); // 0 items processed here.
// ... 100 lines of code later ...
var result = query.ToList(); // NOW it scans the list.
Why this matters in Book 3: When building Custom LINQ Providers (Chapter 10), the provider often needs to inspect the entire expression tree before execution. If you trigger execution too early (e.g., by calling .Count() inside a loop), you break the ability to translate the query into a remote command (like SQL or a Vector Database API call).

2. Side Effects in .Select The prompt forbids modifying external variables inside a lambda. This is a functional programming rule. Bad (Imperative/Stateful):

int counter = 0;
var badQuery = rawData.Select(dp => {
    counter++; // SIDE EFFECT: Modifying external state
    return dp;
});
Why this fails: With Deferred Execution, counter might not be what you expect when you finally iterate. If the query is run in parallel (PLINQ), counter could cause race conditions and incorrect results. Always return new objects based only on the input arguments.

The chapter continues with advanced code, exercises and solutions with analysis, you can find them on the ebook on Leanpub.com or Amazon



Code License: All code examples are released under the MIT License. Github repo.

Content Copyright: Copyright © 2026 Edgar Milvus | Privacy & Cookie Policy. All rights reserved.

All textual explanations, original diagrams, and illustrations are the intellectual property of the author. To support the maintenance of this site via AdSense, please read this content exclusively online. Copying, redistribution, or reproduction is strictly prohibited.