Hybrid Search with Entity Graph Expansion

Embeddings & Vector Search intermediate Building with AI ~20 min read

Overview

Traditional search systems choose between keyword matching (full-text search) and semantic similarity (vector search), but each approach has blind spots. Keyword search misses paraphrased content; vector search misses exact terms and named entities. Hybrid search combines both approaches using Reciprocal Rank Fusion (RRF) to merge ranked result lists from different retrieval strategies into a single, higher-quality ranking.

The memory system takes hybrid search further by adding a third retrieval channel: entity graph expansion. When a query mentions entities (people, projects, concepts), the system recognizes them on the fly, looks up their neighbors in a knowledge graph, and includes those neighbors in the search context. If you search for “Alice’s architecture decisions,” the system not only finds memories mentioning Alice but also surfaces memories about projects Alice is connected to in the entity graph—even if those memories don’t mention Alice by name.

This three-channel approach—full-text, semantic, and graph-expanded—produces significantly richer recall for memory-oriented applications where relationships between entities carry as much meaning as the content of individual documents.

Core Concepts

Reciprocal Rank Fusion (RRF)

RRF is a simple, parameter-light method for combining ranked lists from different retrieval systems. For each document that appears in any list, its RRF score is the sum of 1 / (k + rank) across all lists where it appears, with k being a constant (typically 60) that dampens the influence of high ranks. RRF requires no score normalization and handles heterogeneous score scales gracefully, making it ideal for merging full-text BM25 scores with cosine similarity scores.

On-the-Fly Entity Recognition

Rather than maintaining a pre-built NER pipeline, the memory system performs lightweight entity recognition at query time. It extracts candidate entities from the query using a combination of capitalization heuristics, known-entity lookup against the graph, and optional LLM-based extraction for ambiguous cases. Recognized entities become seeds for graph traversal, expanding the search beyond the literal query terms.

Entity Knowledge Graph

The entity graph stores relationships between entities extracted from ingested memories: people, projects, organizations, concepts, and locations. Edges represent observed relationships (“works on,” “mentioned with,” “decided about”). The graph is built incrementally as new memories are added and is stored as adjacency lists in the database. Traversal is limited to 1-hop neighbors to keep query latency bounded.

Graph Neighbor Traversal

Given a set of seed entities from the query, the system traverses 1-hop outgoing edges in the entity graph to collect related entities. These neighbor entities are used to construct additional filter queries against the full-text and vector indexes. For example, if “Alice” connects to “Project X” and “Architecture Review,” the system adds those terms to the search, surfacing memories about Project X that don’t mention Alice directly.

Three-Channel Retrieval Architecture

The hybrid search pipeline runs three retrieval channels in parallel: (1) full-text search using BM25 or similar scoring against an inverted index, (2) semantic vector search using cosine similarity against the embedding index, and (3) graph-expanded search that uses entity neighbors as additional query terms. Results from all three channels are merged via RRF, producing a final ranked list that captures keyword matches, semantic similarity, and relational context.

How It Works

When a search query arrives, the system runs entity recognition and all three retrieval channels concurrently. The entity recognizer feeds its results to the graph expansion channel while full-text and vector search proceed independently. Once all channels return their ranked results, RRF merges them into a final ranking.

RRF Score Computation

package search

// RRFConstant is the dampening factor for reciprocal rank fusion.
// A value of 60 is standard in the literature (Cormack et al., 2009).
const RRFConstant = 60

// RankedDoc represents a document with its rank from a single retrieval channel.
type RankedDoc struct {
    ID   string
    Rank int
}

// FuseResults merges ranked lists from multiple retrieval channels using RRF.
// Each input slice is a ranked list (index 0 = rank 1).
func FuseResults(channels ...[]RankedDoc) []ScoredDoc {
    scores := make(map[string]float64)

    for _, channel := range channels {
        for _, doc := range channel {
            scores[doc.ID] += 1.0 / float64(RRFConstant+doc.Rank)
        }
    }

    // Convert to sorted slice
    result := make([]ScoredDoc, 0, len(scores))
    for id, score := range scores {
        result = append(result, ScoredDoc{ID: id, Score: score})
    }

    sort.Slice(result, func(i, j int) bool {
        return result[i].Score > result[j].Score
    })

    return result
}

// ScoredDoc holds a document ID and its fused RRF score.
type ScoredDoc struct {
    ID    string
    Score float64
}

Entity Recognition and Graph Expansion

package search

import (
    "context"
    "strings"
)

// EntityGraph provides neighbor lookup for known entities.
type EntityGraph interface {
    // Lookup returns the entity ID if the name is known, or empty string.
    Lookup(ctx context.Context, name string) (entityID string, found bool)
    // Neighbors returns 1-hop neighbor entity names for a given entity ID.
    Neighbors(ctx context.Context, entityID string) ([]string, error)
}

// ExtractAndExpand recognizes entities in the query and returns expanded terms.
func ExtractAndExpand(ctx context.Context, query string, graph EntityGraph) ([]string, error) {
    // Simple entity extraction: split on spaces, check capitalized tokens
    // against the known entity graph.
    tokens := strings.Fields(query)
    var seedEntities []string

    for _, token := range tokens {
        cleaned := strings.Trim(token, ".,!?\"'")
        if len(cleaned) == 0 {
            continue
        }
        if entityID, found := graph.Lookup(ctx, cleaned); found {
            seedEntities = append(seedEntities, entityID)
        }
    }

    // Also try multi-word entity names (bigrams)
    for i := 0; i < len(tokens)-1; i++ {
        bigram := strings.Trim(tokens[i], ".,!?") + " " + strings.Trim(tokens[i+1], ".,!?")
        if entityID, found := graph.Lookup(ctx, bigram); found {
            seedEntities = append(seedEntities, entityID)
        }
    }

    // Expand: collect 1-hop neighbors for all seed entities
    expanded := make([]string, 0)
    seen := make(map[string]bool)
    for _, eid := range seedEntities {
        neighbors, err := graph.Neighbors(ctx, eid)
        if err != nil {
            continue // non-fatal: skip this entity's expansion
        }
        for _, neighbor := range neighbors {
            if !seen[neighbor] {
                seen[neighbor] = true
                expanded = append(expanded, neighbor)
            }
        }
    }

    return expanded, nil
}

Full Hybrid Search Pipeline

package search

import (
    "context"
    "fmt"
    "sync"
)

// HybridSearcher orchestrates three-channel retrieval with RRF fusion.
type HybridSearcher struct {
    FullText    FullTextIndex
    Vector      VectorIndex
    Graph       EntityGraph
    Embedder    Embedder
}

// Search runs all three channels concurrently and fuses results.
func (h *HybridSearcher) Search(ctx context.Context, query string, topK int) ([]ScoredDoc, error) {
    var (
        ftResults    []RankedDoc
        vecResults   []RankedDoc
        graphResults []RankedDoc
        ftErr, vecErr, graphErr error
        wg sync.WaitGroup
    )

    // Channel 1: Full-text search
    wg.Add(1)
    go func() {
        defer wg.Done()
        ftResults, ftErr = h.FullText.Search(ctx, query, topK*2)
    }()

    // Channel 2: Vector similarity search
    wg.Add(1)
    go func() {
        defer wg.Done()
        queryVec, err := h.Embedder.Embed(ctx, query)
        if err != nil {
            vecErr = err
            return
        }
        vecResults, vecErr = h.Vector.Search(ctx, queryVec, topK*2)
    }()

    // Channel 3: Graph-expanded search
    wg.Add(1)
    go func() {
        defer wg.Done()
        expanded, err := ExtractAndExpand(ctx, query, h.Graph)
        if err != nil || len(expanded) == 0 {
            graphErr = err
            return
        }
        // Search for graph-expanded entity terms via full-text
        expandedQuery := query + " " + strings.Join(expanded, " ")
        graphResults, graphErr = h.FullText.Search(ctx, expandedQuery, topK*2)
    }()

    wg.Wait()

    // Collect available channels (graph expansion is optional)
    channels := make([][]RankedDoc, 0, 3)
    if ftErr == nil {
        channels = append(channels, ftResults)
    }
    if vecErr == nil {
        channels = append(channels, vecResults)
    }
    if graphErr == nil && len(graphResults) > 0 {
        channels = append(channels, graphResults)
    }

    if len(channels) == 0 {
        return nil, fmt.Errorf("all search channels failed: ft=%v, vec=%v, graph=%v", ftErr, vecErr, graphErr)
    }

    fused := FuseResults(channels...)

    // Trim to requested topK
    if len(fused) > topK {
        fused = fused[:topK]
    }

    return fused, nil
}

Practical Applications

The memory system uses this hybrid search architecture for its core memory retrieval pipeline. When a user or AI agent queries for relevant memories, the system needs to surface results that match the exact query terms (keyword search), results that are semantically related but use different wording (vector search), and results that are contextually connected through entity relationships (graph expansion). A query like “what did the team decide about the migration?” retrieves memories that mention “migration” literally, memories about database schema changes (semantically similar), and memories about the team members and projects involved in the migration (graph-expanded).

The graph expansion channel is particularly valuable for memory applications because human conversations are full of implicit references. People say “the project” instead of naming it, or refer to decisions by their outcomes rather than the meeting where they were made. The entity graph bridges these implicit connections, recovering context that neither keyword nor semantic search can find alone.

The RRF fusion approach also makes the system robust to individual channel failures. If the vector index is temporarily unavailable, the system degrades gracefully to full-text plus graph-expanded search, rather than returning empty results.

Common Pitfalls

Further Reading