142 lines
4.9 KiB
Go
142 lines
4.9 KiB
Go
package knowledge_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"math"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/krow/krow-backend/go-api/internal/knowledge"
|
|
)
|
|
|
|
// The local embedder.
|
|
//
|
|
// Driven against a stub rather than a real Ollama, because what is being tested
|
|
// is this package's half of the contract: the request shape, the normalisation,
|
|
// and — most of all — what an operator is told when it does not work. The model
|
|
// itself is somebody else's code and testing it here would test the network.
|
|
|
|
func TestTheLocalEmbedderSendsWhatOllamaExpects(t *testing.T) {
|
|
var got map[string]any
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/embed" {
|
|
t.Errorf("posted to %s, want /api/embed", r.URL.Path)
|
|
}
|
|
json.NewDecoder(r.Body).Decode(&got)
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"embeddings": [][]float32{{3, 4}, {1, 0}},
|
|
})
|
|
}))
|
|
defer srv.Close()
|
|
|
|
e := knowledge.NewOllama(srv.URL, "nomic-embed-text", 2)
|
|
out, err := e.Embed(context.Background(), []string{"a", "b"}, knowledge.KindDocument)
|
|
if err != nil {
|
|
t.Fatalf("embed: %v", err)
|
|
}
|
|
|
|
if got["model"] != "nomic-embed-text" {
|
|
t.Errorf("model = %v", got["model"])
|
|
}
|
|
if inputs, ok := got["input"].([]any); !ok || len(inputs) != 2 {
|
|
t.Errorf("input = %v; the batch should travel as a list", got["input"])
|
|
}
|
|
if len(out) != 2 {
|
|
t.Fatalf("%d vectors, want 2", len(out))
|
|
}
|
|
}
|
|
|
|
func TestTheLocalEmbedderNormalisesWhatItGetsBack(t *testing.T) {
|
|
// The schema's similarity function is a plain dot product, which equals
|
|
// cosine similarity ONLY for unit vectors. Ollama returns whatever the
|
|
// model produced. Skipping this would not error — it would rank badly,
|
|
// dominated by whichever chunks happened to have the largest magnitude,
|
|
// which is the kind of wrong that never looks broken.
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
json.NewEncoder(w).Encode(map[string]any{"embeddings": [][]float32{{3, 4}}})
|
|
}))
|
|
defer srv.Close()
|
|
|
|
out, err := knowledge.NewOllama(srv.URL, "m", 2).
|
|
Embed(context.Background(), []string{"x"}, knowledge.KindDocument)
|
|
if err != nil {
|
|
t.Fatalf("embed: %v", err)
|
|
}
|
|
|
|
var sum float64
|
|
for _, v := range out[0] {
|
|
sum += float64(v) * float64(v)
|
|
}
|
|
if math.Abs(math.Sqrt(sum)-1) > 1e-5 {
|
|
t.Errorf("vector has length %.4f, want 1 — the dot product will not be cosine similarity",
|
|
math.Sqrt(sum))
|
|
}
|
|
}
|
|
|
|
func TestOllamaNotRunningSaysWhatToDo(t *testing.T) {
|
|
// The single most likely failure, and the one where a bad message costs the
|
|
// most time: an operator reading "connection refused" goes looking for a
|
|
// network problem.
|
|
e := knowledge.NewOllama("http://127.0.0.1:1", "nomic-embed-text", 768)
|
|
_, err := e.Embed(context.Background(), []string{"x"}, knowledge.KindQuery)
|
|
if err == nil {
|
|
t.Fatal("embedding against nothing succeeded")
|
|
}
|
|
msg := err.Error()
|
|
if !strings.Contains(msg, "ollama pull") {
|
|
t.Errorf("the failure does not say how to fix it: %s", msg)
|
|
}
|
|
|
|
var kErr *knowledge.Error
|
|
if !asErr(err, &kErr) || kErr.Code != knowledge.ErrEmbedUnavailable {
|
|
t.Errorf("want %s, got %v", knowledge.ErrEmbedUnavailable, err)
|
|
}
|
|
// Retryable: a model that is starting up will answer in a moment.
|
|
if !kErr.Retryable() {
|
|
t.Error("an unreachable local model should be retryable")
|
|
}
|
|
}
|
|
|
|
func TestAMissingModelIsADifferentProblemFromADeadServer(t *testing.T) {
|
|
// Ollama up but never told to pull the model. Same symptom to a user, a
|
|
// completely different fix — and one of them is not worth retrying.
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
_, err := knowledge.NewOllama(srv.URL, "nomic-embed-text", 768).
|
|
Embed(context.Background(), []string{"x"}, knowledge.KindQuery)
|
|
|
|
var kErr *knowledge.Error
|
|
if !asErr(err, &kErr) {
|
|
t.Fatalf("want a knowledge error, got %v", err)
|
|
}
|
|
if kErr.Code != knowledge.ErrNotConfigured {
|
|
t.Errorf("a missing model reported %s; it is a configuration problem, not an outage", kErr.Code)
|
|
}
|
|
if kErr.Retryable() {
|
|
t.Error("a model that was never pulled will not appear by retrying")
|
|
}
|
|
if !strings.Contains(kErr.Message, "ollama pull") {
|
|
t.Errorf("the failure does not name the fix: %s", kErr.Message)
|
|
}
|
|
}
|
|
|
|
func TestALocalCorpusIsNotConfusedWithAHostedOne(t *testing.T) {
|
|
// 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.
|
|
// A local `nomic-embed-text` and a hosted model of the same name must not
|
|
// share an identity.
|
|
local := knowledge.NewOllama("", "nomic-embed-text", 768)
|
|
if !strings.HasPrefix(local.Model(), "ollama/") {
|
|
t.Errorf("local model name is %q; it must be distinguishable from a hosted one", local.Model())
|
|
}
|
|
if local.Model() == knowledge.NewVoyage("k", "nomic-embed-text", 768).Model() {
|
|
t.Error("a local and a hosted model with the same name share an identity")
|
|
}
|
|
}
|