Building a Genkit Retriever Plugin

Agentic AI Systems intermediate Building with AI ~30 min read

Overview

Google’s Genkit framework provides a standardized interface for building AI-powered applications in Go, with first-class support for retrievers — components that fetch relevant documents for retrieval-augmented generation (RAG). Building a Genkit retriever plugin for a custom database bridges the gap between the Genkit AI application framework and a database platform, enabling any Genkit-based application to use that database as its document store and vector search backend.

A well-designed Genkit plugin bootstraps database clients from environment variables, automatically creates tables and indexes with Ollama-based embedders, hashes document IDs with xxhash for efficient storage, and blends semantic vector search with Bleve text filters and order-by controls. This gives Genkit applications a powerful retriever that combines the precision of keyword filtering with the recall of semantic similarity — a hybrid search approach that consistently outperforms either technique alone.

This topic covers the architecture of a Genkit retriever plugin: the plugin registration interface, client bootstrapping, auto-provisioning of tables and indexes, embedding pipeline integration, hybrid search blending, and the practical considerations of deploying a Go-based retriever in production. These patterns apply to any framework-specific plugin that wraps a database or search backend for AI applications.

Core Concepts

Genkit Plugin Architecture

Genkit plugins follow a registration pattern: a plugin exports an initializer that accepts configuration and returns components (retrievers, embedders, indexers) that Genkit discovers and wires into the application’s dependency graph. The plugin lifecycle includes initialization (connection setup, schema validation), component registration (making retrievers available by name), and graceful shutdown (connection cleanup). Plugins must be stateless across requests but can maintain connection pools and caches.

Environment-Based Client Bootstrapping

Rather than hardcoding connection details, the plugin reads configuration from environment variables (DB_HOST, DB_API_KEY, DB_TABLE). This follows the twelve-factor app methodology and makes the plugin work seamlessly across development, staging, and production environments. The bootstrapping phase also validates connectivity and schema compatibility before registering the retriever as available.

Auto-Provisioning Tables and Indexes

On first initialization, the plugin checks whether the required table and indexes exist. If not, it creates them automatically — including the Bleve text index for keyword search and the vector index for semantic search using an Ollama embedder. This “just works” behavior is critical for developer experience: a new developer can clone the repo, set environment variables, and run the application without manual database setup. The provisioning is idempotent — running it multiple times has no effect if the resources already exist.

xxhash Document ID Hashing

Document IDs in the source system may be long strings (UUIDs, URLs, file paths). The plugin hashes these with xxhash — a non-cryptographic hash function optimized for speed — to produce compact, fixed-size keys for database storage. xxhash is deterministic, so the same document always maps to the same key, enabling efficient upserts and deduplication. The original ID is stored as a metadata field for reverse lookup.

Hybrid Search: Semantic + Keyword Blending

Pure vector search finds semantically similar documents but misses exact keyword matches. Pure keyword search finds exact matches but misses semantic equivalents (“car” vs. “automobile”). The plugin blends both: it runs a vector similarity search and a Bleve keyword search in parallel, then merges the result sets using reciprocal rank fusion (RRF). This hybrid approach consistently delivers better relevance than either approach alone, especially for domain-specific queries where exact terminology matters.

How It Works

The plugin operates in three phases: initialization (connect and provision), indexing (ingest documents with embeddings), and retrieval (hybrid search at query time).

Initialization Phase

When the Genkit application starts, it calls the plugin’s Init function. The plugin reads environment variables, creates a database client, pings the server to verify connectivity, and checks for the required table. If the table doesn’t exist, it creates it with the appropriate schema — a content column for document text, a metadata column for structured attributes, and a vector column configured for the Ollama embedding model’s dimensionality. It then creates the Bleve index on the content column and the vector index on the embedding column.

Indexing Phase

When documents are added through the Genkit indexer interface, each document goes through: (1) ID hashing with xxhash to generate a stable key, (2) embedding generation via the configured Ollama model, (3) upsert into the database with the content, metadata, and embedding vector stored together. Batch indexing groups documents into chunks to reduce round trips. The indexer is idempotent — re-indexing a document with the same ID updates it in place.

Retrieval Phase

At query time, the retriever: (1) generates an embedding for the query text using the same Ollama model, (2) executes a vector similarity search against the vector index, (3) executes a Bleve keyword search against the text index, (4) merges results using reciprocal rank fusion, and (5) returns the top-K documents with their scores, content, and metadata. Bleve filters (date ranges, category restrictions, boolean flags) can be applied alongside the semantic search to narrow results without sacrificing relevance ranking.

Implementation Patterns

Plugin Registration and Initialization

The plugin follows Genkit’s registration pattern, exporting an Init function that configures the database client and registers the retriever.

package dbretriever

import (
    "context"
    "fmt"
    "os"

    "github.com/cespare/xxhash/v2"
    "github.com/firebase/genkit/go/ai"
    "github.com/firebase/genkit/go/genkit"
)

type Config struct {
    Host      string
    APIKey    string
    TableName string
    Model     string // Ollama model name for embeddings
    TopK      int
}

func ConfigFromEnv() Config {
    return Config{
        Host:      os.Getenv("DB_HOST"),
        APIKey:    os.Getenv("DB_API_KEY"),
        TableName: os.Getenv("DB_TABLE"),
        Model:     os.Getenv("DB_EMBED_MODEL"),
        TopK:      20,
    }
}

type Plugin struct {
    client *DatabaseClient
    config Config
}

func Init(ctx context.Context, g *genkit.Genkit, cfg Config) (*Plugin, error) {
    client, err := NewDatabaseClient(cfg.Host, cfg.APIKey)
    if err != nil {
        return nil, fmt.Errorf("db connect: %w", err)
    }

    // Auto-provision table and indexes
    if err := client.EnsureTable(ctx, cfg.TableName); err != nil {
        return nil, fmt.Errorf("ensure table: %w", err)
    }
    if err := client.EnsureBleveIndex(ctx, cfg.TableName, "content"); err != nil {
        return nil, fmt.Errorf("ensure bleve index: %w", err)
    }
    if err := client.EnsureVectorIndex(ctx, cfg.TableName, "embedding", cfg.Model); err != nil {
        return nil, fmt.Errorf("ensure vector index: %w", err)
    }

    p := &Plugin{client: client, config: cfg}

    // Register the retriever with Genkit
    ai.DefineRetriever(g, "mydb", cfg.TableName, p.retrieve)

    return p, nil
}

func (p *Plugin) retrieve(
    ctx context.Context,
    req *ai.RetrieverRequest,
) (*ai.RetrieverResponse, error) {
    return p.hybridSearch(ctx, req.Document.Content[0].Text, req.Options)
}

Hybrid Search with Reciprocal Rank Fusion

The retriever runs semantic and keyword searches in parallel, then merges results using RRF scoring.

func (p *Plugin) hybridSearch(
    ctx context.Context,
    query string,
    opts any,
) (*ai.RetrieverResponse, error) {
    type result struct {
        docs []ScoredDoc
        err  error
    }

    vectorCh := make(chan result, 1)
    keywordCh := make(chan result, 1)

    // Run vector search
    go func() {
        docs, err := p.client.VectorSearch(ctx, p.config.TableName, query, p.config.TopK*2)
        vectorCh <- result{docs, err}
    }()

    // Run keyword search
    go func() {
        docs, err := p.client.BleveSearch(ctx, p.config.TableName, query, p.config.TopK*2)
        keywordCh <- result{docs, err}
    }()

    vr := <-vectorCh
    kr := <-keywordCh

    if vr.err != nil {
        return nil, fmt.Errorf("vector search: %w", vr.err)
    }
    if kr.err != nil {
        return nil, fmt.Errorf("keyword search: %w", kr.err)
    }

    // Reciprocal rank fusion
    merged := reciprocalRankFusion(vr.docs, kr.docs, 60) // k=60

    // Take top-K
    if len(merged) > p.config.TopK {
        merged = merged[:p.config.TopK]
    }

    // Convert to Genkit response
    resp := &ai.RetrieverResponse{}
    for _, doc := range merged {
        resp.Documents = append(resp.Documents, &ai.Document{
            Content: []*ai.Part{ai.NewTextPart(doc.Content)},
            Metadata: doc.Metadata,
        })
    }
    return resp, nil
}

func reciprocalRankFusion(
    vectorResults, keywordResults []ScoredDoc,
    k int,
) []ScoredDoc {
    scores := make(map[string]float64)
    docs := make(map[string]ScoredDoc)

    for rank, doc := range vectorResults {
        id := doc.ID
        scores[id] += 1.0 / float64(k+rank+1)
        docs[id] = doc
    }
    for rank, doc := range keywordResults {
        id := doc.ID
        scores[id] += 1.0 / float64(k+rank+1)
        docs[id] = doc
    }

    // Sort by fused score descending
    type scored struct {
        doc   ScoredDoc
        score float64
    }
    var merged []scored
    for id, score := range scores {
        merged = append(merged, scored{docs[id], score})
    }
    sort.Slice(merged, func(i, j int) bool {
        return merged[i].score > merged[j].score
    })

    result := make([]ScoredDoc, len(merged))
    for i, s := range merged {
        result[i] = s.doc
        result[i].Score = s.score
    }
    return result
}

// HashDocID produces a stable, compact key from an arbitrary document ID
func HashDocID(id string) string {
    h := xxhash.Sum64String(id)
    return fmt.Sprintf("%016x", h)
}

Practical Applications

A Genkit retriever plugin enables a range of RAG applications built on top of a database platform.

Document Q&A with Genkit Flows

A Genkit flow ingests a corpus of documentation (markdown files, API specs, knowledge base articles) into the database via the indexer. At query time, the flow retrieves the top-K relevant documents using the hybrid retriever and feeds them as context to an LLM for answer generation. The hybrid search ensures both semantically related content and exact technical terminology are captured in the retrieval step.

Embedding Model Hot-Swapping

Because the plugin reads the embedding model name from configuration, switching from one Ollama model to another (e.g., from nomic-embed-text to mxbai-embed-large) requires only changing an environment variable and re-indexing. The auto-provisioning logic detects the dimensionality change and creates a new vector index compatible with the new model. This enables A/B testing different embedding models without code changes.

Multi-Tenant Retrieval

Each tenant’s documents are stored in a separate database table. The plugin instantiation is parameterized by table name, so a single Genkit application can serve multiple tenants by creating one retriever per tenant. Bleve filters on the metadata column provide additional access control within a shared table when full table-level isolation is too expensive.

Common Pitfalls

Further Reading