Files
krow_backend/go-api/internal/knowledge/embed.go
2026-08-28 12:21:44 +05:30

454 lines
16 KiB
Go

package knowledge
import (
"bytes"
"context"
"encoding/json"
"fmt"
"hash/fnv"
"math"
"net/http"
"strings"
"time"
"unicode"
)
// The dense half of hybrid retrieval.
//
// §5 requires dense + BM25 fused with RRF, and says not to drop to dense-only
// for convenience. The reverse is the same sin, so the architecture below is
// hybrid from the first line even though the deployment this was built on has
// no embedding credential.
//
// That constraint is met with an interface and two implementations, and the
// difference between them is stated bluntly rather than smoothed over:
//
// - Voyage is the real one. Semantic: "time off" retrieves a paragraph about
// annual leave that never uses either word.
// - Lexical is a deterministic stand-in that hashes terms into a vector. It
// is NOT semantic. It captures term overlap and nothing else, so hybrid
// retrieval running on it is two flavours of keyword search wearing a
// trenchcoat. It exists so the ACL pre-filter, the fusion and the whole
// retrieval path are testable without a network or a key, and it refuses to
// run in production.
//
// Anthropic does not serve embeddings; Voyage is the documented partner. The
// interface is what matters — swapping in another provider is one file.
// Kind distinguishes a document from a query.
//
// Modern embedding models are asymmetric: they encode "what is our lateness
// policy?" and "Staff arriving more than ten minutes after..." differently on
// purpose, and a retriever that embeds both the same way loses accuracy for no
// reason. The interface carries it so a provider that cares can use it and one
// that does not can ignore it.
type Kind string
const (
KindDocument Kind = "document"
KindQuery Kind = "query"
)
// Embedder turns text into vectors.
//
// Implementations MUST return unit-normalised vectors. The schema's similarity
// function is a plain dot product, which equals cosine similarity only for unit
// vectors — an implementation that skipped normalisation would produce a
// ranking dominated by whichever chunks happened to have the largest magnitude,
// and it would not error, it would just quietly rank badly.
type Embedder interface {
Embed(ctx context.Context, texts []string, kind Kind) ([][]float32, error)
// Model names the vectors this embedder produces. Stored on every chunk,
// because vectors from two models are not comparable and a half-migrated
// corpus returns nonsense rather than failing.
Model() string
// Dimensions is the vector length. Fixed per model.
Dimensions() int
}
/* ── Voyage ─────────────────────────────────────────────────────────────── */
// VoyageEmbedder calls Voyage AI.
type VoyageEmbedder struct {
APIKey string
ModelI string
Dims int
HTTP *http.Client
BaseURL string
}
// DefaultVoyageModel is the general-purpose retrieval model.
const (
DefaultVoyageModel = "voyage-3.5"
DefaultVoyageDims = 1024
defaultVoyageURL = "https://api.voyageai.com/v1/embeddings"
)
// NewVoyage builds an embedder over the Voyage API.
func NewVoyage(apiKey, model string, dims int) *VoyageEmbedder {
if model == "" {
model = DefaultVoyageModel
}
if dims <= 0 {
dims = DefaultVoyageDims
}
return &VoyageEmbedder{
APIKey: apiKey, ModelI: model, Dims: dims,
HTTP: &http.Client{Timeout: 30 * time.Second},
BaseURL: defaultVoyageURL,
}
}
func (v *VoyageEmbedder) Model() string { return v.ModelI }
func (v *VoyageEmbedder) Dimensions() int { return v.Dims }
func (v *VoyageEmbedder) Embed(ctx context.Context, texts []string, kind Kind) ([][]float32, error) {
if v.APIKey == "" {
// Structured rather than a bare string, and raised here rather than at
// startup: this service boots and serves everything that is not
// retrieval without an embedding key, and a refusal to start would
// make the knowledge layer's absence take the whole API with it.
return nil, &Error{Code: ErrNotConfigured, Message: "no embedding credential is configured"}
}
if len(texts) == 0 {
return nil, nil
}
inputType := "document"
if kind == KindQuery {
inputType = "query"
}
body, err := json.Marshal(map[string]any{
"input": texts,
"model": v.ModelI,
"input_type": inputType,
// Unit-normalised at the source where the provider offers it, so the
// dot product in SQL is cosine similarity without a second pass.
"output_dimension": v.Dims,
})
if err != nil {
return nil, &Error{Code: ErrEmbedFailed, Message: "the embedding request could not be encoded", Cause: err}
}
url := v.BaseURL
if url == "" {
url = defaultVoyageURL
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, &Error{Code: ErrEmbedFailed, Message: "the embedding request could not be built", Cause: err}
}
req.Header.Set("Authorization", "Bearer "+v.APIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := v.HTTP.Do(req)
if err != nil {
return nil, &Error{Code: ErrEmbedUnavailable, Message: "the embedding service could not be reached", Cause: err}
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
code := ErrEmbedFailed
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
code = ErrEmbedUnavailable
}
// The response body is deliberately not included. It is provider text,
// it can echo the input, and the input is tenant content.
return nil, &Error{Code: code, Message: fmt.Sprintf("the embedding service answered %d", resp.StatusCode)}
}
var decoded struct {
Data []struct {
Index int `json:"index"`
Embedding []float32 `json:"embedding"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
return nil, &Error{Code: ErrEmbedFailed, Message: "the embedding response could not be read", Cause: err}
}
if len(decoded.Data) != len(texts) {
return nil, &Error{Code: ErrEmbedFailed, Message: fmt.Sprintf(
"asked for %d embeddings and got %d", len(texts), len(decoded.Data))}
}
// Placed by the index the provider reports rather than by arrival order. A
// mis-ordered batch would attach every chunk's vector to its neighbour,
// which produces a corpus that retrieves confidently and wrongly.
out := make([][]float32, len(texts))
for _, d := range decoded.Data {
if d.Index < 0 || d.Index >= len(out) {
return nil, &Error{Code: ErrEmbedFailed, Message: "the embedding response was mis-indexed"}
}
out[d.Index] = normalise(d.Embedding)
}
for i, vec := range out {
if len(vec) == 0 {
return nil, &Error{Code: ErrEmbedFailed, Message: fmt.Sprintf("no embedding came back for input %d", i)}
}
}
return out, nil
}
/* ── Lexical stand-in ───────────────────────────────────────────────────── */
// LexicalEmbedder hashes terms into a fixed-width vector.
//
// READ THIS BEFORE USING IT. It is not a semantic embedder and does not
// approximate one. It hashes each term to a dimension and counts it, so two
// texts are "similar" here exactly when they share vocabulary — "annual leave"
// and "time off" are orthogonal. Running hybrid retrieval on it gives you BM25
// twice, and any evaluation of retrieval QUALITY against it is measuring
// nothing.
//
// It exists for one reason: the ACL pre-filter, the fusion, the citation path
// and the prompt assembly all need to be exercised and asserted, and none of
// them should require a network call or a credential to test. Those properties
// are independent of whether the vectors mean anything.
//
// It refuses outside development, so it cannot become the thing that shipped.
type LexicalEmbedder struct {
Dims int
// AllowInProduction is the deliberate override, and there is no reason to
// set it. It exists so the refusal below is a decision someone had to make
// in code rather than a flag they could set in an environment.
AllowInProduction bool
Production bool
}
// NewLexical builds the stand-in embedder.
func NewLexical(dims int) *LexicalEmbedder {
if dims <= 0 {
dims = 256
}
return &LexicalEmbedder{Dims: dims}
}
func (l *LexicalEmbedder) Model() string { return fmt.Sprintf("lexical-hash-%d", l.Dims) }
func (l *LexicalEmbedder) Dimensions() int { return l.Dims }
func (l *LexicalEmbedder) Embed(_ context.Context, texts []string, _ Kind) ([][]float32, error) {
if l.Production && !l.AllowInProduction {
return nil, &Error{
Code: ErrNotConfigured,
Message: "the lexical stand-in embedder cannot run in production; it is not semantic, " +
"and a corpus indexed with it would retrieve on word overlap alone",
}
}
out := make([][]float32, len(texts))
for i, text := range texts {
vec := make([]float32, l.Dims)
for _, term := range terms(text) {
h := fnv.New32a()
h.Write([]byte(term))
d := int(h.Sum32()) % l.Dims
if d < 0 {
d += l.Dims
}
// A second hash decides the sign, so unrelated terms colliding on a
// dimension tend to cancel rather than reinforce. Cheap, and it
// keeps a small vector from saturating.
s := fnv.New32()
s.Write([]byte(term))
if s.Sum32()%2 == 0 {
vec[d] += 1
} else {
vec[d] -= 1
}
}
out[i] = normalise(vec)
}
return out, nil
}
// terms splits text into lower-cased word tokens.
func terms(text string) []string {
fields := strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
out := make([]string, 0, len(fields))
for _, f := range fields {
if len(f) > 1 {
out = append(out, f)
}
}
return out
}
/* ── Shared ─────────────────────────────────────────────────────────────── */
// normalise scales a vector to unit length.
//
// The schema's similarity function is a dot product, which is cosine similarity
// only for unit vectors. A zero vector — a chunk of pure punctuation, or a
// provider returning zeros — is returned unchanged rather than divided by zero;
// it scores 0 against everything, which is the right answer for text with no
// content.
func normalise(v []float32) []float32 {
var sum float64
for _, x := range v {
sum += float64(x) * float64(x)
}
if sum == 0 {
return v
}
inv := float32(1 / math.Sqrt(sum))
out := make([]float32, len(v))
for i, x := range v {
out[i] = x * inv
}
return out
}
/* ── Ollama ─────────────────────────────────────────────────────────────── */
// OllamaEmbedder calls a model running on this machine.
//
// The third option, and for a workforce corpus often the right one. It is a
// real semantic embedder — "time off" finds "annual leave" — with three
// properties the hosted one does not have:
//
// - **No credential.** Nothing to provision, rotate, or leak.
// - **No per-token cost.** Re-embedding the whole corpus after a chunking
// change is free, which is the difference between tuning retrieval and
// being afraid to.
// - **No tenant text leaving the machine.** For handbooks and worker notes
// that is a substantive argument, not a preference.
//
// The cost is quality: `nomic-embed-text` is genuinely good and still behind
// the best hosted models on subtle retrieval over a large messy corpus. For a
// policy library it is not the limiting factor.
type OllamaEmbedder struct {
BaseURL string
ModelI string
Dims int
HTTP *http.Client
}
const (
// DefaultOllamaModel is a retrieval-tuned embedding model that runs
// comfortably on a laptop.
DefaultOllamaModel = "nomic-embed-text"
// DefaultOllamaDims is that model's output width.
DefaultOllamaDims = 768
defaultOllamaURL = "http://localhost:11434"
)
// NewOllama builds an embedder over a local Ollama.
func NewOllama(baseURL, model string, dims int) *OllamaEmbedder {
if baseURL == "" {
baseURL = defaultOllamaURL
}
if model == "" {
model = DefaultOllamaModel
}
if dims <= 0 {
dims = DefaultOllamaDims
}
return &OllamaEmbedder{
BaseURL: strings.TrimRight(baseURL, "/"),
ModelI: model,
Dims: dims,
// Longer than the hosted client's: a local model that has just been
// pulled loads into memory on the first request, and that first call
// can take tens of seconds on a cold start. Timing it out would make
// the very first ingest look broken.
HTTP: &http.Client{Timeout: 120 * time.Second},
}
}
// Model names the vectors this embedder produces.
//
// Prefixed, so a corpus embedded by a local `nomic-embed-text` is never
// mistaken for one embedded by a hosted model of the same name. Vectors from
// two models are not comparable, and the model name on the chunk row is the
// only thing standing between that and confident nonsense.
func (o *OllamaEmbedder) Model() string { return "ollama/" + o.ModelI }
func (o *OllamaEmbedder) Dimensions() int { return o.Dims }
func (o *OllamaEmbedder) Embed(ctx context.Context, texts []string, _ Kind) ([][]float32, error) {
if len(texts) == 0 {
return nil, nil
}
// Ollama's embedding endpoint takes no input_type, so the document/query
// asymmetry the hosted models use is simply not available here. Ignored
// rather than faked: prefixing the text with "query:" is a convention some
// models are trained on and this one is not, and applying it anyway would
// degrade retrieval while looking like a refinement.
body, err := json.Marshal(map[string]any{
"model": o.ModelI,
"input": texts,
})
if err != nil {
return nil, &Error{Code: ErrEmbedFailed, Message: "the embedding request could not be encoded", Cause: err}
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, o.BaseURL+"/api/embed", bytes.NewReader(body))
if err != nil {
return nil, &Error{Code: ErrEmbedFailed, Message: "the embedding request could not be built", Cause: err}
}
req.Header.Set("Content-Type", "application/json")
resp, err := o.HTTP.Do(req)
if err != nil {
// The common case by a distance: Ollama is not running. Said plainly,
// with the command to fix it, because the alternative is an operator
// reading "connection refused" and going looking for a network problem.
return nil, &Error{
Code: ErrEmbedUnavailable,
Message: fmt.Sprintf(
"no embedding model is answering at %s — start Ollama and run "+
"`ollama pull %s`", o.BaseURL, o.ModelI),
Cause: err,
}
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
if resp.StatusCode == http.StatusNotFound {
// Ollama is up but has never seen this model. A different problem
// from being down, and a different fix.
return nil, &Error{
Code: ErrNotConfigured,
Message: fmt.Sprintf("Ollama does not have %q — run `ollama pull %s`",
o.ModelI, o.ModelI),
}
}
code := ErrEmbedFailed
if resp.StatusCode >= 500 {
code = ErrEmbedUnavailable
}
return nil, &Error{Code: code, Message: fmt.Sprintf(
"the embedding model answered %d", resp.StatusCode)}
}
var decoded struct {
Embeddings [][]float32 `json:"embeddings"`
}
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
return nil, &Error{Code: ErrEmbedFailed, Message: "the embedding response could not be read", Cause: err}
}
if len(decoded.Embeddings) != len(texts) {
return nil, &Error{Code: ErrEmbedFailed, Message: fmt.Sprintf(
"asked for %d embeddings and got %d", len(texts), len(decoded.Embeddings))}
}
// Normalised here rather than trusted. Ollama returns whatever the model
// produced, and the schema's similarity function is a plain dot product —
// which equals cosine similarity only for unit vectors. Skipping this would
// not error; it would just rank badly, dominated by whichever chunks
// happened to have the largest magnitude.
out := make([][]float32, len(decoded.Embeddings))
for i, v := range decoded.Embeddings {
if len(v) == 0 {
return nil, &Error{Code: ErrEmbedFailed, Message: fmt.Sprintf(
"no embedding came back for input %d", i)}
}
out[i] = normalise(v)
}
return out, nil
}