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.
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.
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.
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.
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.
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.
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
})
}
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
}
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
}
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
}
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.
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.
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.
mentions edge is directional (memory → entity). If you also need entity → entity relationships (e.g., “Alice manages the payments team”), you need a separate relation extraction step beyond NER. Don’t conflate entity recognition with relation extraction — they require different models.