Files
relata/internal/embeddings/ollama.go
T
2026-08-11 10:40:10 +01:00

294 lines
7.7 KiB
Go

package embeddings
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"github.com/andydixon/relata/internal/domain"
"github.com/andydixon/relata/internal/observability"
)
// OllamaOptions configure the Ollama embedding adapter.
type OllamaOptions struct {
BaseURL string
Model string
Timeout time.Duration
BatchSize int
Retry RetryPolicy
// ExpectedDims, when non-zero, is asserted against the provider's first
// response. A silent dimension change would corrupt the vector collection.
ExpectedDims int
HTTPClient *http.Client
Metrics *observability.Metrics
}
// Ollama implements domain.Embedder against an Ollama server.
type Ollama struct {
base string
model string
batch int
retry RetryPolicy
client *http.Client
metrics *observability.Metrics
wantDims int
mu sync.RWMutex
dims int
}
var _ domain.Embedder = (*Ollama)(nil)
// NewOllama builds an Ollama-backed embedder.
func NewOllama(o OllamaOptions) *Ollama {
if o.Timeout <= 0 {
o.Timeout = 60 * time.Second
}
if o.BatchSize <= 0 {
o.BatchSize = 16
}
client := o.HTTPClient
if client == nil {
client = &http.Client{
Timeout: o.Timeout,
// The provider URL comes from operator configuration, never from a
// document, so no redirect-following restriction is needed beyond
// refusing to follow redirects at all.
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
}
return &Ollama{
base: strings.TrimRight(o.BaseURL, "/"),
model: o.Model,
batch: o.BatchSize,
retry: o.Retry,
client: client,
metrics: o.Metrics,
wantDims: o.ExpectedDims,
dims: o.ExpectedDims,
}
}
// ModelID returns the configured model name, stamped onto every stored vector.
func (o *Ollama) ModelID() string { return o.model }
// Dimensions returns the vector width, learned from the first response if the
// configured value was zero.
func (o *Ollama) Dimensions() int {
o.mu.RLock()
defer o.mu.RUnlock()
return o.dims
}
type ollamaEmbedRequest struct {
Model string `json:"model"`
Input []string `json:"input"`
}
type ollamaEmbedResponse struct {
Embeddings [][]float32 `json:"embeddings"`
Model string `json:"model"`
}
// Embed returns one vector per input text, in order.
//
// Inputs are sent in bounded batches; each batch is a separate request with its
// own retry budget, so one slow batch cannot stall the whole set indefinitely.
// Document text is never logged.
func (o *Ollama) Embed(ctx context.Context, texts []string) ([][]float32, error) {
if len(texts) == 0 {
return nil, nil
}
out := make([][]float32, 0, len(texts))
for start := 0; start < len(texts); start += o.batch {
end := start + o.batch
if end > len(texts) {
end = len(texts)
}
batch := texts[start:end]
vecs, err := o.embedBatch(ctx, batch)
if err != nil {
return nil, fmt.Errorf("embed batch %d-%d of %d: %w", start, end, len(texts), err)
}
if len(vecs) != len(batch) {
return nil, Permanent(fmt.Errorf(
"provider returned %d vectors for %d inputs", len(vecs), len(batch)))
}
out = append(out, vecs...)
}
return out, nil
}
func (o *Ollama) embedBatch(ctx context.Context, batch []string) ([][]float32, error) {
// Ollama rejects empty strings; substitute a single space and let the
// caller's chunker guarantee meaningful content.
inputs := make([]string, len(batch))
for i, t := range batch {
if strings.TrimSpace(t) == "" {
inputs[i] = " "
} else {
inputs[i] = t
}
}
body, err := json.Marshal(ollamaEmbedRequest{Model: o.model, Input: inputs})
if err != nil {
return nil, Permanent(fmt.Errorf("encode request: %w", err))
}
var result [][]float32
started := time.Now()
err = Do(ctx, o.retry, func(ctx context.Context, attempt int) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, o.base+"/api/embed", bytes.NewReader(body))
if err != nil {
return Permanent(err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := o.client.Do(req)
if err != nil {
return err
}
defer func() {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
se := &StatusError{Status: resp.StatusCode, Body: strings.TrimSpace(string(msg)), Op: "ollama embed"}
if resp.StatusCode == http.StatusNotFound {
return Permanent(fmt.Errorf(
"%w — is the model pulled? try: ollama pull %s", se, o.model))
}
return se
}
// Cap the response so a misbehaving provider cannot exhaust memory.
var out ollamaEmbedResponse
dec := json.NewDecoder(io.LimitReader(resp.Body, 64<<20))
if err := dec.Decode(&out); err != nil {
return fmt.Errorf("decode embedding response: %w", err)
}
if len(out.Embeddings) == 0 {
return Permanent(fmt.Errorf("provider returned no embeddings"))
}
result = out.Embeddings
return nil
})
outcome := "success"
if err != nil {
outcome = "error"
}
if o.metrics != nil {
o.metrics.AIRequests.WithLabelValues("ollama", "embed", outcome).Inc()
o.metrics.AIDuration.WithLabelValues("ollama", "embed").Observe(time.Since(started).Seconds())
}
if err != nil {
return nil, err
}
if err := o.checkDimensions(result); err != nil {
return nil, err
}
return result, nil
}
// checkDimensions validates vector width and learns it on first use. A mismatch
// is permanent: writing differently-sized vectors into one Qdrant collection
// would corrupt it, so the job must fail loudly instead.
func (o *Ollama) checkDimensions(vecs [][]float32) error {
if len(vecs) == 0 {
return nil
}
got := len(vecs[0])
if got == 0 {
return Permanent(fmt.Errorf("provider returned a zero-length vector"))
}
for i, v := range vecs {
if len(v) != got {
return Permanent(fmt.Errorf(
"provider returned inconsistent vector widths: %d at index 0, %d at index %d", got, len(v), i))
}
}
o.mu.Lock()
defer o.mu.Unlock()
if o.dims == 0 {
o.dims = got
return nil
}
if o.dims != got {
return Permanent(fmt.Errorf(
"embedding model %q returned %d dimensions but %d were expected; "+
"set RELATA_EMBEDDING_DIMENSIONS to %d and re-embed the workspace",
o.model, got, o.dims, got))
}
return nil
}
type ollamaTagsResponse struct {
Models []struct {
Name string `json:"name"`
} `json:"models"`
}
// Health reports whether Ollama is reachable and whether the configured model
// is present, so the settings page can tell the user what to pull.
func (o *Ollama) Health(ctx context.Context) domain.ProviderHealth {
h := domain.ProviderHealth{Name: "ollama", Endpoint: o.base, Model: o.model}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, o.base+"/api/tags", nil)
if err != nil {
h.Detail = err.Error()
return h
}
resp, err := o.client.Do(req)
if err != nil {
h.Detail = "cannot reach Ollama: " + err.Error()
return h
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
h.Detail = fmt.Sprintf("Ollama returned HTTP %d", resp.StatusCode)
return h
}
var tags ollamaTagsResponse
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&tags); err != nil {
h.Detail = "unreadable model list: " + err.Error()
return h
}
for _, m := range tags.Models {
h.Models = append(h.Models, m.Name)
}
h.Available = true
if !modelPresent(h.Models, o.model) {
h.Available = false
h.Detail = fmt.Sprintf("model %q is not installed — run: ollama pull %s", o.model, o.model)
}
return h
}
// modelPresent matches names allowing for an implicit ":latest" tag.
func modelPresent(models []string, want string) bool {
for _, m := range models {
if m == want || m == want+":latest" || strings.TrimSuffix(m, ":latest") == want {
return true
}
}
return false
}