454 lines
16 KiB
Go
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
|
|
}
|