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.
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.
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.
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.
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.
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.
The plugin operates in three phases: initialization (connect and provision), indexing (ingest documents with embeddings), and retrieval (hybrid search at query time).
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.
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.
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.
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)
}
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)
}
A Genkit retriever plugin enables a range of RAG applications built on top of a database platform.
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.
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.
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.
DB_AUTO_PROVISION=true) and disable it in production.k parameter in reciprocal rank fusion controls how quickly the contribution of lower-ranked results decays. A k of 60 is the standard default, but different corpora may benefit from tuning. Too low and the fusion over-weights top results; too high and it under-differentiates ranks.context.WithTimeout to bound the maximum wait time and return partial results from whichever search completed.