Backend.Upsert never received the fragment's Content, so ChromaDB (and any backend) stored the vector but silently dropped the actual text — saved memories had nothing to retrieve later. Backend.Search now also takes the raw query text, and a failed/missing embedding no longer hard-fails Add/Search: it degrades to a nil vector so a lexical-capable backend can still index/find the content (Chroma has no such fallback and now says so explicitly instead of misbehaving). Adds pkg/rag/backends/sqlitevec: a zero-dependency backend (pure-Go SQLite, no external service) that does cosine similarity when a real embedding vector is available and falls back to FTS5/BM25 full-text search otherwise. Adds pkg/rag/embeddings.OpenAICompatible, covering both a local llama.cpp server (`--embeddings` enabled) and real OpenAI (or any OpenAI-shaped /embeddings endpoint) through the same client. Also fixes token usage tracking for llama.cpp streaming: the client never requested `stream_options.include_usage` nor parsed a usage-only SSE event, and even when present, the agent loop's RunStream dropped any chunk with no Delta/ReasoningDelta — silently discarding the only chunk that carries usage. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
123 lines
4 KiB
Go
123 lines
4 KiB
Go
package embeddings
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"sync/atomic"
|
|
)
|
|
|
|
// OpenAICompatibleConfig holds the settings for any embeddings API that
|
|
// follows OpenAI's request/response shape: POST {BaseURL}/embeddings with
|
|
// {"input": ..., "model": ...}, returning {"data": [{"embedding": [...]}]}.
|
|
// This covers llama.cpp (started with --embeddings), real OpenAI, and most
|
|
// third-party providers advertised as "OpenAI-compatible".
|
|
type OpenAICompatibleConfig struct {
|
|
BaseURL string // e.g. "http://localhost:8080/v1" or "https://api.openai.com/v1"
|
|
APIKey string // sent as "Authorization: Bearer <key>" when non-empty; local servers like llama.cpp don't need one
|
|
Model string // e.g. "text-embedding-3-small"; ignored by servers that only have one model loaded
|
|
}
|
|
|
|
// OpenAICompatible implements Embedder against any OpenAI-shaped
|
|
// /embeddings endpoint.
|
|
type OpenAICompatible struct {
|
|
baseURL string
|
|
apiKey string
|
|
model string
|
|
http *http.Client
|
|
dims atomic.Int64 // lazily learned from the first successful response
|
|
}
|
|
|
|
// NewOpenAICompatible creates a new OpenAI-shaped embedder.
|
|
func NewOpenAICompatible(cfg OpenAICompatibleConfig) (*OpenAICompatible, error) {
|
|
if cfg.BaseURL == "" {
|
|
return nil, fmt.Errorf("base URL is required")
|
|
}
|
|
return &OpenAICompatible{
|
|
baseURL: cfg.BaseURL,
|
|
apiKey: cfg.APIKey,
|
|
model: cfg.Model,
|
|
http: http.DefaultClient,
|
|
}, nil
|
|
}
|
|
|
|
// NewLlamaCpp is a convenience constructor for a local llama.cpp server
|
|
// (defaults to http://localhost:8080/v1, no API key). The server must have
|
|
// been started with the `--embeddings` flag, otherwise every call fails
|
|
// (llama.cpp returns a 501). Quality depends on the loaded model: dedicated
|
|
// embedding models (e.g. nomic-embed-text, bge-m3) work best, but a
|
|
// chat/instruct model still produces a usable semantic vector via pooling.
|
|
func NewLlamaCpp(cfg LlamaCppConfig) (*OpenAICompatible, error) {
|
|
baseURL := cfg.BaseURL
|
|
if baseURL == "" {
|
|
baseURL = "http://localhost:8080/v1"
|
|
}
|
|
return NewOpenAICompatible(OpenAICompatibleConfig{BaseURL: baseURL, Model: cfg.Model})
|
|
}
|
|
|
|
// LlamaCppConfig holds the settings for NewLlamaCpp.
|
|
type LlamaCppConfig struct {
|
|
BaseURL string // e.g. "http://localhost:8080/v1"
|
|
Model string // optional; llama.cpp embeds with whatever model is loaded regardless of this value
|
|
}
|
|
|
|
func (e *OpenAICompatible) Embed(ctx context.Context, text string) ([]float32, error) {
|
|
endpoint := e.baseURL + "/embeddings"
|
|
|
|
reqBody, err := json.Marshal(map[string]interface{}{
|
|
"input": text,
|
|
"model": e.model,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshaling request: %w", err)
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(reqBody))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating request: %w", err)
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
if e.apiKey != "" {
|
|
httpReq.Header.Set("Authorization", "Bearer "+e.apiKey)
|
|
}
|
|
|
|
resp, err := e.http.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var apiResp openAICompatibleEmbedResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
|
|
return nil, fmt.Errorf("decoding response: %w", err)
|
|
}
|
|
if len(apiResp.Data) == 0 || len(apiResp.Data[0].Embedding) == 0 {
|
|
return nil, fmt.Errorf("empty embeddings response")
|
|
}
|
|
|
|
vector := apiResp.Data[0].Embedding
|
|
e.dims.Store(int64(len(vector)))
|
|
return vector, nil
|
|
}
|
|
|
|
// Dimensions returns the vector size learned from the last successful Embed
|
|
// call, or 0 if none has succeeded yet (it depends on the model/provider
|
|
// behind BaseURL, so it can't be known upfront).
|
|
func (e *OpenAICompatible) Dimensions() int {
|
|
return int(e.dims.Load())
|
|
}
|
|
|
|
type openAICompatibleEmbedResponse struct {
|
|
Data []struct {
|
|
Embedding []float32 `json:"embedding"`
|
|
Index int `json:"index"`
|
|
} `json:"data"`
|
|
}
|