rony-llm-agent/pkg/rag/backends/sqlitevec/sqlitevec_test.go
Victor Vargas 0652023037 feat(rag): add SQLite+FTS5 backend, fix content/usage plumbing bugs
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>
2026-07-06 00:05:30 -07:00

240 lines
6.5 KiB
Go

package sqlitevec_test
import (
"context"
"testing"
"github.com/VictorVargas/rony-llm-agent/pkg/rag/backends/sqlitevec"
)
func newTestBackend(t *testing.T) *sqlitevec.Backend {
t.Helper()
b, err := sqlitevec.New("")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
t.Cleanup(func() { b.Close() })
return b
}
func TestBackend_UpsertAndSearchByVector(t *testing.T) {
b := newTestBackend(t)
ctx := context.Background()
if err := b.Upsert(ctx, "a", []float32{1, 0, 0}, "cómo desplegar a producción", map[string]string{"kind": "process"}); err != nil {
t.Fatalf("upsert a: %v", err)
}
if err := b.Upsert(ctx, "b", []float32{0, 1, 0}, "receta de pan", nil); err != nil {
t.Fatalf("upsert b: %v", err)
}
results, err := b.Search(ctx, "", []float32{1, 0, 0}, 5)
if err != nil {
t.Fatalf("search: %v", err)
}
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results))
}
if results[0].ID != "a" {
t.Fatalf("expected closest match to be 'a', got %q", results[0].ID)
}
if results[0].Content != "cómo desplegar a producción" {
t.Fatalf("expected content to round-trip, got %q", results[0].Content)
}
if results[0].Metadata["kind"] != "process" {
t.Fatalf("expected metadata to round-trip, got %v", results[0].Metadata)
}
}
func TestBackend_UpsertReplacesExisting(t *testing.T) {
b := newTestBackend(t)
ctx := context.Background()
if err := b.Upsert(ctx, "a", []float32{1, 0}, "first version", nil); err != nil {
t.Fatalf("upsert: %v", err)
}
if err := b.Upsert(ctx, "a", []float32{0, 1}, "second version", nil); err != nil {
t.Fatalf("upsert: %v", err)
}
results, err := b.Search(ctx, "", []float32{0, 1}, 5)
if err != nil {
t.Fatalf("search: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected the upsert to replace, not duplicate; got %d results", len(results))
}
if results[0].Content != "second version" {
t.Fatalf("expected replaced content, got %q", results[0].Content)
}
// The FTS5 side must also have been replaced, not duplicated.
ftsResults, err := b.Search(ctx, "second version", nil, 5)
if err != nil {
t.Fatalf("fts search: %v", err)
}
if len(ftsResults) != 1 {
t.Fatalf("expected 1 fts result after replace, got %d", len(ftsResults))
}
}
func TestBackend_SearchTopK(t *testing.T) {
b := newTestBackend(t)
ctx := context.Background()
for i, id := range []string{"a", "b", "c"} {
vec := []float32{float32(i), 1, 1}
if err := b.Upsert(ctx, id, vec, id, nil); err != nil {
t.Fatalf("upsert %s: %v", id, err)
}
}
results, err := b.Search(ctx, "", []float32{1, 1, 1}, 2)
if err != nil {
t.Fatalf("search: %v", err)
}
if len(results) != 2 {
t.Fatalf("expected topK=2 results, got %d", len(results))
}
}
func TestBackend_Forget(t *testing.T) {
b := newTestBackend(t)
ctx := context.Background()
if err := b.Upsert(ctx, "a", []float32{1, 0}, "content", nil); err != nil {
t.Fatalf("upsert: %v", err)
}
if err := b.Forget(ctx, "a"); err != nil {
t.Fatalf("forget: %v", err)
}
results, err := b.Search(ctx, "", []float32{1, 0}, 5)
if err != nil {
t.Fatalf("search: %v", err)
}
if len(results) != 0 {
t.Fatalf("expected no results after forget, got %d", len(results))
}
ftsResults, err := b.Search(ctx, "content", nil, 5)
if err != nil {
t.Fatalf("fts search: %v", err)
}
if len(ftsResults) != 0 {
t.Fatalf("expected no fts results after forget, got %d", len(ftsResults))
}
}
func TestBackend_ForgetAll(t *testing.T) {
b := newTestBackend(t)
ctx := context.Background()
for _, id := range []string{"a", "b"} {
if err := b.Upsert(ctx, id, []float32{1, 0}, id, nil); err != nil {
t.Fatalf("upsert %s: %v", id, err)
}
}
if err := b.ForgetAll(ctx); err != nil {
t.Fatalf("forget all: %v", err)
}
results, err := b.Search(ctx, "", []float32{1, 0}, 5)
if err != nil {
t.Fatalf("search: %v", err)
}
if len(results) != 0 {
t.Fatalf("expected no results after forget all, got %d", len(results))
}
}
func TestBackend_SearchEmpty(t *testing.T) {
b := newTestBackend(t)
results, err := b.Search(context.Background(), "", []float32{1, 0}, 5)
if err != nil {
t.Fatalf("search: %v", err)
}
if len(results) != 0 {
t.Fatalf("expected no results on empty store, got %d", len(results))
}
}
func TestBackend_SearchByText_NoVectorFallsBackToFTS(t *testing.T) {
b := newTestBackend(t)
ctx := context.Background()
// Upserted with an empty vector, as if the embedder had failed.
if err := b.Upsert(ctx, "a", nil, "cómo desplegar a producción con Docker", map[string]string{"kind": "process"}); err != nil {
t.Fatalf("upsert a: %v", err)
}
if err := b.Upsert(ctx, "b", nil, "receta de pan con masa madre", nil); err != nil {
t.Fatalf("upsert b: %v", err)
}
results, err := b.Search(ctx, "desplegar producción", nil, 5)
if err != nil {
t.Fatalf("search: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 fts match, got %d", len(results))
}
if results[0].ID != "a" {
t.Fatalf("expected match to be 'a', got %q", results[0].ID)
}
if results[0].Metadata["kind"] != "process" {
t.Fatalf("expected metadata to round-trip through fts path, got %v", results[0].Metadata)
}
}
func TestBackend_SearchByText_NoMatches(t *testing.T) {
b := newTestBackend(t)
ctx := context.Background()
if err := b.Upsert(ctx, "a", nil, "receta de pan", nil); err != nil {
t.Fatalf("upsert: %v", err)
}
results, err := b.Search(ctx, "algo completamente distinto", nil, 5)
if err != nil {
t.Fatalf("search: %v", err)
}
if len(results) != 0 {
t.Fatalf("expected no matches, got %d", len(results))
}
}
func TestBackend_SearchByText_HandlesSpecialCharacters(t *testing.T) {
b := newTestBackend(t)
ctx := context.Background()
if err := b.Upsert(ctx, "a", nil, "usa docker-compose para levantar todo", nil); err != nil {
t.Fatalf("upsert: %v", err)
}
// FTS5 operator characters in the query must not cause a syntax error.
results, err := b.Search(ctx, `docker-compose "up" AND/OR *test*`, nil, 5)
if err != nil {
t.Fatalf("search should not error on special characters: %v", err)
}
if len(results) == 0 {
t.Fatal("expected at least one match despite special characters in the query")
}
}
func TestBackend_SearchByText_EmptyQuery(t *testing.T) {
b := newTestBackend(t)
ctx := context.Background()
if err := b.Upsert(ctx, "a", nil, "algo", nil); err != nil {
t.Fatalf("upsert: %v", err)
}
results, err := b.Search(ctx, " ", nil, 5)
if err != nil {
t.Fatalf("search: %v", err)
}
if len(results) != 0 {
t.Fatalf("expected no results for an empty query, got %d", len(results))
}
}