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>
413 lines
14 KiB
Go
413 lines
14 KiB
Go
// Package portfolio loads and indexes the user's project markdowns for RAG.
|
|
//
|
|
// Schema decisions are documented in docs/architecture.md §4.0 (driver) and
|
|
// §4 (tokenizer, chunking). The tokenize/driver choices are validated by
|
|
// ./bench/; chunking was changed from size-based to heading-based after
|
|
// inspecting real data/templates in data/projects/.
|
|
package portfolio
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// SearchResult is one hit from the FTS5 index, with a relevance score.
|
|
type SearchResult struct {
|
|
ID string
|
|
ProjectID string
|
|
Kind string // KindProject | KindDoc
|
|
SourceFile string
|
|
Section string
|
|
Index int
|
|
Content string
|
|
Score float64
|
|
}
|
|
|
|
// Chunk kinds. A "project" is a piece of Victor's portfolio and shows up in
|
|
// the catalogue the bot injects into the prompt; a "doc" (his CV, an about
|
|
// page, a FAQ) is retrievable evidence that is not itself a project and must
|
|
// never be listed as one.
|
|
const (
|
|
KindProject = "project"
|
|
KindDoc = "doc"
|
|
)
|
|
|
|
// Source is one directory of markdown to index, and what the documents in it
|
|
// mean. See KindProject / KindDoc.
|
|
type Source struct {
|
|
Path string
|
|
Kind string
|
|
}
|
|
|
|
// SourcesFor is the standard mapping from the two configured directories to
|
|
// index sources. docsPath may be empty.
|
|
func SourcesFor(dataPath, docsPath string) []Source {
|
|
return []Source{
|
|
{Path: dataPath, Kind: KindProject},
|
|
{Path: docsPath, Kind: KindDoc},
|
|
}
|
|
}
|
|
|
|
const schema = `
|
|
CREATE VIRTUAL TABLE IF NOT EXISTS portfolio_chunks USING fts5(
|
|
id UNINDEXED,
|
|
project_id UNINDEXED,
|
|
kind UNINDEXED,
|
|
source_file UNINDEXED,
|
|
section UNINDEXED,
|
|
chunk_index UNINDEXED,
|
|
content,
|
|
tokenize = 'unicode61 remove_diacritics 2'
|
|
);
|
|
`
|
|
|
|
// Vectors live in an ordinary table keyed by chunk id. There is no ANN index:
|
|
// a portfolio is hundreds of chunks, not millions, so a full scan with a dot
|
|
// product is microseconds and needs no extension.
|
|
// content_hash is what makes a vector verifiable. A chunk's id is derived
|
|
// from file + heading + position, so editing the *body* of a section leaves
|
|
// the id untouched — and a vector keyed only by id would keep describing the
|
|
// text that used to be there. Nothing errors; semantic ranking just silently
|
|
// scores the chunk by a meaning it no longer has. Storing the hash of the
|
|
// embedded text lets the search ignore rows whose source has moved on.
|
|
const vectorSchema = `
|
|
CREATE TABLE IF NOT EXISTS portfolio_vectors (
|
|
chunk_id TEXT PRIMARY KEY,
|
|
content_hash TEXT NOT NULL,
|
|
dim INTEGER NOT NULL,
|
|
vec BLOB NOT NULL
|
|
);
|
|
`
|
|
|
|
// ensureChunkSchema creates portfolio_chunks, and rebuilds it when an older
|
|
// database is missing a column (FTS5 has no ALTER TABLE ADD COLUMN).
|
|
//
|
|
// Dropping is safe precisely because this table is a derived index: every row
|
|
// is regenerated from the markdown on the next Reindex. It deliberately
|
|
// touches only portfolio_chunks — conversations live in the same file and are
|
|
// real user data.
|
|
func ensureChunkSchema(ctx context.Context, db *sql.DB) error {
|
|
var existing string
|
|
err := db.QueryRowContext(ctx,
|
|
`SELECT sql FROM sqlite_master WHERE type='table' AND name='portfolio_chunks'`).Scan(&existing)
|
|
switch {
|
|
case err == sql.ErrNoRows:
|
|
// Fresh database; fall through to CREATE.
|
|
case err != nil:
|
|
return fmt.Errorf("inspect chunk schema: %w", err)
|
|
case !strings.Contains(existing, "kind"):
|
|
slog.Info("portfolio index predates the kind column, rebuilding it (run reindex to repopulate)")
|
|
if _, err := db.ExecContext(ctx, `DROP TABLE portfolio_chunks`); err != nil {
|
|
return fmt.Errorf("drop stale chunk table: %w", err)
|
|
}
|
|
}
|
|
if _, err := db.ExecContext(ctx, schema); err != nil {
|
|
return fmt.Errorf("create schema: %w", err)
|
|
}
|
|
// Same rebuild-on-mismatch rule as the chunk table: vectors are derived
|
|
// data, regenerated by the next reindex.
|
|
var vecSQL string
|
|
switch err := db.QueryRowContext(ctx,
|
|
`SELECT sql FROM sqlite_master WHERE type='table' AND name='portfolio_vectors'`).Scan(&vecSQL); {
|
|
case err == sql.ErrNoRows:
|
|
case err != nil:
|
|
return fmt.Errorf("inspect vector schema: %w", err)
|
|
case !strings.Contains(vecSQL, "content_hash"):
|
|
slog.Info("vector index predates content_hash, rebuilding it (run reindex to repopulate)")
|
|
if _, err := db.ExecContext(ctx, `DROP TABLE portfolio_vectors`); err != nil {
|
|
return fmt.Errorf("drop stale vector table: %w", err)
|
|
}
|
|
}
|
|
if _, err := db.ExecContext(ctx, vectorSchema); err != nil {
|
|
return fmt.Errorf("create vector schema: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Store wraps a SQLite FTS5 database with the portfolio schema.
|
|
type Store struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
func OpenStore(dbPath string) (*Store, error) {
|
|
dir := filepath.Dir(dbPath)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return nil, fmt.Errorf("create db dir: %w", err)
|
|
}
|
|
dsn := dbPath + "?_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(1)"
|
|
db, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open sqlite: %w", err)
|
|
}
|
|
db.SetMaxOpenConns(1) // SQLite + concurrent writers doesn't help
|
|
if err := ensureChunkSchema(context.Background(), db); err != nil {
|
|
_ = db.Close()
|
|
return nil, err
|
|
}
|
|
if _, err := db.ExecContext(context.Background(), conversationSchema); err != nil {
|
|
_ = db.Close()
|
|
return nil, fmt.Errorf("create conversation schema: %w", err)
|
|
}
|
|
return &Store{db: db}, nil
|
|
}
|
|
|
|
func (s *Store) Close() error { return s.db.Close() }
|
|
|
|
// DB exposes the underlying *sql.DB for callers that need to run their own
|
|
// queries (e.g. the health check). Don't use for hot-path code: go through
|
|
// the Search / Reindex methods.
|
|
func (s *Store) DB() *sql.DB { return s.db }
|
|
|
|
// isReadme reports whether a file is a directory's README rather than content.
|
|
// These directories are checked into the repo with instructions for whoever
|
|
// fills them, and those instructions are not one of Victor's projects: without
|
|
// this, `data/projects/README.md` was indexed as a project and the catalogue
|
|
// injected into every prompt announced "README" and "README.es" to visitors.
|
|
// Localised variants (README.es.md) are covered by matching the first segment.
|
|
func isReadme(path string) bool {
|
|
base := filepath.Base(path)
|
|
name, _, _ := strings.Cut(base, ".")
|
|
return strings.EqualFold(name, "README")
|
|
}
|
|
|
|
// Reindex rebuilds the whole index from the given sources. Each source
|
|
// contributes its `.md` and `.mdx` files; `.mdx` is included because content
|
|
// written for an Astro or Next site (a CV, an about page) is usually authored
|
|
// there, and its JSX is harmless to full-text search.
|
|
//
|
|
// A source with an empty Path is skipped, so callers can pass an optional
|
|
// docs directory without branching.
|
|
func (s *Store) Reindex(ctx context.Context, sources []Source, cfg ChunkerConfig) (files, chunks int, err error) {
|
|
type doc struct {
|
|
path string
|
|
kind string
|
|
}
|
|
var docs []doc
|
|
for _, src := range sources {
|
|
if strings.TrimSpace(src.Path) == "" {
|
|
continue
|
|
}
|
|
kind := src.Kind
|
|
if kind == "" {
|
|
kind = KindProject
|
|
}
|
|
for _, ext := range []string{"*.md", "*.mdx"} {
|
|
matches, err := filepath.Glob(filepath.Join(src.Path, ext))
|
|
if err != nil {
|
|
return 0, 0, fmt.Errorf("glob %s: %w", ext, err)
|
|
}
|
|
for _, m := range matches {
|
|
if isReadme(m) {
|
|
continue
|
|
}
|
|
docs = append(docs, doc{path: m, kind: kind})
|
|
}
|
|
}
|
|
}
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM portfolio_chunks`); err != nil {
|
|
return 0, 0, fmt.Errorf("clear: %w", err)
|
|
}
|
|
|
|
stmt, err := tx.PrepareContext(ctx,
|
|
`INSERT INTO portfolio_chunks (id, project_id, kind, source_file, section, chunk_index, content) VALUES (?,?,?,?,?,?,?)`)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
defer stmt.Close()
|
|
|
|
for _, d := range docs {
|
|
body, err := os.ReadFile(d.path)
|
|
if err != nil {
|
|
slog.Warn("read file failed", "file", d.path, "err", err)
|
|
continue
|
|
}
|
|
docID := strings.TrimSuffix(strings.TrimSuffix(filepath.Base(d.path), ".mdx"), ".md")
|
|
sections := SplitMarkdownSections(string(body), cfg)
|
|
for idx, sec := range sections {
|
|
id := fmt.Sprintf("%s-%s-%d", docID, slugify(sec.Heading), idx)
|
|
if _, err := stmt.ExecContext(ctx, id, docID, d.kind, d.path, sec.Heading, idx, sec.Body); err != nil {
|
|
return len(docs), chunks, fmt.Errorf("insert %s: %w", id, err)
|
|
}
|
|
chunks++
|
|
}
|
|
files++
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return 0, 0, err
|
|
}
|
|
return files, chunks, nil
|
|
}
|
|
|
|
func slugify(s string) string {
|
|
out := make([]byte, 0, len(s))
|
|
for i := 0; i < len(s); i++ {
|
|
c := s[i]
|
|
switch {
|
|
case c >= 'a' && c <= 'z', c >= '0' && c <= '9':
|
|
out = append(out, c)
|
|
case c >= 'A' && c <= 'Z':
|
|
out = append(out, c+32)
|
|
case c == ' ' || c == '-' || c == '_':
|
|
out = append(out, '_')
|
|
}
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
// CatalogEntry is one project in the portfolio, identified by its filename
|
|
// stem and its human-readable H1 title.
|
|
type CatalogEntry struct {
|
|
ProjectID string
|
|
Title string
|
|
}
|
|
|
|
// Catalog lists every indexed project with its title, cheaply and
|
|
// deterministically (no BM25, no query).
|
|
//
|
|
// It exists because retrieval alone can't answer "what projects does Victor
|
|
// have?": top-K search returns the K best-matching *chunks*, which for a
|
|
// broad question is a handful of sections from two or three documents, and a
|
|
// small model asked to enumerate from that will confidently fill the gaps
|
|
// with invented project names. Injecting the full catalogue into the system
|
|
// prompt turns enumeration into a copy job instead of a recall job. The
|
|
// portfolio is a few dozen documents at most, so the whole list costs a
|
|
// trivial number of tokens.
|
|
//
|
|
// Title falls back to the project ID when a document has no H1.
|
|
func (s *Store) Catalog(ctx context.Context) ([]CatalogEntry, error) {
|
|
// The chunker emits the frontmatter block first (when present) and the
|
|
// H1 title section next, so the lowest-indexed non-frontmatter section
|
|
// carries the document's title.
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT project_id, section, MIN(chunk_index)
|
|
FROM portfolio_chunks
|
|
WHERE section <> 'frontmatter' AND kind = ?
|
|
GROUP BY project_id
|
|
ORDER BY project_id`, KindProject)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("catalog query: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []CatalogEntry
|
|
for rows.Next() {
|
|
var e CatalogEntry
|
|
var idx int
|
|
if err := rows.Scan(&e.ProjectID, &e.Title, &idx); err != nil {
|
|
return nil, fmt.Errorf("catalog scan: %w", err)
|
|
}
|
|
if strings.TrimSpace(e.Title) == "" {
|
|
e.Title = e.ProjectID
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// Search returns up to topK chunks ordered by BM25 score.
|
|
func (s *Store) Search(ctx context.Context, query string, topK int) ([]SearchResult, error) {
|
|
if topK <= 0 {
|
|
topK = 5
|
|
}
|
|
rows, err := s.db.QueryContext(ctx, fmt.Sprintf(`
|
|
SELECT id, project_id, kind, source_file, section, chunk_index, content, bm25(portfolio_chunks) AS score
|
|
FROM portfolio_chunks
|
|
WHERE portfolio_chunks MATCH '%s' AND section <> 'frontmatter'
|
|
ORDER BY score
|
|
LIMIT %d
|
|
`, sanitizeFTS5(query), topK))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var hits []SearchResult
|
|
for rows.Next() {
|
|
var r SearchResult
|
|
if err := rows.Scan(&r.ID, &r.ProjectID, &r.Kind, &r.SourceFile, &r.Section, &r.Index, &r.Content, &r.Score); err != nil {
|
|
return nil, err
|
|
}
|
|
hits = append(hits, r)
|
|
}
|
|
return hits, rows.Err()
|
|
}
|
|
|
|
// sanitizeFTS5 escapes special chars, adds prefix-match wildcards, and joins
|
|
// tokens with OR (Q&A behavior: "what database" should match a doc that
|
|
// contains "database" even when it doesn't contain "what"). FTS5 doesn't
|
|
// accept `?` placeholders for MATCH in driver-prepared statements; this
|
|
// inlines the escaped query.
|
|
func sanitizeFTS5(q string) string {
|
|
tokens := strings.FieldsFunc(strings.ToLower(q), func(r rune) bool {
|
|
return !(r == '-' || r == '_' || r == '.' || r == '+' ||
|
|
(r >= '0' && r <= '9') ||
|
|
(r >= 'a' && r <= 'z') ||
|
|
r > 0x7F)
|
|
})
|
|
if len(tokens) == 0 {
|
|
return `""`
|
|
}
|
|
// Drop a tiny stopword list so "what is" doesn't dominate the OR
|
|
// (these match every doc and dilute BM25 ranking).
|
|
keep := tokens[:0]
|
|
for _, t := range tokens {
|
|
switch t {
|
|
case "what", "which", "who", "how", "when", "where", "is", "are",
|
|
"do", "does", "can", "tell", "about", "the", "a", "an":
|
|
continue
|
|
}
|
|
keep = append(keep, t)
|
|
}
|
|
if len(keep) == 0 {
|
|
// All tokens were stopwords — fall back to the original set.
|
|
keep = tokens
|
|
}
|
|
for i, t := range keep {
|
|
keep[i] = `"` + t + `"*`
|
|
}
|
|
return strings.Join(keep, " OR ")
|
|
}
|
|
|
|
// ReindexOnDisk is a small convenience that opens the store, reindexes, and
|
|
// closes — used by the CLI subcommand.
|
|
// embedder may be nil, in which case no vectors are written and retrieval
|
|
// stays keyword-only. An embedding failure is logged and swallowed: the
|
|
// keyword index is already committed by then, and a bot that answers from
|
|
// keywords alone beats a reindex that reports failure and leaves nothing.
|
|
func ReindexOnDisk(dbPath string, sources []Source, cfg ChunkerConfig, embedder Embedder) (time.Duration, int, int, error) {
|
|
start := time.Now()
|
|
store, err := OpenStore(dbPath)
|
|
if err != nil {
|
|
return 0, 0, 0, err
|
|
}
|
|
defer store.Close()
|
|
files, chunks, err := store.Reindex(context.Background(), sources, cfg)
|
|
if err != nil {
|
|
return time.Since(start), files, chunks, err
|
|
}
|
|
if embedder != nil {
|
|
n, err := store.EmbedChunks(context.Background(), embedder)
|
|
if err != nil {
|
|
slog.Error("embedding failed; keyword search still works", "err", err)
|
|
} else {
|
|
slog.Info("embedded chunks", "chunks", n)
|
|
}
|
|
}
|
|
return time.Since(start), files, chunks, nil
|
|
}
|