rony-chat-bot/internal/portfolio/hybrid_test.go

244 lines
7.2 KiB
Go
Raw Permalink Normal View History

feat(rag): hybrid retrieval, reference documents, and vendor sampling Answers were short, sometimes in the wrong language, and occasionally about projects that do not exist. Measured on a 20-question battery against the real corpus in both Spanish and English, this takes grounded content from 3/10 to 9/10 and language matching from 7/10 to 10/10. Retrieval - Fuse FTS5 keyword search with dense vectors via Reciprocal Rank Fusion. Both halves are load-bearing: the corpus is English and visitors ask in Spanish, so the meaningful words score zero. "paga" appears 0 times in a document that says "Payments: Stripe" — the question "¿Con qué se paga en la tienda de ropa?" retrieved nothing at all. Embeddings put all three of that project's chunks on top. RRF ranks by agreement rather than comparing a BM25 score against a cosine, quantities with no shared scale. - internal/embed: OpenAI-compatible embeddings client, unit-normalised so a dot product is the cosine. Reorders by the response `index` field. - Store a content hash beside each vector and skip rows where it no longer matches the chunk. Chunk ids survive body edits, so without this an edited document keeps serving embeddings that describe text that is gone — reproduced live by changing a payment provider and watching the old one keep coming back. - Degrade to keyword-only when the embedder is down instead of failing. Reference documents that are not projects - Index `.mdx` alongside `.md`, and split sources into projects (announced in the catalogue) and reference material (retrievable, never listed). A CV is what someone deciding whether to hire actually reads, and it was unreachable while it lived only in the Astro site — but filing it under projects made the bot list "cv" as one of Victor's works. - Skip each directory's README. `data/projects/README.md` was being indexed, so the catalogue injected into every prompt announced "README" and "README.es" as projects of Victor's. - Exclude frontmatter from retrieval. It is dense metadata in a very short chunk, which makes it a magnet for short queries: a CV's `location:` field answered "¿Dónde ha trabajado Victor?" with a city instead of a work history. - Split oversized sections at `###` before falling back to byte offsets. A CV's Experience section is a list of jobs, and size-splitting cut one mid-word, stranding the employer's name in the previous chunk. Prompt and sampling - Inject the full project catalogue every turn. Top-K search returns the best matching sections, so "list every project" cannot be answered from retrieval alone, and a small model asked to enumerate from partial hits invents the rest. ~10 tokens per project; this is what stopped the invented names. - Wire the sampling parameters the model authors publish (top_k, top_p, min_p, repeat_penalty, presence_penalty) through config to llama.cpp. Leaving them at llama.cpp's defaults produced 16-token stub answers. - Localised system prompt selected by detected language. The English prompt plus "reply in the user's language" answered 1/5 Spanish questions in Spanish; few-shot examples fixed the language but got copied verbatim into real answers. - Fold compaction's system notes into the leading system message. Gemma's chat template rejects a system message that is not first, and the whole request failed with HTTP 400 the moment compaction fired. Configuration and docs - context_size 4096, down from 8192. The largest prompt this bot ever built over 20 real requests was 1255 tokens, compaction starts at ~3070, and the cut saved 212 MB resident with zero truncations and identical throughput. - Correct the RAM figures throughout. They were measured with a GPU absorbing llama.cpp's buffers; on a GPU-less VPS those come out of system RAM, which is 1.1 GB more for qwen2.5-3b and 2.8 GB more for granite. Both READMEs still started gemma-3-1b while the config defaulted to qwen, and neither started the embedder at all. Measured on the 2-core, 8 GB CPU-only target: 3.64 GB LLM + 0.91 GB embedder + 0.02 GB bot, 21.0 tok/s steady state. Known and unfixed, so they are not re-filed as new bugs: the model reads dates out of the CV correctly but does the arithmetic on them wrong, and "¿Dónde ha trabajado Victor?" still answers with projects rather than employers, though "¿En qué empresas ha trabajado?" works. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:45:37 +00:00
package portfolio
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"github.com/VictorVargas/rony-chat-bot/internal/embed"
)
// stubEmbedder returns a fixed vector per exact text, so a test can decide
// which chunk a query should land on without running a model.
type stubEmbedder struct {
vecs map[string][]float32
err error
}
func (s *stubEmbedder) Embed(_ context.Context, texts []string) ([][]float32, error) {
if s.err != nil {
return nil, s.err
}
out := make([][]float32, len(texts))
for i, t := range texts {
if v, ok := s.vecs[t]; ok {
out[i] = embed.Normalize(v)
continue
}
out[i] = embed.Normalize([]float32{1, 1, 1}) // neutral
}
return out, nil
}
func newTestStore(t *testing.T) *Store {
t.Helper()
dir := t.TempDir()
src := filepath.Join(dir, "projects")
if err := os.MkdirAll(src, 0o755); err != nil {
t.Fatal(err)
}
docs := map[string]string{
"tienda.md": "# Tienda\n\n## Tech stack\n\nNext.js and Stripe for payments.\n",
"dash.md": "# Dashboard\n\n## Tech stack\n\nGo backend with WebSockets and D3.\n",
}
for name, body := range docs {
if err := os.WriteFile(filepath.Join(src, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
store, err := OpenStore(filepath.Join(dir, "t.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { store.Close() })
if _, _, err := store.Reindex(context.Background(), SourcesFor(src, ""), DefaultChunkerConfig()); err != nil {
t.Fatal(err)
}
return store
}
// The point of the whole feature: a query whose words appear nowhere in the
// corpus still finds the right chunk, because the vector matches.
func TestHybridSearchFindsChunkKeywordSearchCannot(t *testing.T) {
store := newTestStore(t)
// "¿Cómo se paga?" shares no word with "Next.js and Stripe for payments".
query := "¿Cómo se paga?"
target := []float32{1, 0, 0}
emb := &stubEmbedder{vecs: map[string][]float32{query: target}}
// Embed the corpus so the Stripe chunk owns the target direction.
corpus := &stubEmbedder{vecs: map[string][]float32{}}
rows, err := store.db.Query(`SELECT id, project_id, section, content FROM portfolio_chunks`)
if err != nil {
t.Fatal(err)
}
for rows.Next() {
var id, project, section, content string
if err := rows.Scan(&id, &project, &section, &content); err != nil {
t.Fatal(err)
}
v := []float32{0, 1, 0}
if project == "tienda" && section == "Tech stack" {
v = target
}
corpus.vecs[project+" — "+section+"\n"+content] = v
}
rows.Close()
if _, err := store.EmbedChunks(context.Background(), corpus); err != nil {
t.Fatal(err)
}
// Keyword search alone finds nothing useful for this query.
kw, err := store.Search(context.Background(), query, 5)
if err != nil {
t.Fatal(err)
}
for _, h := range kw {
if h.ProjectID == "tienda" && h.Section == "Tech stack" {
t.Skip("keyword search already found it; this corpus can't demonstrate the gap")
}
}
hits, err := store.HybridSearch(context.Background(), emb, query, 3)
if err != nil {
t.Fatal(err)
}
if len(hits) == 0 {
t.Fatal("hybrid search returned nothing")
}
if hits[0].ProjectID != "tienda" || hits[0].Section != "Tech stack" {
t.Errorf("top hit = %s/%s, want tienda/Tech stack", hits[0].ProjectID, hits[0].Section)
}
}
// A dead embeddings endpoint must degrade to keyword search, not fail the
// visitor's question.
func TestHybridSearchFallsBackWhenEmbedderFails(t *testing.T) {
store := newTestStore(t)
broken := &stubEmbedder{err: errors.New("connection refused")}
hits, err := store.HybridSearch(context.Background(), broken, "Stripe", 3)
if err != nil {
t.Fatalf("hybrid search should fall back, got error: %v", err)
}
if len(hits) == 0 {
t.Fatal("fallback returned no keyword hits for a term that is in the corpus")
}
}
// With no embedder configured at all, hybrid search is plain keyword search.
func TestHybridSearchWithoutEmbedderIsKeywordSearch(t *testing.T) {
store := newTestStore(t)
hits, err := store.HybridSearch(context.Background(), nil, "Stripe", 3)
if err != nil {
t.Fatal(err)
}
if len(hits) == 0 {
t.Fatal("expected keyword hits for Stripe")
}
}
// Vectors from a different embedding model (wrong dimension) must be ignored
// rather than compared against and ranked.
func TestVectorSearchIgnoresMismatchedDimensions(t *testing.T) {
store := newTestStore(t)
corpus := &stubEmbedder{} // neutral 3-dim vectors
if _, err := store.EmbedChunks(context.Background(), corpus); err != nil {
t.Fatal(err)
}
hits, err := store.VectorSearch(context.Background(), []float32{1, 0, 0, 0}, 5)
if err != nil {
t.Fatal(err)
}
if len(hits) != 0 {
t.Errorf("got %d hits for a 4-dim query against 3-dim rows, want 0", len(hits))
}
}
// A chunk's id is derived from file + heading + position, so editing the body
// of a section keeps the id. A vector keyed only by id would then rank that
// chunk by text that no longer exists — silently, with no error anywhere.
// The stored content hash makes such a row invisible instead of wrong.
func TestVectorSearchIgnoresVectorsWhoseChunkChanged(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "projects")
if err := os.MkdirAll(src, 0o755); err != nil {
t.Fatal(err)
}
file := filepath.Join(src, "demo.md")
write := func(body string) {
if err := os.WriteFile(file, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
write("# Demo\n\n## Tech stack\n\nThe payment provider is Stripe.\n")
store, err := OpenStore(filepath.Join(dir, "t.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
sources := SourcesFor(src, "")
if _, _, err := store.Reindex(context.Background(), sources, DefaultChunkerConfig()); err != nil {
t.Fatal(err)
}
if _, err := store.EmbedChunks(context.Background(), &stubEmbedder{}); err != nil {
t.Fatal(err)
}
hits, err := store.VectorSearch(context.Background(), embed.Normalize([]float32{1, 1, 1}), 5)
if err != nil {
t.Fatal(err)
}
if len(hits) == 0 {
t.Fatal("expected the freshly embedded chunk to be searchable")
}
// Rewrite the body, keeping every heading — the chunk id is unchanged.
write("# Demo\n\n## Tech stack\n\nThe payment provider is PayPal. Stripe was removed.\n")
if _, _, err := store.Reindex(context.Background(), sources, DefaultChunkerConfig()); err != nil {
t.Fatal(err)
}
// Deliberately do NOT re-embed: this is the "endpoint was down" case.
var ids []string
rows, err := store.db.Query(`SELECT chunk_id FROM portfolio_vectors`)
if err != nil {
t.Fatal(err)
}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
t.Fatal(err)
}
ids = append(ids, id)
}
rows.Close()
if len(ids) == 0 {
t.Fatal("precondition: the stale vector row should still be present")
}
hits, err = store.VectorSearch(context.Background(), embed.Normalize([]float32{1, 1, 1}), 5)
if err != nil {
t.Fatal(err)
}
if len(hits) != 0 {
t.Errorf("stale vector was used for ranking: %+v", hits)
}
// Re-embedding restores it.
if _, err := store.EmbedChunks(context.Background(), &stubEmbedder{}); err != nil {
t.Fatal(err)
}
hits, err = store.VectorSearch(context.Background(), embed.Normalize([]float32{1, 1, 1}), 5)
if err != nil {
t.Fatal(err)
}
if len(hits) == 0 {
t.Error("re-embedding should make the chunk searchable again")
}
}