11 KiB
AI provider interface
Relata's AI providers sit behind two interfaces declared in internal/domain. Ollama is the
default; nothing in the pipeline knows that.
The two ports
// internal/domain/ports.go
type Embedder interface {
// Embed returns one vector per input text, in the same order.
// Implementations must honour ctx cancellation and must not retry indefinitely.
Embed(ctx context.Context, texts []string) ([][]float32, error)
ModelID() string // stamped onto every derived record
Dimensions() int // vector width
}
type Analyser interface {
Complete(ctx context.Context, req CompletionRequest) (*CompletionResponse, error)
ModelID() string
}
CompletionRequest carries messages, an optional JSON schema, a token ceiling and a
temperature. Complete returns the raw content — it does not parse it. Parsing belongs to
internal/analysis, so a validation failure is attributed to the task rather than the
transport.
Optionally, a provider may implement HealthChecker to appear on the settings page:
type HealthChecker interface {
Health(ctx context.Context) ProviderHealth
}
Contract
Anything implementing these interfaces must:
| Requirement | Why |
|---|---|
Honour ctx cancellation |
A worker that loses its lease cancels the context; a provider that ignores it wastes the slot |
| Bound retries | A permanently failing provider must surface as a job failure, not an endless loop |
| Retry only transient errors | A 400 or an unknown model will not fix itself |
Return Permanent(err) for unrecoverable failures |
The worker uses this to decide not to retry |
| Return vectors in input order | The caller pairs them with chunks positionally |
| Return exactly one vector per input | Anything else is a permanent error |
| Keep vector width stable | A change corrupts the Qdrant collection |
| Bound response size | A misbehaving provider must not exhaust memory |
| Never log document content | Content is the user's data |
| Apply a timeout | Every outbound call is bounded |
| Refuse redirects | A compromised provider must not bounce requests elsewhere |
The helpers in internal/embeddings/retry.go — Do, Permanent, Retryable, StatusError
— are provider-agnostic and are the expected way to satisfy the retry requirements.
Adding an OpenAI-compatible provider
Two files. Nothing else changes.
1. The embedder
package embeddings
type OpenAICompatible struct {
base, model, apiKey string
dims int
client *http.Client
retry RetryPolicy
}
var _ domain.Embedder = (*OpenAICompatible)(nil)
func (o *OpenAICompatible) ModelID() string { return o.model }
func (o *OpenAICompatible) Dimensions() int { return o.dims }
func (o *OpenAICompatible) Embed(ctx context.Context, texts []string) ([][]float32, error) {
var out [][]float32
for start := 0; start < len(texts); start += o.batch {
end := min(start+o.batch, len(texts))
body, _ := json.Marshal(map[string]any{
"model": o.model,
"input": texts[start:end],
})
var resp struct {
Data []struct {
Index int `json:"index"`
Embedding []float32 `json:"embedding"`
} `json:"data"`
}
err := Do(ctx, o.retry, func(ctx context.Context, attempt int) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
o.base+"/v1/embeddings", bytes.NewReader(body))
if err != nil {
return Permanent(err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+o.apiKey)
httpResp, err := o.client.Do(req)
if err != nil {
return err // transport failures are retried
}
defer httpResp.Body.Close()
if httpResp.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(io.LimitReader(httpResp.Body, 2048))
se := &StatusError{Status: httpResp.StatusCode, Body: string(msg), Op: "embeddings"}
if httpResp.StatusCode == 400 || httpResp.StatusCode == 404 {
return Permanent(se) // never retried
}
return se // Retryable() decides from the status
}
return json.NewDecoder(io.LimitReader(httpResp.Body, 64<<20)).Decode(&resp)
})
if err != nil {
return nil, err
}
// The API may return results out of order.
sort.Slice(resp.Data, func(i, j int) bool { return resp.Data[i].Index < resp.Data[j].Index })
for _, d := range resp.Data {
out = append(out, d.Embedding)
}
}
if len(out) != len(texts) {
return nil, Permanent(fmt.Errorf("provider returned %d vectors for %d inputs", len(out), len(texts)))
}
return out, nil
}
2. The analyser
package analysis
type OpenAICompatibleAnalyser struct { /* … */ }
var _ domain.Analyser = (*OpenAICompatibleAnalyser)(nil)
func (a *OpenAICompatibleAnalyser) Complete(ctx context.Context,
req domain.CompletionRequest) (*domain.CompletionResponse, error) {
payload := map[string]any{
"model": a.model,
"messages": toOpenAIMessages(req.Messages),
"max_tokens": req.MaxTokens,
"temperature": req.Temperature,
}
// Structured output, where the provider supports it.
if len(req.JSONSchema) > 0 {
payload["response_format"] = map[string]any{
"type": "json_schema",
"json_schema": map[string]any{
"name": "relata_task",
"strict": true,
"schema": json.RawMessage(req.JSONSchema),
},
}
}
// … POST /v1/chat/completions, return the raw content unparsed.
}
3. Wire it in
In cmd/relata/main.go, choose the implementation from configuration:
var embedder domain.Embedder
switch cfg.Ollama.Provider { // e.g. RELATA_AI_PROVIDER
case "openai":
embedder = embeddings.NewOpenAICompatible(...)
default:
embedder = embeddings.NewOllama(...)
}
Every service takes domain.Embedder and domain.Analyser, so nothing downstream changes.
Structured output without native schema support
analysis.DecodeStrict does not require the provider to enforce the schema. It:
- rejects unknown fields and trailing content;
- bounds the payload before parsing;
- unwraps a complete markdown fence (a structural operation on a delimiter — if what remains is not valid JSON it is still rejected);
- never uses a regular expression to find or repair JSON.
A provider without structured-output support therefore still works: the schema is included in
the prompt as guidance, and validation catches what the model gets wrong. Expect a higher
rejection rate, visible in relata_model_invalid_output_total.
Task-level validation
Above the transport, each task validates independently of the provider:
| Task | Checks |
|---|---|
extract_entities |
Entity type enum per row; a bad row is skipped, not fatal. An evidence quotation absent from the source is dropped. |
extract_claims |
Nature enum; quotation must be locatable in the source (whitespace reflow tolerated), or the claim is discarded. |
classify_relationship |
Relationship type enum; confidence in 0–1. |
generate_answer |
Citations resolved against supplied refs; fabricated references dropped and counted. |
Substance, not only shape
A JSON schema constrains a response's shape, not its substance. A small model asked for
{"answer": ..., "sufficient": ...} will sometimes return the schema back:
{"answer": "sufficient", "sufficient": true, "citations": []}
That is schema-valid and completely empty. llama3.2:3b produced exactly this, and an earlier
build displayed the word "sufficient" to the user as an answer.
Free-text fields are therefore also checked for degeneracy (analysis.Degenerate), which
rejects a value that is a bare field name or enum member from the schema itself, a placeholder
("string", "n/a", "TODO", "..."), or too short to carry meaning:
| Field | Minimum | Rejected as |
|---|---|---|
generate_answer → answer |
15 chars | degenerate_content |
extract_entities → name |
2 chars | degenerate_content |
extract_claims → statement |
10 chars | degenerate_content |
| proposed alias → name | 2 chars | degenerate_content |
A rejected answer is reported as unavailable rather than shown, and the request still returns its retrieved sources and any contradictions Relata recorded between them — those are deterministic and do not depend on the model. Only a transport failure fails the request.
The same word list applies to every field, which costs a little precision on names: a person genuinely surnamed "Na", or an organisation called exactly "Example", would be rejected as a placeholder. The cost is bounded — a rejected row is skipped, the rest of the extraction proceeds, and deterministic extraction is unaffected — and a model returning a bare placeholder word as an entity name is overwhelmingly more often noise than a real name.
This is a floor on obvious emptiness, not a quality judgement. It cannot tell a poor answer from a good one, only a non-answer from an answer.
Rejections are recorded on relata_model_invalid_output_total{task,reason} with a bounded
reason set, and are not retried — a permanently invalid response will not become valid by
repetition.
Running without a language model
Set RELATA_ANALYSIS_ENABLED=false. analysis.Service reports itself disabled and every task
returns ErrDisabled, which the pipeline treats as degraded quality rather than failure.
Still working: ingestion, parsing, chunking, embedding, deterministic entity extraction, deterministic claim extraction, relationship discovery and scoring, hybrid search, and the whole interface.
Not working: model-assisted entity and claim extraction, relationship type classification by model, and generated answers.
An embedder is still required for meaning-based search. Without one, search degrades to keyword, entity and relationship matching, and says so in the response notes.
Model versions and stored data
Every derived record carries the model that produced it, and model_versions records what has
been seen per workspace. The settings page shows this, and warns when the stored vector count
does not match the chunk count.
Changing the embedding model changes the vector geometry. Relata detects a dimension
mismatch and refuses to write mixed-width vectors — a permanent error naming the fix. After
such a change: set RELATA_EMBEDDING_DIMENSIONS, then use Settings → Re-embed workspace,
which processes in bounded batches and re-enqueues itself rather than holding one lease for
hours.
Changing the analysis model does not invalidate anything; reprocess items to refresh model-derived entities, claims and relationship classifications.