Task-Queue-Powered Entity Graph Linking

Agentic AI Systems intermediate Building with AI ~22 min read

Overview

Agent memory systems that store only flat text memories miss the relational structure inherent in knowledge. When an agent remembers that “Alice leads the payments team” and separately that “the payments team owns the billing microservice,” it should be able to connect Alice to the billing microservice through graph traversal — not just vector similarity. Entity graph linking extracts named entities from memory content, normalizes them into canonical nodes, and creates typed edges that capture how entities relate to each other and to the memories that mention them.

This pattern can be illustrated with a task-queue framework — used to run GLiNER2 (Generalist and Lightweight Named Entity Recognition) models for entity extraction. When a new memory is stored, a task-queue job invokes GLiNER2 to identify entities (people, systems, teams, concepts, locations), normalizes their names against existing entity nodes, upserts new nodes as needed, and creates mentions edges between the memory and each extracted entity. Edge weights reflect confidence scores from the NER model, and a mention_count transform on entity nodes tracks how frequently each entity appears across all memories.

This approach transforms the memory store from a flat collection of searchable text into a rich knowledge graph where agents can answer questions that require multi-hop reasoning: “Who owns the services that had incidents last week?” requires traversing from incident memories to service entities to team entities to person entities — a query that pure vector search cannot solve.

Core Concepts

GLiNER2 Named Entity Recognition

GLiNER2 is a lightweight, generalist NER model that identifies entities in text without requiring domain-specific fine-tuning. Unlike traditional NER systems that recognize only predefined categories (PERSON, ORG, LOCATION), GLiNER2 accepts arbitrary entity type labels at inference time — you can ask it to find “microservice,” “API endpoint,” or “Kubernetes resource” as entity types. This flexibility is essential for technical memory systems where domain entities vary per team and project.

Task Queue Execution

The task queue is an example task execution framework for running compute-intensive operations asynchronously. Entity extraction runs as a queued task rather than inline with memory storage, decoupling the fast path (store memory, return ID) from the slow path (run NER model, update graph). The task queue handles retries, timeouts, and backpressure, ensuring that a burst of memory writes doesn’t overwhelm the NER model or the graph update pipeline.

Entity Normalization

Raw NER output contains surface forms: “Alice Smith,” “Alice,” “A. Smith.” Entity normalization maps these variants to a single canonical entity node. Strategies include exact-match lookup, fuzzy string matching (Levenshtein distance, Jaro-Winkler), and embedding similarity for semantic deduplication. The goal is a clean entity graph where each real-world entity has exactly one node, regardless of how many ways it’s mentioned in memory text.

Mention Edges and Weights

A mentions edge connects a memory node to an entity node. The edge weight encodes the NER model’s confidence score for that extraction (typically 0.0–1.0). Higher-confidence extractions produce stronger edges, which downstream graph algorithms can use for ranking. The mention_count transform on entity nodes aggregates the total number of mention edges, serving as a proxy for entity importance in the agent’s knowledge base.

Graph-Augmented Retrieval

By combining vector search with graph traversal, the memory system can answer queries that require relational reasoning. A search for “payments team incidents” first retrieves memories via semantic search, then follows entity edges to find connected memories about the same entities (team members, services, error codes). This graph expansion step discovers relevant context that vector similarity alone would miss because the text may use completely different vocabulary.

How It Works

Entity Extraction via the Task Queue

When a memory is stored, a task is enqueued to extract entities. The task calls GLiNER2 with configurable entity type labels and processes the results into normalized entity nodes and mention edges.

package memory

import (
    "context"
    "fmt"
    "strings"

    "github.com/example/taskqueue"
)

type EntityExtractionTask struct {
    MemoryID  string   `json:"memory_id"`
    Namespace string   `json:"namespace"`
    Content   string   `json:"content"`
    Labels    []string `json:"labels"`
}

type ExtractedEntity struct {
    Text       string  `json:"text"`
    Label      string  `json:"label"`
    Confidence float64 `json:"confidence"`
    Start      int     `json:"start"`
    End        int     `json:"end"`
}

func RegisterEntityExtractor(worker *taskqueue.Worker, linker *EntityLinker) {
    worker.Handle("extract_entities", func(ctx context.Context, task taskqueue.Task) error {
        var input EntityExtractionTask
        if err := task.Decode(&input); err != nil {
            return fmt.Errorf("decoding task: %w", err)
        }

        // Call GLiNER2 for entity extraction
        entities, err := linker.gliner.Extract(ctx, input.Content, input.Labels)
        if err != nil {
            return fmt.Errorf("GLiNER2 extraction: %w", err)
        }

        // Normalize and link each entity
        for _, entity := range entities {
            if entity.Confidence < 0.5 {
                continue
            }

            nodeID, err := linker.UpsertEntity(ctx, input.Namespace, entity)
            if err != nil {
                return fmt.Errorf("upserting entity %q: %w", entity.Text, err)
            }

            err = linker.CreateMentionEdge(ctx, input.Namespace,
                input.MemoryID, nodeID, entity.Confidence)
            if err != nil {
                return fmt.Errorf("creating mention edge: %w", err)
            }
        }

        return nil
    })
}

GLiNER2 Client

The GLiNER2 model runs as a sidecar service exposed over HTTP. The client sends text and desired entity labels, receiving back a list of extracted spans with confidence scores.

package memory

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "net/http"
)

type GLiNERClient struct {
    endpoint   string
    httpClient *http.Client
}

type glinerRequest struct {
    Text   string   `json:"text"`
    Labels []string `json:"labels"`
}

type glinerResponse struct {
    Entities []ExtractedEntity `json:"entities"`
}

func NewGLiNERClient(endpoint string) *GLiNERClient {
    return &GLiNERClient{
        endpoint:   endpoint,
        httpClient: &http.Client{},
    }
}

func (c *GLiNERClient) Extract(
    ctx context.Context, text string, labels []string,
) ([]ExtractedEntity, error) {
    body, _ := json.Marshal(glinerRequest{Text: text, Labels: labels})

    req, err := http.NewRequestWithContext(ctx, "POST",
        c.endpoint+"/extract", bytes.NewReader(body))
    if err != nil {
        return nil, err
    }
    req.Header.Set("Content-Type", "application/json")

    resp, err := c.httpClient.Do(req)
    if err != nil {
        return nil, fmt.Errorf("calling GLiNER2: %w", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("GLiNER2 returned status %d", resp.StatusCode)
    }

    var result glinerResponse
    if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
        return nil, fmt.Errorf("decoding GLiNER2 response: %w", err)
    }

    return result.Entities, nil
}

Entity Normalization and Upsert

The entity linker normalizes extracted text, checks for existing entity nodes using fuzzy matching, and either creates a new node or updates the mention count on an existing one.

package memory

import (
    "context"
    "fmt"
    "strings"
    "time"

    "github.com/example/mydb"
    "github.com/google/uuid"
    "github.com/texttheater/golang-levenshtein/levenshtein"
)

type EntityLinker struct {
    client *mydb.Client
    gliner *GLiNERClient
}

func (l *EntityLinker) UpsertEntity(
    ctx context.Context, ns string, entity ExtractedEntity,
) (string, error) {
    entitiesTable := fmt.Sprintf("%s_entities", ns)
    normalized := normalizeEntityName(entity.Text)

    // Look for existing entity with similar name and same type
    existing, err := l.client.Query(ctx, entitiesTable, mydb.Filter{
        Where: "entity_type = ?",
        Args:  []any{entity.Label},
    })
    if err != nil {
        return "", fmt.Errorf("querying entities: %w", err)
    }

    for _, row := range existing {
        existingName := row["name"].(string)
        distance := levenshtein.DistanceForStrings(
            []rune(normalized), []rune(existingName),
            levenshtein.DefaultOptions,
        )
        maxLen := max(len(normalized), len(existingName))
        similarity := 1.0 - float64(distance)/float64(maxLen)

        if similarity > 0.85 {
            // Match found — increment mention count
            existingID := row["id"].(string)
            err := l.client.Update(ctx, entitiesTable, existingID, mydb.Row{
                "mention_count": row["mention_count"].(int64) + 1,
            })
            return existingID, err
        }
    }

    // No match — create new entity node
    id := uuid.New().String()
    err = l.client.Insert(ctx, entitiesTable, mydb.Row{
        "id":            id,
        "name":          normalized,
        "entity_type":   entity.Label,
        "mention_count": int64(1),
        "created_at":    time.Now(),
    })
    return id, err
}

func (l *EntityLinker) CreateMentionEdge(
    ctx context.Context, ns string,
    memoryID string, entityID string, confidence float64,
) error {
    edgesTable := fmt.Sprintf("%s_edges", ns)
    return l.client.Insert(ctx, edgesTable, mydb.Row{
        "id":         uuid.New().String(),
        "source_id":  memoryID,
        "target_id":  entityID,
        "edge_type":  "mentions",
        "weight":     confidence,
        "created_at": time.Now(),
    })
}

func normalizeEntityName(raw string) string {
    name := strings.TrimSpace(raw)
    name = strings.ToLower(name)
    // Collapse multiple spaces
    for strings.Contains(name, "  ") {
        name = strings.ReplaceAll(name, "  ", " ")
    }
    return name
}

Graph-Augmented Memory Retrieval

The graph expansion step takes initial vector search results, follows entity edges to discover related memories, and merges the expanded set back using RRF.

func (s *MemoryStore) GraphAugmentedSearch(
    ctx context.Context, ns string, query string, limit int,
) ([]Memory, error) {
    // Stage 1: Vector search for initial candidates
    vectorResults, err := s.Search(ctx, ns, query, limit*2)
    if err != nil {
        return nil, err
    }

    // Stage 2: Extract entity IDs from initial results via edges
    edgesTable := fmt.Sprintf("%s_edges", ns)
    entityIDs := make(map[string]bool)
    for _, mem := range vectorResults {
        edges, _ := s.client.Query(ctx, edgesTable, mydb.Filter{
            Where: "source_id = ? AND edge_type = ?",
            Args:  []any{mem.ID, "mentions"},
        })
        for _, edge := range edges {
            entityIDs[edge["target_id"].(string)] = true
        }
    }

    // Stage 3: Find other memories mentioning the same entities
    graphResults := make([]Memory, 0)
    seen := make(map[string]bool)
    for _, mem := range vectorResults {
        seen[mem.ID] = true
    }

    for entityID := range entityIDs {
        edges, _ := s.client.Query(ctx, edgesTable, mydb.Filter{
            Where: "target_id = ? AND edge_type = ?",
            Args:  []any{entityID, "mentions"},
        })
        for _, edge := range edges {
            memID := edge["source_id"].(string)
            if seen[memID] {
                continue
            }
            seen[memID] = true
            mem, err := s.GetByID(ctx, ns, memID)
            if err == nil {
                graphResults = append(graphResults, mem)
            }
        }
    }

    // Stage 4: Fuse vector results and graph results
    // (simplified — production would use proper RRF)
    combined := append(vectorResults, graphResults...)
    if len(combined) > limit {
        combined = combined[:limit]
    }
    return combined, nil
}

Implementation Patterns

Configurable Entity Labels per Namespace

Different namespaces may care about different entity types. A DevOps team wants to extract “service,” “cluster,” and “incident” entities while a product team cares about “feature,” “customer,” and “metric.” Store configurable entity type labels in namespace metadata so the extraction task uses the right categories for each namespace.

Batch Entity Extraction

GLiNER2 inference is more efficient when batching multiple texts in a single request. The task-queue worker can accumulate pending extraction tasks and process them in batches of 8–16, reducing per-memory latency and GPU utilization overhead.

Entity Merge Operations

Over time, the normalization heuristic may create duplicate entity nodes that should be merged. Provide an explicit merge operation that re-points all mention edges from one entity to another, sums their mention counts, and deletes the duplicate node. Expose this as an admin API for human-in-the-loop graph curation.

Common Pitfalls

Further Reading