Concepts Retrieval & citations
Concept

Retrieval & citations

Sparsi provides a robust architectural foundation for RAG-driven MCP servers. It ships an opinionated retrieval layer: a single Retriever interface, two retrieval operators that thread query and filters through the workflow, and an EmbeddingClientFactory abstraction for vector-store backends.

Interface

One method, any backend

You implement Retriever; Sparsi calls it. The library never sees inside — your retriever is free to use BM25, embeddings + a vector store, a hosted search API, or anything else.

library/retriever.go
type Retriever interface {
    Retrieve(ctx context.Context, query string, k int) ([]Document, error)
}

type Document struct {
    ID       string
    Content  string
    Score    float64
    Metadata map[string]any
}

// framework-documented metadata keys (use these instead of bare strings)
const (
    MetadataSource     = "source"      // filename or document title
    MetadataSourceURL  = "source_url"  // canonical URL
    MetadataHighlights = "highlights"  // []string of matched snippets
    MetadataUpdatedAt  = "updated_at"  // time.Time (UTC recommended)
)

Register your Retriever before engine.Run: library.SetDefaultRetriever(r) for a process-wide default, or library.RegisterRetriever("acme", r) for an id you select from a vertex param. Implementations must be safe for concurrent Retrieve calls; graph execution invokes a single retriever from multiple parallel vertices.

Operators

Two operators, parallel outputs

Both ops emit two output wires: Documents (full records) and Texts (the parallel []string of Document.Content). Wire Texts straight into AI ops that take *[]string; wire Documents into downstream ops that need metadata.

RetrieveOp

Top-k retrieval. Params: k (default 5), retriever_id, embedding credential routing (credential_ref, client_factory_id, api_factory_timeout_ms, embed_timeout_ms).

Inputs: Query *string. Outputs: Documents []Document, Texts []string.

RetrieveWithFiltersOp

Same as above plus a Filters *map[string]string input wire and a static_filters param (CSV "tenant=acme,locale=en"). Runtime filters win on key collision; both are installed into ctx via library.WithRetrievalFilters for the retriever to read.

Use when filters come from upstream ops (auth, classifier, planner).

Security note: queries and filter values are untrusted — they routinely come from upstream AI ops. Retriever implementations must pass them through their backend's parameterized-query / placeholder / typed-filter API. Never string-concatenate them into SQL, search DSLs, or shell commands.

Citation integrity

ValidateCitationsOp — the security boundary

LLMs hallucinate citations. Without enforcement, "Sources: file1.txt, file2.txt" at the bottom of a response might include filenames the model invented or imported from training data. ValidateCitationsOp filters raw citations against an allow-list of identifiers and partitions them into Accepted / Rejected.

canonical RAG wiring
// 1. Retrieve.
Vertex("retrieve").Op("RetrieveOp").
    Params(map[string]string{"k": "3"}).
    Input("Query", "question").
    Output("Documents", "documents").

// 2. Derive the allow-list from retrieved docs (NOT the full corpus).
Vertex("retrieved_sources").Op("RetrievedSourcesOp").
    Input("Documents", "documents").
    Output("Sources", "retrieved_sources").

// 3. AI generates the answer with a "Sources: ..." trailer.
Vertex("answer").Op("AIComputeStringToStringOp").Input("Input", "prompt").
    Output("Result", "raw_answer").

// 4. Parse the citations out of the response.
Vertex("parse_citations").Op("ParseCitationsOp").
    Input("Raw", "raw_answer").
    Output("Sources", "sources").

// 5. Filter against the allow-list. Hallucinations land in Rejected.
Vertex("validate_citations").Op("ValidateCitationsOp").
    Input("Raw",     "sources").
    Input("Allowed", "retrieved_sources").
    Output("Accepted", "accepted_sources").
    Output("Rejected", "rejected_sources").

Build the allow-list from documents actually retrieved, not from the full corpus. Otherwise a model that hallucinates the filename of a real-but-unretrieved KB document slips past the check. Surface Rejected via slog at WARN — it's signal about model behavior, not a graph failure.

Embeddings

Vector-store backends plug in through a factory

Retrievers that embed text — query embedding for cosine similarity, batch embedding for index build — call library.ResolveEmbeddingClient(ctx, provider, model) instead of reading env vars directly. The framework installs credentials on ctx from the vertex's params, then a registered EmbeddingClientFactory materialises a client.

library/embedding_factory.go
type EmbeddingClient interface {
    Embed(ctx context.Context, texts []string) ([][]float32, error)
}

type EmbeddingClientFactory interface {
    Embedder(ctx context.Context, provider, model, ref string) (EmbeddingClient, error)
}

// the bundled default is gemini-only — register a custom factory for
// any other provider (Voyage, OpenAI, Cohere, Vertex, in-house, ...)
library.SetDefaultEmbeddingClientFactory(myFactory)
library.RegisterEmbeddingClientFactory("voyage-prod", voyageFactory)

The bundled EnvEmbeddingClientFactory only supports provider="gemini" via GEMINI_API_KEY. This is intentionally asymmetric with AI ops (whose bundled factory handles both Claude and Gemini) — embedding vendors are fragmented and Sparsi stays out of opinions about SDKs.

See it in practice