From 9ee722947a3cd14655298f7280f329de1d333fbd Mon Sep 17 00:00:00 2001 From: Victor Hugo Vargas Date: Thu, 30 Jul 2026 15:27:30 -0700 Subject: [PATCH] docs(architecture): bring the design doc up to what actually ships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RAG section still described the pipeline as originally specced, not the one that runs: fixed-size chunking, keyword-only retrieval, a schema without kind or content_hash, and an `Indexer` type that does not exist. Someone reading it to understand the retrieval path would have been wrong about every part of it. Section 4, rewritten: - 4.1 documents both source kinds, the README skip, the frontmatter exclusion and the ### sub-split, each with the failure that motivated it. - 4.2 replaces the BM25-only pipeline with hybrid retrieval, and explains why RRF rather than a weighted blend, what happens when the embedder is down, and why vectors carry a content hash. - 4.3 swaps the fictional code sketch for the real schema plus an API table. - 4.4 documents prompt assembly in the order the code does it, why there is exactly one system message, and the three attempts it took to get the language right. - The banner at the top listed four decisions as pending. All four are now made and measured, including the one it got wrong: BM25 was strong on the corpus but the questions arrive in Spanish, which is a translation problem a trigram tokenizer does not solve. - 4.5 claimed a ~3k-token system prompt leaving room for 2–3 turns. Measured, the largest prompt is 1255 tokens and compaction never fired in the whole benchmark. Elsewhere: - §2 adds the embeddings server and internal/embed; the stack table said qwen2.5:1.5b while the config ships 3b. - §6.1 and §8.1 did not deploy the architecture being described: no embedder, no --device none, no --parallel 1, no sampling flags, and a systemd unit that started only the bot. §8.1 now has all three units and the memory budget. - §9.4 lists the retrieval tests, since a regression there is silent. - §11 marks the phases that are done, records where the widget deliberately diverged from the plan (vanilla JS, not React + Tailwind), and keeps the three known unfixed answer-quality issues. - §12.1 replaces aspirational targets with measured numbers, and says plainly that TTFT <500ms and end-to-end <3s are not met on 2 CPU cores and why that is the accepted trade. Both language editions updated in step. Co-Authored-By: Claude Opus 5 --- docs/architecture.es.md | 694 ++++++++++++++++++++++++---------------- docs/architecture.md | 676 ++++++++++++++++++++++---------------- 2 files changed, 814 insertions(+), 556 deletions(-) diff --git a/docs/architecture.es.md b/docs/architecture.es.md index d4beda0..e65904c 100644 --- a/docs/architecture.es.md +++ b/docs/architecture.es.md @@ -63,21 +63,27 @@ El bot responde con información precisa extraída de los archivos markdown de p │ ↓ │ │ Agent loop (rony-llm-agent) │ │ ↓ │ -│ RAG retrieval → SQLite FTS5 sobre data/projects/*.md │ +│ RAG híbrido → SQLite FTS5 (BM25) ⊕ vectores, fusionados con RRF │ +│ ↓ sobre data/projects/ + data/docs/ │ +│ ├─→ servidor de embeddings (:9200, nomic-embed-v2-moe) │ │ ↓ │ │ LLM (llama.cpp local default / Ollama o Anthropic opcionales) │ └─────────────────────────────────────────────────────────────────┘ ``` +Dos servidores de modelos locales, no uno. Ambos son procesos `llama-server` +comunes con los que el bot habla por HTTP; ninguno se enlaza dentro del binario. + ### 2.2 Componentes principales | Componente | Path | Responsabilidad | |---|---|---| -| **HTTP server** | `internal/server/` | Gin/chi handlers, SSE streaming | -| **Agent runner** | `internal/agent/` | Wrapper sobre `rony-llm-agent` con config específica | -| **Portfolio loader** | `internal/portfolio/` | Lee `data/projects/*.md`, indexa en SQLite FTS5 | +| **HTTP server** | `internal/server/` | chi handlers, SSE streaming | +| **Agent runner** | `internal/agent/` | Wrapper sobre `rony-llm-agent`: armado del prompt, recuperación, selección de idioma, compactación | +| **Portfolio loader** | `internal/portfolio/` | Lee `data/projects/` y `data/docs/` (`.md` + `.mdx`), indexa en SQLite FTS5 + vectores, búsqueda híbrida | +| **Cliente de embeddings** | `internal/embed/` | Embeddings compatibles con OpenAI, normalización, códec float32 | | **Persona** | `internal/persona/` | Carga persona desde `configs/portfolio-bot.yaml` | -| **CLI** | `cm./rony-chat-bot/` | Comandos: `serve`, `reindex`, `ask`, `version` | +| **CLI** | `cmd/chat-bot/` | Comandos: `serve`, `reindex`, `ask`, `version` | ### 2.3 Stack tecnológico @@ -87,8 +93,9 @@ El bot responde con información precisa extraída de los archivos markdown de p | **HTTP router** | `net/http` + `chi` | Stdlib + chi para middleware (CORS, logging) | | **SSE** | `net/http` Flusher | Stdlib es suficiente, no necesita librería externa | | **Config** | `gopkg.in/yaml.v3` | Mismo que harness | -| **RAG backend** | SQLite + FTS5 (BM25) | Sin dependencias externas, un solo archivo, rápido | -| **LLM** | llama.cpp (qwen2.5:1.5b GGUF) — default; Ollama como alternativa | Self-hosted por defecto | +| **RAG backend** | SQLite + FTS5 (BM25) ⊕ vectores densos | Sin dependencias externas, un solo archivo. Sin índice ANN: un portafolio son cientos de chunks, así que un scan completo son microsegundos | +| **LLM** | llama.cpp (qwen2.5-3b-instruct Q4_K_M) — default; Ollama como alternativa | Self-hosted por defecto. 3B y no 1.5B: ver el benchmark en `configs/portfolio-bot.yaml` | +| **Embeddings** | `nomic-embed-v2-moe` Q5_K_M, 768 dims | Multilingüe — el punto entero es casar preguntas en español con documentos en inglés | | **Tests** | stdlib + testify | Consistencia con el resto | --- @@ -214,7 +221,7 @@ dependencia esté colgada. ```json { "name": "Asistente de Victor Hugo Vargas", - "model": "qwen2.5:1.5b", + "model": "qwen2.5-3b-instruct", "persona": "...", "topics": ["proyectos", "experiencia", "skills técnicas"] } @@ -345,12 +352,16 @@ func (s *Server) rateLimitMiddleware(next http.Handler) http.Handler { ## 🧠 4. RAG (Retrieval-Augmented Generation) -> ⚠️ **Decisiones pendientes de validar antes de implementar este módulo:** +> ✅ **Las cuatro decisiones que esta sección dejaba abiertas ya se tomaron y se midieron:** > -> - **Tokenizer FTS5** — el spec asume `unicode61 remove_diacritics 2`. Confirmar con datos reales si conviene cambiar a `porter` (stemming EN), `trigram` (sub-string matching) o un tokenizer custom para español. **Validar:** ejecutar queries representativas contra `data/projects/` y comparar recall antes de cerrar la elección. -> - **Driver SQLite** — ✅ **DECIDIDO: `modernc.org/sqlite`** (puro Go, sin CGO). Ver benchmark abajo. -> - **Chunking** — el split por tamaño fijo (500 chars / 50 overlap) corta headings y code blocks arbitrariamente. **Validar:** medir recall con chunks por sección markdown (split por `#`/`##`) vs por tamaño. -> - **Sin similitud semántica** — BM25 no matchea "IA" con "machine learning" salvo que la palabra esté literal. **Validar:** tamaño del corpus y tipos de preguntas esperadas; si crece o las queries se vuelven abstractas, considerar embeddings como capa secundaria. +> - **Tokenizer FTS5** — `unicode61 remove_diacritics 2`, como decía el spec. El +> stemming no era el cuello de botella; el salto de idioma sí, y eso lo +> cierran los embeddings. +> - **Driver SQLite** — `modernc.org/sqlite` (puro Go, sin CGO). Benchmark abajo. +> - **Chunking** — por heading de markdown, no por tamaño fijo, y las secciones +> demasiado grandes se parten en `###` antes de caer al corte por bytes. §4.1. +> - **Similitud semántica** — agregada. BM25 solo devolvía **absolutamente nada** +> ante una pregunta en español sobre un documento en inglés. §4.2. ### 4.0 Decisión de driver: resultados del benchmark @@ -377,259 +388,224 @@ Justificación: ### 4.1 Pipeline de indexación ``` -data/projects/*.md - ↓ (read all files) -Raw markdown content - ↓ (split into chunks, ~500 chars, 50 overlap) -Chunks [] - ↓ (insert into SQLite FTS5 virtual table "portfolio_chunks") -Indexed corpus +data/projects/*.{md,mdx} kind=project → se anuncia en el catálogo +data/docs/*.{md,mdx} kind=doc → se busca, nunca se anuncia + ↓ (se salta el README de cada carpeta — son instrucciones, no contenido) +Markdown crudo + ↓ (split por heading; las secciones grandes se parten en ###, luego por tamaño) + ↓ (se descarta el chunk de frontmatter) +Chunks + ├─→ tabla virtual FTS5 "portfolio_chunks" + └─→ endpoint de embeddings → "portfolio_vectors" (id, content_hash, dim, vec) +Corpus indexado ``` +**Dos tipos de fuente.** Todo lo que está bajo `data_path` es un proyecto de +Victor y se lista en el catálogo que se inyecta en cada prompt. Todo lo que +está bajo `docs_path` es evidencia buscable que *no* es un proyecto: su CV, una +página "sobre mí". El CV es el documento que de verdad lee quien está decidiendo +si contratarlo, y era irrecuperable mientras vivía solo en el sitio de Astro; +pero meterlo en projects hacía que el bot listara "cv" como uno de sus trabajos. + +**Tres cosas se excluyen o se reorganizan, cada una por una falla medida:** + +| Regla | Falla que arregla | +|---|---| +| Saltar `README*` en ambas carpetas | `data/projects/README.md` se indexaba, así que el catálogo anunciaba "README" y "README.es" como proyectos de Victor | +| Descartar el chunk de frontmatter | Metadata densa en un chunk muy corto es un imán para consultas breves — el campo `location:` de un CV respondía *"¿Dónde ha trabajado Victor?"* con una ciudad en vez del historial laboral | +| Partir las secciones grandes en `###` | La sección Experience de un CV es una lista de empleos; el corte por tamaño partía una entrada a mitad de palabra y dejaba el nombre del empleador huérfano en el chunk anterior. Ahora cada chunk es un empleo, llamado `Experience — Metrimex — Frontend Developer` | + **Cuándo se ejecuta:** -- Al arrancar el bot (si `--reindex-on-start` flag) - Manualmente: `./chat-bot reindex` +- Al arrancar, con `serve --reindex-on-start` - Vía HTTP: `POST /api/reindex` +Los vectores se construyen al indexar, así que activar embeddings exige un +reindex. La tabla de chunks es dato derivado: `OpenStore` la reconstruye cuando +le faltan columnas, de modo que actualizar una instalación existente no +requiere migración. Esa reconstrucción nunca toca las tablas de conversaciones. + ### 4.2 Pipeline de retrieval ``` -User query "¿qué proyectos tiene Victor?" - ↓ (FTS5 MATCH query, BM25 ranking, top_k=5) -Top 5 chunks relevantes - ↓ (format as context block) -System prompt += chunks relevantes - ↓ (send to LLM) -LLM generates answer +Consulta "¿Con qué se paga en la tienda de ropa?" + ↓ + ├─→ FTS5 MATCH, ranking BM25 → top 15 (topK × 3) + └─→ embed(query) → coseno vs vecs → top 15 (topK × 3) + ↓ (Reciprocal Rank Fusion, k=60) +Top 5 chunks + ↓ (system prompt + catálogo + extractos + directiva de idioma) +El LLM genera la respuesta ``` -### 4.3 Implementación +**Las dos mitades hacen falta.** La búsqueda por palabras hace coincidencia +exacta: sin stemming, sin traducción. El corpus está escrito en inglés y los +visitantes preguntan en español, así que las palabras con carga semántica +puntúan cero: medido sobre el corpus real, `"paga"` aparece 0 veces en un +documento que dice *"Payments: Stripe"* y `"trabajado"` 0 veces en uno que dice +*"worked"*. La pregunta de arriba no recuperaba **nada**. Los embeddings +(`nomic-embed-v2-moe`, multilingüe) ponen los tres chunks de `tienda-ropa` +arriba — pero difuminan los términos raros exactos, donde BM25 es preciso. + +**Por qué RRF y no una puntuación ponderada.** Un score BM25 y un coseno no +comparten escala, así que mezclarlos numéricamente significa inventar un factor +de conversión y reajustarlo cada vez que cambia el corpus. RRF ignora las +magnitudes y ordena por acuerdo entre los dos rankings: cada lista aporta +`1/(60 + rank)`. Un chunk que le gusta a ambas mitades le gana a uno que solo +una adora. + +**Degradación.** Si el endpoint de embeddings se cae o se desactiva, la mitad +vectorial devuelve vacío y la recuperación sigue en modo keyword-only en vez de +fallar la petición. + +**Vectores obsoletos.** Los ids de chunk se derivan de la posición, así que +sobreviven a las ediciones del cuerpo. Sin una guarda, editar un documento deja +vectores que siguen describiendo el texto que se eliminó — reproducido en vivo +cambiando el medio de pago de un proyecto y viendo cómo el anterior seguía +apareciendo. Cada vector guarda un hash del texto exacto con el que se +construyó, y las filas cuyo hash ya no coincide se ignoran con un warning hasta +el siguiente reindex. + +**El catálogo.** El top-K devuelve las *secciones* que mejor coinciden, así que +una pregunta amplia como "¿qué ha construido Victor?" no se puede responder solo +con recuperación, y un modelo pequeño al que se le pide enumerar a partir de +resultados parciales inventa el resto. La lista completa de proyectos se +inyecta en cada turno a un costo de ~10 tokens por proyecto. Esto es lo que +detuvo que el bot nombrara proyectos inexistentes. + +### 4.3 Esquema ```go // internal/portfolio/indexer.go -package portfolio - -import ( - "context" - "database/sql" - "fmt" - "log/slog" - "os" - "path/filepath" - "strings" -) - -type Indexer struct { - dataPath string - db *sql.DB - chunkSize int - chunkOverlap int -} - -func (i *Indexer) IndexAll(ctx context.Context) (int, error) { - files, err := filepath.Glob(filepath.Join(i.dataPath, "*.md")) - if err != nil { - return 0, err - } - - // Reconstruir el índice FTS5 desde cero (DELETE+INSERT es más rápido - // que diff para corpus pequeños) - if _, err := i.db.ExecContext(ctx, `DELETE FROM portfolio_chunks`); err != nil { - return 0, fmt.Errorf("clear index: %w", err) - } - - totalChunks := 0 - for _, file := range files { - chunks, err := i.indexFile(ctx, file) - if err != nil { - slog.Warn("failed to index file", "file", file, "err", err) - continue - } - totalChunks += chunks - } - - return totalChunks, nil -} - -func (i *Indexer) indexFile(ctx context.Context, path string) (int, error) { - content, err := os.ReadFile(path) - if err != nil { - return 0, err - } - - projectID := strings.TrimSuffix(filepath.Base(path), ".md") - chunks := splitIntoChunks(string(content), i.chunkSize, i.chunkOverlap) - - tx, err := i.db.BeginTx(ctx, nil) - if err != nil { - return 0, err - } - defer tx.Rollback() - - stmt, err := tx.PrepareContext(ctx, ` - INSERT INTO portfolio_chunks (id, project_id, source_file, chunk_index, content) - VALUES (?, ?, ?, ?, ?) - `) - if err != nil { - return 0, err - } - defer stmt.Close() - - for idx, chunk := range chunks { - id := fmt.Sprintf("%s-chunk-%d", projectID, idx) - if _, err := stmt.ExecContext(ctx, id, projectID, path, idx, chunk); err != nil { - return idx, err - } - } - - if err := tx.Commit(); err != nil { - return 0, err - } - return len(chunks), nil -} - -// schema.go — aplicado al arrancar const schema = ` CREATE VIRTUAL TABLE IF NOT EXISTS portfolio_chunks USING fts5( id UNINDEXED, project_id UNINDEXED, + kind UNINDEXED, -- 'project' | 'doc' source_file UNINDEXED, + section UNINDEXED, -- el heading del que salió el chunk chunk_index UNINDEXED, content, tokenize = 'unicode61 remove_diacritics 2' ); ` -func splitIntoChunks(text string, size, overlap int) []string { - // Implementación simple: split por tamaño con overlap - // Versión production usa tokenizer-aware chunking - var chunks []string - for i := 0; i < len(text); i += size - overlap { - end := i + size - if end > len(text) { - end = len(text) - } - chunks = append(chunks, text[i:end]) - } - return chunks -} +// Los vectores viven en una tabla común indexada por chunk id. No hay índice +// ANN: un portafolio son cientos de chunks, no millones, así que un scan +// completo con producto punto son microsegundos y no necesita extensiones. +const vectorSchema = ` +CREATE TABLE IF NOT EXISTS portfolio_vectors ( + chunk_id TEXT PRIMARY KEY, + content_hash TEXT NOT NULL, -- sha256 del texto exacto embebido + dim INTEGER NOT NULL, + vec BLOB NOT NULL -- float32 little-endian, normalizado +); +` ``` -### 4.4 Retrieval en el agent loop +FTS5 no tiene `ALTER TABLE ADD COLUMN`, así que `ensureChunkSchema` dropea y +recrea `portfolio_chunks` cuando una base vieja no tiene alguna columna. Es +seguro precisamente porque la tabla es un índice derivado: cada fila se +regenera desde el markdown en el siguiente reindex. Toca deliberadamente solo +las tablas del índice; las conversaciones viven en el mismo archivo y son datos +reales del usuario. -```go -// internal/portfolio/search.go -package portfolio +Los vectores se normalizan al escribirlos, así que el producto punto **es** el +coseno y la búsqueda no necesita una división por comparación. `dim` se guarda +para que un cambio de modelo de embeddings se detecte en vez de producir +similitudes basura en silencio: las filas cuya dimensión no coincide con el +vector de consulta se ignoran. -type Hit struct { - ProjectID string - SourceFile string - ChunkIndex int - Content string - Score float64 // BM25 score devuelto por FTS5 -} +**API principal:** -func (s *Store) Search(ctx context.Context, query string, topK int) ([]Hit, error) { - // Escapar input del usuario: la sintaxis FTS5 puede romperse con caracteres especiales - ftsQuery := sanitizeFTS5(query) +| Función | Archivo | Para qué | +|---|---|---| +| `SourcesFor(dataPath, docsPath)` | `indexer.go` | Arma el par `[]Source`; un path vacío se salta, así que `docs_path` es opcional sin ramificar | +| `Store.Reindex(ctx, sources, cfg)` | `indexer.go` | Reconstruye ambas tablas desde disco | +| `Store.Search(ctx, query, topK)` | `indexer.go` | Solo BM25, excluye `section = 'frontmatter'` | +| `Store.HybridSearch(ctx, emb, q, topK)` | `hybrid.go` | BM25 + vectores fusionados con RRF | +| `Store.Catalog(ctx)` | `indexer.go` | Lista de proyectos para el prompt; filtra `kind = 'project'` | +| `embed.Client.Embed(ctx, texts)` | `internal/embed/` | Embeddings compatibles con OpenAI, reordenados por el campo `index` de la respuesta | - rows, err := s.db.QueryContext(ctx, ` - SELECT project_id, source_file, chunk_index, content, bm25(portfolio_chunks) AS score - FROM portfolio_chunks - WHERE portfolio_chunks MATCH ? - ORDER BY score - LIMIT ? - `, ftsQuery, topK) - if err != nil { - return nil, err - } - defer rows.Close() +### 4.4 Armado del prompt - var hits []Hit - for rows.Next() { - var h Hit - if err := rows.Scan(&h.ProjectID, &h.SourceFile, &h.ChunkIndex, &h.Content, &h.Score); err != nil { - return nil, err - } - hits = append(hits, h) - } - return hits, rows.Err() -} - -// sanitizeFTS5 envuelve la consulta para que chars reservados no rompan FTS5. -// Para un bot de Q&A: agrega wildcard prefix-match a cada token. -func sanitizeFTS5(q string) string { - tokens := strings.FieldsFunc(q, func(r rune) bool { - return !(r == '-' || r == '_' || (r >= '0' && r <= '9') || - (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || - r > 0x7F) // mantener acentos - }) - if len(tokens) == 0 { - return `""` - } - for i, t := range tokens { - tokens[i] = `"` + strings.ToLower(t) + `"*` - } - return strings.Join(tokens, " ") -} -``` +`agent.Runner.BuildMessages` corre una vez por petición y produce exactamente +un mensaje de sistema seguido de los turnos de conversación. El orden de las +operaciones importa: ```go // internal/agent/runner.go -package agent +func (r *Runner) BuildMessages(ctx context.Context, history []Message) ([]Message, string, error) { + // 1. Saca los mensajes de sistema (el resumen del compactador) de los turnos. + notes, history := foldSystemNotes(history) -func (r *Runner) buildSystemPrompt(ctx context.Context, query string) (string, error) { - basePrompt := r.persona.SystemPrompt + // 2. El idioma del visitante decide sobre qué versión del prompt + // construimos, así que se resuelve antes que nada. + lang := detectLanguage(history) + systemPrompt := r.promptFor(lang) - hits, err := r.store.Search(ctx, query, r.config.RAG.TopK) - if err != nil { - return "", err - } - if len(hits) == 0 { - return basePrompt, nil - } + // 3. Se resuelve antes del retrieval para que el presupuesto de + // extractos lo tenga en cuenta. + catalog := r.catalogBlock(ctx) - var contextBlock strings.Builder - contextBlock.WriteString(basePrompt) - contextBlock.WriteString("\n\n## Relevant context\n\n") - for _, h := range hits { - contextBlock.WriteString(fmt.Sprintf("### Source: %s\n%s\n\n", - h.SourceFile, h.Content)) - } - return contextBlock.String(), nil -} + // 4. Recuperación híbrida sobre el último turno del usuario. + hits, err := r.store.HybridSearch(ctx, r.embedder, last.Content, r.topK) + ragContext = r.limitRAGContext(systemPrompt, catalog, formatHits(hits), history) -func (r *Runner) RunStream(ctx context.Context, messages []llm.Message) iter.Seq2[Chunk, error] { - return func(yield func(Chunk, error) bool) { - lastUserMsg := getLastUserMessage(messages) - systemPrompt, err := r.buildSystemPrompt(ctx, lastUserMsg) - if err != nil { - yield(Chunk{}, err) - return - } + // 5. prompt → catálogo → extractos → resumen → directiva de idioma. + system := botpersona.BuildSystemPrompt(systemPrompt, catalog, ragContext) + system += "\n\n" + strings.Join(notes, "\n\n") + system += "\n\n" + r.languageDirective(lang) - messages = prependSystem(messages, systemPrompt) - - for chunk, err := range r.loop.RunStream(ctx, messages) { - if !yield(chunk, err) { - return - } - } - } + history, err = r.fitHistory(system, history) + return append([]Message{{Role: RoleSystem, Content: system}}, history...), ragContext, nil } ``` -**Por qué esto es más simple que embeddings:** -- Sin modelo de embeddings que descargar ni ejecutar (ahorra ~270MB de RAM y ~200ms por consulta) -- Un archivo (`data/portfolio.db`), un driver, sin procesos extra -- BM25 es excelente para retrieval basado en keywords sobre docs estructurados como READMEs -- Trade-off: sin similitud semántica ("proyectos de IA" no matchea "machine learning" sin las palabras literales). Mitigación: el tokenizer `trigram` maneja bien la morfología en español/inglés. +**Por qué exactamente un mensaje de sistema.** La plantilla de chat de Gemma 3 +lanza *"Conversation roles must alternate user/assistant/..."* ante cualquier +mensaje de sistema que no sea el primero, y llama-server lo devuelve como HTTP +400 — activar la compactación mataba la conversación la primera vez que se +disparaba. La API de Anthropic también rechaza mensajes de sistema a mitad de +conversación, así que plegarlos es el comportamiento portable y no un parche +para Gemma. `foldSystemNotes` nunca aliasea el slice del llamador; el handler +reutiliza el historial que le pasa. + +**Por qué la directiva de idioma va al final.** Es la instrucción que un modelo +pequeño tiene más probabilidad de seguir reteniendo cuando empieza a generar. +Aunque la posición sola no alcanzó — ver abajo. + +**Responder en el idioma del visitante** costó tres intentos, medidos sobre +gemma-3-1b con las mismas cinco preguntas en español: + +| Enfoque | Resultado | +|---|---| +| Prompt en inglés + "respondé en el idioma del usuario" | 1/5 respuestas en español | +| Prompt en inglés + ejemplos few-shot en español | 5/5 en español, pero ~2/5 eran el ejemplo copiado literal en vez de una respuesta | +| Una versión completa del prompt en español (`system_prompt_es`) | 4/5 en español, 4/5 respuestas reales | + +Así que `internal/i18n` detecta el idioma y `promptFor` elige la versión; la +directiva refuerza un prompt que ya está escrito en el idioma correcto en vez +de intentar sobreescribir uno escrito en el equivocado. Las dos versiones del +YAML hay que mantenerlas sincronizadas a mano. + +**Por qué la búsqueda por palabras no alcanzó.** El plan original daba tres +razones para no usar embeddings: no hay modelo que ejecutar, un solo archivo y +un solo driver, y BM25 es fuerte sobre documentos estructurados. Las dos +primeras siguen siendo ciertas y costaron lo previsto (~0.91 GB residentes, un +proceso más). La tercera acertó sobre el corpus y erró sobre las preguntas: +BM25 es excelente recuperando documentos en inglés dadas palabras en inglés, y +a este bot le preguntan en español. Eso no es un problema de morfología que +arregle un tokenizer `trigram` — es un problema de traducción. De ahí §4.2, y +de ahí que se conserven las dos mitades. --- ## 🗜️ 4.5 Auto-compactación -Las conversaciones largas eventualmente agotan el contexto — con la ventana de 4k de qwen2.5-3b, el system prompt de ~3k tokens + el bloque RAG sólo deja espacio para 2–3 turnos del usuario. La auto-compactación resuelve esto plegando la parte más antigua de la conversación en un único mensaje-resumen del sistema cuando los tokens de entrada del turno anterior cruzan un umbral configurable. +Las conversaciones largas eventualmente agotan el contexto. La auto-compactación pliega la parte más antigua de la conversación en un único mensaje-resumen del sistema cuando los tokens de entrada del turno anterior cruzan un umbral configurable. + +**Cuánto margen hay en realidad.** Medido sobre 20 peticiones reales con `context_size: 4096`, el prompt más grande que este bot llegó a construir fue de **1255 tokens** — system prompt, catálogo de proyectos, cinco chunks recuperados y la pregunta — con una mediana de 1069. Eso deja espacio para una docena larga de turnos cortos antes de llegar al umbral del 75% (~3070 tokens), no los 2–3 que estimaba un borrador anterior de este documento. La compactación es entonces una red de seguridad para hilos genuinamente largos, no algo que se dispare en una visita típica: en todo el benchmark no se activó nunca y no se truncó nada. ### Cuándo se dispara @@ -832,30 +808,56 @@ ls $RONY_MODELS_PATH/qwen2.5-3b-instruct-q4_k_m.gguf # 2. Arrancar llama-server (puerto configurable; default de llama.cpp es 8080) llama-server \ - -m $RONY_MODELS_PATH/qwen2.5-3b-instruct-q4_k_m.gguf \ - --port 9100 \ - --host 127.0.0.1 \ - --ctx-size 4096 \ - --mlock # previene swap, crítico en VPS compartido + -m $RONY_MODELS_PATH/Qwen2.5/qwen2.5-3b-instruct-q4_k_m.gguf \ + --port 9100 --host 127.0.0.1 \ + --ctx-size 4096 --parallel 1 \ + --device none --threads 2 --mlock \ + --temp 0.7 --top-k 20 --top-p 0.8 --repeat-penalty 1.05 -# 3. Verifica que configs/portfolio-bot.yaml apunte al mismo puerto +# 3. Arrancar el servidor de embeddings (segundo proceso, segunda terminal) +llama-server \ + -m $RONY_MODELS_PATH/embeddings/nomic-embed-v2-moe.Q5_K_M.gguf \ + --port 9200 --embedding --pooling mean \ + --ctx-size 2048 --parallel 1 --device none --threads 2 + +# 4. Verifica que configs/portfolio-bot.yaml apunte a los mismos puertos # providers[0].endpoint: http://localhost:9100/v1 +# embeddings.endpoint: http://localhost:9200/v1 -# 4. Arrancar el bot (puerto default 7331, también configurable) +# 5. Construir el índice (necesita el embebedor arriba — los vectores se crean acá) +./bin/chat-bot reindex + +# 6. Arrancar el bot (puerto default 7331, también configurable) ./bin/chat-bot serve # → Sirve en http://localhost:7331 # → Override: ./bin/chat-bot serve --port 9101 --host 127.0.0.1 ``` +**Flags que no son opcionales, cada una por una razón medida:** + +| Flag | Por qué | +|---|---| +| `--device none` | llama.cpp levanta el backend de GPU compilado aunque pases `-ngl 0`, y en un host sin GPU esos buffers salen de la RAM del sistema. Medido en qwen2.5-3b: 2,54 GB con una GPU absorbiéndolos, **3,66 GB sin ella**. Presupuestá con el segundo número | +| `--parallel 1` | `--ctx-size` se reparte entre slots y el default son 4, así que `--ctx-size 4096` sin esto le deja 1024 tokens a cada petición | +| `--mlock` | Previene swap — crítico en un VPS compartido | +| `--temp` / `--top-k` / `--top-p` | Usá los valores que publican los autores del modelo, no los defaults de llama.cpp. gemma-3-1b con `temperature 0.7` y el resto sin setear devolvía respuestas de 16 tokens | +| `--pooling mean` (embebedor) | Sin ella el endpoint no devuelve un vector por entrada y el cliente rechaza la respuesta | + **Referencia de puertos:** | Qué | Default | Cómo cambiarlo | |---|---|---| | Puerto HTTP de `llama-server` | 8080 (convención de llama.cpp) | flag `--port N` al arrancar `llama-server` | +| Puerto del `llama-server` de embeddings | 8080 (misma convención) | `--port N`; este repo usa 9200 | | Puerto HTTP del chat-bot | 7331 | flag `--port N` en `serve`, o `server.port` en YAML | | URL bot → llama-server | `http://localhost:8080/v1` | campo `endpoint` del provider en YAML | -El provider `llamacpp` se importa desde `rony-llm-agent/pkg/llm/providers/llamacpp` y se compila contra `llama.cpp` vía CGO o binario externo. +Mantené `context_size` en el YAML igual a `--ctx-size`: el bot calcula sus +presupuestos de RAG y compactación a partir de ese número y nunca le pregunta +al servidor qué tiene en realidad, así que un desacople significa prompts que +el servidor rechaza. + +El provider `llamacpp` se importa desde `rony-llm-agent/pkg/llm/providers/llamacpp` y habla HTTP con el servidor de arriba — sin CGO, sin enlazar contra llama.cpp. ### 6.2 Alternativa: Ollama (más fácil para desarrollo) @@ -890,12 +892,16 @@ providers: type: llamacpp model: qwen2.5-3b-instruct endpoint: http://localhost:9100/v1 # configurable, ver §6.1 - context_size: 4096 - max_tokens: 2048 + context_size: 4096 # debe coincidir con --ctx-size + max_tokens: 640 + temperature: 0.7 # defaults publicados de Qwen instruct + top_k: 20 + top_p: 0.8 + repeat_penalty: 1.05 default: true ``` -El adapter `llamacpp` se importa desde `rony-llm-agent/pkg/llm/providers/llamacpp` y se compila contra `llama.cpp` vía CGO o binario externo. +El adapter `llamacpp` se importa desde `rony-llm-agent/pkg/llm/providers/llamacpp` y habla HTTP con `llama-server` — sin CGO, sin enlazar contra llama.cpp. --- @@ -907,7 +913,8 @@ El adapter `llamacpp` se importa desde `rony-llm-agent/pkg/llm/providers/llamacp # Arrancar servidor HTTP chat-bot serve [--port 7331] [--host 0.0.0.0] [--reindex-on-start] -# Re-indexar portfolio (lee data/projects/*.md → SQLite FTS5) +# Re-indexar (lee data/projects/ + data/docs/ *.md y *.mdx → FTS5 + vectores) +# Necesita el servidor de embeddings arriba si los embeddings están activos. chat-bot reindex # Pregunta única (sin servidor, útil para tests) @@ -982,19 +989,62 @@ func serveCmd() *cobra.Command { ### 8.1 Recomendación: Self-hosted en VPS -```bash -# 1. Instalar dependencias -sudo apt install golang-go ollama -ollama pull qwen2.5:1.5b +Target: **2 cores de CPU, 8 GB de RAM, sin GPU.** Tres procesos — el bot y dos +instancias de `llama-server` — así que tres units. El bot depende de los dos. -# 2. Build +```bash +# 1. Build go build -o /usr/local/bin/chat-bot ./cmd/chat-bot -# 3. systemd service +# 2. El LLM +cat > /etc/systemd/system/llama-chat.service < /etc/systemd/system/llama-embed.service < /etc/systemd/system/chat-bot.service < ⚠️ **Decisiones pendientes de validar antes de implementar este módulo:** +> ✅ **The four decisions this section left open have been made and measured:** > -> - **Tokenizer FTS5** — el spec asume `unicode61 remove_diacritics 2`. Confirmar con datos reales si conviene cambiar a `porter` (stemming EN), `trigram` (sub-string matching) o un tokenizer custom para español. **Validar:** ejecutar queries representativas contra `data/projects/` y comparar recall antes de cerrar esta elección. -> - **Driver SQLite** — ✅ **DECIDIDO: `modernc.org/sqlite`** (puro Go, sin CGO). Ver benchmark abajo. -> - **Chunking** — el split por tamaño fijo (500 chars / 50 overlap) corta headings y code blocks arbitrariamente. **Validar:** medir recall con chunks por sección markdown (split por `#`/`##`) vs por tamaño. -> - **Sin similitud semántica** — BM25 no matchea "IA" con "machine learning" salvo que la palabra esté literal. **Validar:** tamaño del corpus y types of questions esperadas; si el corpus crece o las queries se vuelven abstractas, considerar agregar embeddings como capa secundaria. +> - **FTS5 tokenizer** — `unicode61 remove_diacritics 2`, as specced. Stemming +> was not the bottleneck; the language gap was, and embeddings close it. +> - **SQLite driver** — `modernc.org/sqlite` (pure Go, no CGO). Benchmark below. +> - **Chunking** — by markdown heading, not fixed size, with oversized sections +> split at `###` before falling back to byte offsets. §4.1. +> - **Semantic similarity** — added. BM25 alone returned **nothing at all** for +> a Spanish question about an English document. §4.2. ### 4.0 Driver decision: benchmark results @@ -498,258 +508,217 @@ Justificación: ### 4.1 Indexing pipeline ``` -data/projects/*.md - ↓ (read all files) -Raw markdown content - ↓ (split into chunks, ~500 chars, 50 overlap) -Chunks [] - ↓ (insert into SQLite FTS5 virtual table "portfolio_chunks") +data/projects/*.{md,mdx} kind=project → announced in the catalogue +data/docs/*.{md,mdx} kind=doc → retrievable, never announced + ↓ (skip each directory's README — those are instructions, not content) +Raw markdown + ↓ (split by heading; sections over the limit split at ###, then by size) + ↓ (drop the frontmatter chunk) +Chunks + ├─→ SQLite FTS5 virtual table "portfolio_chunks" + └─→ embeddings endpoint → "portfolio_vectors" (id, content_hash, dim, vec) Indexed corpus ``` +**Two kinds of source.** Everything under `data_path` is one of Victor's +projects and is listed in the catalogue injected into every prompt. Everything +under `docs_path` is searchable evidence that is *not* a project — his CV, an +about page. The 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 his works. + +**Three things are excluded or reshaped, each because of a measured failure:** + +| Rule | Failure it fixes | +|---|---| +| Skip `README*` in both directories | `data/projects/README.md` was indexed, so the catalogue announced "README" and "README.es" as projects of Victor's | +| Drop the frontmatter chunk | Dense metadata in a very short chunk is 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 `###` | A CV's Experience section is a list of jobs; size-splitting cut one mid-word, stranding the employer's name in the previous chunk. Chunks now hold one job each, named `Experience — Metrimex — Frontend Developer` | + **When it runs:** -- On bot startup (if `--reindex-on-start` flag) - Manually: `./chat-bot reindex` +- On startup with `serve --reindex-on-start` - Via HTTP: `POST /api/reindex` +Vectors are built at index time, so enabling embeddings requires a reindex. +The chunk table is derived data: `OpenStore` rebuilds it when its columns are +missing, so upgrading an existing install needs no migration step. Conversation +tables are never touched by that rebuild. + ### 4.2 Retrieval pipeline ``` -User query "what projects does Victor have?" - ↓ (FTS5 MATCH query, BM25 ranking, top_k=5) -Top 5 relevant chunks - ↓ (format as context block) -System prompt += relevant chunks - ↓ (send to LLM) +User query "¿Con qué se paga en la tienda de ropa?" + ↓ + ├─→ FTS5 MATCH, BM25 ranking → top 15 (topK × 3) + └─→ embed(query) → cosine vs vecs → top 15 (topK × 3) + ↓ (Reciprocal Rank Fusion, k=60) +Top 5 chunks + ↓ (system prompt + project catalogue + excerpts + language directive) LLM generates answer ``` -### 4.3 Implementation +**Both halves are load-bearing.** Keyword search matches words exactly — no +stemming, no translation. The corpus is written in English and visitors ask in +Spanish, so the words carrying the meaning score zero: measured on the real +corpus, `"paga"` appears 0 times in a document that says *"Payments: Stripe"* +and `"trabajado"` 0 times in one that says *"worked"*. The question above +retrieved **nothing at all**. Embeddings (`nomic-embed-v2-moe`, multilingual) +put all three `tienda-ropa` chunks on top — but they blur exact rare tokens, +where BM25 is sharp. + +**Why RRF rather than a weighted score.** A BM25 score and a cosine share no +scale, so blending them numerically means inventing a conversion factor and +retuning it whenever the corpus changes. RRF ignores the magnitudes and ranks +by agreement between the two orderings: each list contributes `1/(60 + rank)`. +A chunk both halves like beats one that either loves alone. + +**Degradation.** If the embeddings endpoint is down or disabled, the vector +half returns nothing and retrieval continues keyword-only rather than failing +the request. + +**Stale vectors.** Chunk ids are derived from position, so they survive edits +to the body. Without a guard, editing a document leaves vectors that still +describe the text that was removed — reproduced live by changing a project's +payment provider and watching the old one keep coming back. Each vector stores +a hash of the exact text it was built from, and rows whose hash no longer +matches the chunk are skipped with a warning until the next reindex. + +**The catalogue.** Top-K returns the best-matching *sections*, so a broad +question like "what has Victor built?" cannot be answered from retrieval alone, +and a small model asked to enumerate from partial hits invents the rest. The +full project list is injected every turn at a cost of ~10 tokens per project. +This is what stopped the bot naming projects that do not exist. + +### 4.3 Schema ```go // internal/portfolio/indexer.go -package portfolio - -import ( - "context" - "database/sql" - "fmt" - "log/slog" - "os" - "path/filepath" - "strings" -) - -type Indexer struct { - dataPath string - db *sql.DB - chunkSize int - chunkOverlap int -} - -func (i *Indexer) IndexAll(ctx context.Context) (int, error) { - files, err := filepath.Glob(filepath.Join(i.dataPath, "*.md")) - if err != nil { - return 0, err - } - - // Rebuild FTS5 index from scratch (delete + insert is faster than diff for small corpora) - if _, err := i.db.ExecContext(ctx, `DELETE FROM portfolio_chunks`); err != nil { - return 0, fmt.Errorf("clear index: %w", err) - } - - totalChunks := 0 - for _, file := range files { - chunks, err := i.indexFile(ctx, file) - if err != nil { - slog.Warn("failed to index file", "file", file, "err", err) - continue - } - totalChunks += chunks - } - - return totalChunks, nil -} - -func (i *Indexer) indexFile(ctx context.Context, path string) (int, error) { - content, err := os.ReadFile(path) - if err != nil { - return 0, err - } - - projectID := strings.TrimSuffix(filepath.Base(path), ".md") - chunks := splitIntoChunks(string(content), i.chunkSize, i.chunkOverlap) - - tx, err := i.db.BeginTx(ctx, nil) - if err != nil { - return 0, err - } - defer tx.Rollback() - - stmt, err := tx.PrepareContext(ctx, ` - INSERT INTO portfolio_chunks (id, project_id, source_file, chunk_index, content) - VALUES (?, ?, ?, ?, ?) - `) - if err != nil { - return 0, err - } - defer stmt.Close() - - for idx, chunk := range chunks { - id := fmt.Sprintf("%s-chunk-%d", projectID, idx) - if _, err := stmt.ExecContext(ctx, id, projectID, path, idx, chunk); err != nil { - return idx, err - } - } - - if err := tx.Commit(); err != nil { - return 0, err - } - return len(chunks), nil -} - -// schema.go — applied at startup const schema = ` CREATE VIRTUAL TABLE IF NOT EXISTS portfolio_chunks USING fts5( id UNINDEXED, project_id UNINDEXED, + kind UNINDEXED, -- 'project' | 'doc' source_file UNINDEXED, + section UNINDEXED, -- the heading this chunk came from chunk_index UNINDEXED, content, tokenize = 'unicode61 remove_diacritics 2' ); ` -func splitIntoChunks(text string, size, overlap int) []string { - // Simple implementation: split by size with overlap - // Production version uses tokenizer-aware chunking - var chunks []string - for i := 0; i < len(text); i += size - overlap { - end := i + size - if end > len(text) { - end = len(text) - } - chunks = append(chunks, text[i:end]) - } - return chunks -} +// 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. +const vectorSchema = ` +CREATE TABLE IF NOT EXISTS portfolio_vectors ( + chunk_id TEXT PRIMARY KEY, + content_hash TEXT NOT NULL, -- sha256 of the exact text embedded + dim INTEGER NOT NULL, + vec BLOB NOT NULL -- little-endian float32, unit-normalised +); +` ``` -### 4.4 Retrieval in the agent loop +FTS5 has no `ALTER TABLE ADD COLUMN`, so `ensureChunkSchema` drops and +recreates `portfolio_chunks` when an older database is missing a column. That +is safe precisely because the table is a derived index — every row is +regenerated from the markdown on the next reindex. It deliberately touches +only the index tables; conversations live in the same file and are real user +data. -```go -// internal/portfolio/search.go -package portfolio +Vectors are unit-normalised at write time, so a dot product **is** the cosine +and retrieval needs no division per comparison. `dim` is stored so a change of +embedding model is detected rather than silently producing garbage similarity: +rows whose dimension does not match the query vector are skipped. -type Hit struct { - ProjectID string - SourceFile string - ChunkIndex int - Content string - Score float64 // BM25 score from FTS5 -} +**Key API:** -func (s *Store) Search(ctx context.Context, query string, topK int) ([]Hit, error) { - // Escape user input: FTS5 syntax can break with special chars - ftsQuery := sanitizeFTS5(query) +| Function | File | Purpose | +|---|---|---| +| `SourcesFor(dataPath, docsPath)` | `indexer.go` | Builds the `[]Source` pair; an empty path is skipped, so `docs_path` is optional without branching | +| `Store.Reindex(ctx, sources, cfg)` | `indexer.go` | Rebuilds both tables from disk | +| `Store.Search(ctx, query, topK)` | `indexer.go` | BM25 only, excludes `section = 'frontmatter'` | +| `Store.HybridSearch(ctx, emb, q, topK)` | `hybrid.go` | BM25 + vectors fused with RRF | +| `Store.Catalog(ctx)` | `indexer.go` | Project list for the prompt; filters `kind = 'project'` | +| `embed.Client.Embed(ctx, texts)` | `internal/embed/` | OpenAI-compatible embeddings, reordered by the response `index` field | - rows, err := s.db.QueryContext(ctx, ` - SELECT project_id, source_file, chunk_index, content, bm25(portfolio_chunks) AS score - FROM portfolio_chunks - WHERE portfolio_chunks MATCH ? - ORDER BY score - LIMIT ? - `, ftsQuery, topK) - if err != nil { - return nil, err - } - defer rows.Close() +### 4.4 Assembling the prompt - var hits []Hit - for rows.Next() { - var h Hit - if err := rows.Scan(&h.ProjectID, &h.SourceFile, &h.ChunkIndex, &h.Content, &h.Score); err != nil { - return nil, err - } - hits = append(hits, h) - } - return hits, rows.Err() -} - -// sanitizeFTS5 wraps the user query so reserved chars and unquoted strings don't crash FTS5. -// A pragmatic choice for a Q&A bot: append prefix-match wildcard to each token. -func sanitizeFTS5(q string) string { - tokens := strings.FieldsFunc(q, func(r rune) bool { - return !(r == '-' || r == '_' || (r >= '0' && r <= '9') || - (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || - r > 0x7F) // keep accented chars - }) - if len(tokens) == 0 { - return `""` - } - for i, t := range tokens { - tokens[i] = `"` + strings.ToLower(t) + `"*` - } - return strings.Join(tokens, " ") -} -``` +`agent.Runner.BuildMessages` runs once per request and produces exactly one +system message followed by the conversational turns. The order of operations +is load-bearing: ```go // internal/agent/runner.go -package agent +func (r *Runner) BuildMessages(ctx context.Context, history []Message) ([]Message, string, error) { + // 1. Pull system-role notes (the compactor's summary) out of the turn list. + notes, history := foldSystemNotes(history) -func (r *Runner) buildSystemPrompt(ctx context.Context, query string) (string, error) { - basePrompt := r.persona.SystemPrompt + // 2. The visitor's language selects which rendition of the prompt we + // build on, so it is resolved before anything else. + lang := detectLanguage(history) + systemPrompt := r.promptFor(lang) - hits, err := r.store.Search(ctx, query, r.config.RAG.TopK) - if err != nil { - return "", err - } - if len(hits) == 0 { - return basePrompt, nil - } + // 3. Resolved before retrieval so the excerpt budget accounts for it. + catalog := r.catalogBlock(ctx) - var contextBlock strings.Builder - contextBlock.WriteString(basePrompt) - contextBlock.WriteString("\n\n## Relevant context\n\n") - for _, h := range hits { - contextBlock.WriteString(fmt.Sprintf("### Source: %s\n%s\n\n", - h.SourceFile, h.Content)) - } - return contextBlock.String(), nil -} + // 4. Hybrid retrieval on the last user turn. + hits, err := r.store.HybridSearch(ctx, r.embedder, last.Content, r.topK) + ragContext = r.limitRAGContext(systemPrompt, catalog, formatHits(hits), history) -func (r *Runner) RunStream(ctx context.Context, messages []llm.Message) iter.Seq2[Chunk, error] { - return func(yield func(Chunk, error) bool) { - lastUserMsg := getLastUserMessage(messages) - systemPrompt, err := r.buildSystemPrompt(ctx, lastUserMsg) - if err != nil { - yield(Chunk{}, err) - return - } + // 5. prompt → catalogue → excerpts → summary → language directive. + system := botpersona.BuildSystemPrompt(systemPrompt, catalog, ragContext) + system += "\n\n" + strings.Join(notes, "\n\n") + system += "\n\n" + r.languageDirective(lang) - messages = prependSystem(messages, systemPrompt) - - for chunk, err := range r.loop.RunStream(ctx, messages) { - if !yield(chunk, err) { - return - } - } - } + history, err = r.fitHistory(system, history) + return append([]Message{{Role: RoleSystem, Content: system}}, history...), ragContext, nil } ``` -**Why this is simpler than embeddings:** -- No embedding model to download or run (saves ~270MB of RAM and ~200ms per query) -- One file (`data/portfolio.db`), one driver, no extra process -- BM25 ranking is excellent for keyword-based retrieval over structured docs like project READMEs -- Trade-off: no semantic similarity ("projects about AI" won't match "machine learning" without the literal words). Mitigation: `trigram` tokenizer handles morphology well for English/Spanish. +**Why exactly one system message.** Gemma 3's chat template raises +*"Conversation roles must alternate user/assistant/..."* on any system message +after the first, which llama-server surfaces as HTTP 400 — enabling compaction +used to kill the conversation outright the first time it fired. Anthropic's API +rejects mid-conversation system turns too, so folding is the portable +behaviour rather than a Gemma workaround. `foldSystemNotes` never aliases the +caller's slice; the handler reuses the history it passes in. + +**Why the language directive goes last.** It is the instruction a small model +is most likely to still be holding when it starts generating. Position was not +enough on its own, though — see below. + +**Matching the visitor's language** took three attempts, measured on gemma-3-1b +over the same five Spanish questions: + +| Approach | Result | +|---|---| +| English prompt + "reply in the user's language" | 1/5 answered in Spanish | +| English prompt + Spanish few-shot examples | 5/5 Spanish, but ~2/5 were the example reply copied verbatim instead of an answer | +| A full Spanish rendition of the prompt (`system_prompt_es`) | 4/5 Spanish, 4/5 real answers | + +So `internal/i18n` detects the language and `promptFor` selects the rendition; +the directive reinforces a prompt already written in the right language rather +than trying to override one written in the wrong one. The two renditions in +the YAML have to be kept in sync by hand. + +**Why keyword search alone was not enough.** The original plan cited three +reasons to skip embeddings — no model to run, one file and one driver, and +BM25 being strong on structured project docs. The first two still hold and +cost what was predicted (~0.91 GB resident, a second process). The third was +right about the corpus and wrong about the questions: BM25 is excellent at +retrieving English documents given English keywords, and this bot is asked in +Spanish. That is not a morphology problem a `trigram` tokenizer fixes — it is a +translation problem. Hence §4.2, and hence both halves are kept. --- ## 🗜️ 4.5 Auto-compaction -Long conversations eventually run out of context — at qwen2.5-3b's 4k window, the ~3k-token system prompt + RAG block leaves only room for 2–3 user turns. Auto-compaction solves this by folding the older portion of the conversation into a single summary system message when the previous turn's input tokens cross a configurable threshold. +Long conversations eventually run out of context. Auto-compaction folds the older portion of the conversation into a single summary system message when the previous turn's input tokens cross a configurable threshold. + +**How much headroom there actually is.** Measured over 20 real requests at `context_size: 4096`, the largest prompt this bot ever built was **1255 tokens** — system prompt, project catalogue, five retrieved chunks and the question — with a median of 1069. That leaves room for roughly a dozen short turns before the 75% threshold (~3070 tokens) is reached, not the 2–3 an earlier draft of this document estimated. Compaction is therefore a safety net for genuinely long threads rather than something that fires in a typical visit; across the whole benchmark it never triggered and nothing was truncated. ### When it fires @@ -951,30 +920,55 @@ ls $RONY_MODELS_PATH/qwen2.5-3b-instruct-q4_k_m.gguf # 2. Start llama-server (port is configurable; default llama.cpp is 8080) llama-server \ - -m $RONY_MODELS_PATH/qwen2.5-3b-instruct-q4_k_m.gguf \ - --port 9100 \ - --host 127.0.0.1 \ - --ctx-size 4096 \ - --mlock # prevents swap, critical on shared VPS + -m $RONY_MODELS_PATH/Qwen2.5/qwen2.5-3b-instruct-q4_k_m.gguf \ + --port 9100 --host 127.0.0.1 \ + --ctx-size 4096 --parallel 1 \ + --device none --threads 2 --mlock \ + --temp 0.7 --top-k 20 --top-p 0.8 --repeat-penalty 1.05 -# 3. Make sure configs/portfolio-bot.yaml points to the same port +# 3. Start the embeddings server (second process, second terminal) +llama-server \ + -m $RONY_MODELS_PATH/embeddings/nomic-embed-v2-moe.Q5_K_M.gguf \ + --port 9200 --embedding --pooling mean \ + --ctx-size 2048 --parallel 1 --device none --threads 2 + +# 4. Make sure configs/portfolio-bot.yaml points to the same ports # providers[0].endpoint: http://localhost:9100/v1 +# embeddings.endpoint: http://localhost:9200/v1 -# 4. Start the bot (default port 7331, also configurable) +# 5. Build the index (needs the embedder up — vectors are built here) +./bin/chat-bot reindex + +# 6. Start the bot (default port 7331, also configurable) ./bin/chat-bot serve # → Serves on http://localhost:7331 # → Override with: ./bin/chat-bot serve --port 9101 --host 127.0.0.1 ``` +**Flags that are not optional, each for a measured reason:** + +| Flag | Why | +|---|---| +| `--device none` | llama.cpp brings up a compiled-in GPU backend even with `-ngl 0`, and on a GPU-less host those buffers come out of system RAM. Measured on qwen2.5-3b: 2.54 GB with a GPU absorbing them, **3.66 GB without**. Budget from the second number | +| `--parallel 1` | `--ctx-size` is divided across slots and the default is 4, so `--ctx-size 4096` without it gives each request 1024 tokens | +| `--mlock` | Prevents swap — critical on a shared VPS | +| `--temp` / `--top-k` / `--top-p` | Use the model authors' published values, not llama.cpp's defaults. gemma-3-1b at `temperature 0.7` with the rest unset produced 16-token stub answers | +| `--pooling mean` (embedder) | Without it the endpoint does not return one vector per input and the client rejects the response | + **Port reference:** | What | Default | How to change | |---|---|---| | `llama-server` HTTP port | 8080 (llama.cpp convention) | `--port N` flag when starting `llama-server` | +| embeddings `llama-server` port | 8080 (same convention) | `--port N`; this repo uses 9200 | | chat-bot HTTP port | 7331 | `--port N` flag on `serve`, or `server.port` in YAML | | chat-bot → llama-server URL | `http://localhost:8080/v1` | `endpoint` field on the provider in YAML | -The `llamacpp` provider is imported from `rony-llm-agent/pkg/llm/providers/llamacpp` and is compiled against `llama.cpp` via CGO or external binary. +Keep `context_size` in the YAML equal to `--ctx-size`: the bot sizes its RAG +and compaction budgets from that number and never asks the server what it +actually has, so a mismatch means prompts the server rejects. + +The `llamacpp` provider is imported from `rony-llm-agent/pkg/llm/providers/llamacpp` and speaks HTTP to the server above — no CGO, no linking against llama.cpp. ### 6.2 Alternative: Ollama (easier for development) @@ -1009,12 +1003,16 @@ providers: type: llamacpp model: qwen2.5-3b-instruct endpoint: http://localhost:9100/v1 # configurable, see §6.1 - context_size: 4096 - max_tokens: 2048 + context_size: 4096 # must match --ctx-size + max_tokens: 640 + temperature: 0.7 # Qwen's published instruct defaults + top_k: 20 + top_p: 0.8 + repeat_penalty: 1.05 default: true ``` -The `llamacpp` adapter is imported from `rony-llm-agent/pkg/llm/providers/llamacpp` and is compiled against `llama.cpp` via CGO or external binary. +The `llamacpp` adapter is imported from `rony-llm-agent/pkg/llm/providers/llamacpp` and speaks HTTP to `llama-server` — no CGO, no linking against llama.cpp. --- @@ -1026,7 +1024,8 @@ The `llamacpp` adapter is imported from `rony-llm-agent/pkg/llm/providers/llamac # Start HTTP server chat-bot serve [--port 7331] [--host 0.0.0.0] [--reindex-on-start] -# Re-index portfolio (reads data/projects/*.md → SQLite FTS5) +# Re-index (reads data/projects/ + data/docs/ *.md and *.mdx → FTS5 + vectors) +# Needs the embeddings server up if embeddings are enabled. chat-bot reindex # Single question (no server, useful for tests) @@ -1101,19 +1100,62 @@ func serveCmd() *cobra.Command { ### 8.1 Recommendation: Self-hosted on VPS -```bash -# 1. Install dependencies -sudo apt install golang-go ollama -ollama pull qwen2.5:1.5b +Target: **2 CPU cores, 8 GB RAM, no GPU.** Three processes — the bot and two +`llama-server` instances — so three units. The bot depends on both. -# 2. Build +```bash +# 1. Build go build -o /usr/local/bin/chat-bot ./cmd/chat-bot -# 3. systemd service +# 2. The LLM +cat > /etc/systemd/system/llama-chat.service < /etc/systemd/system/llama-embed.service < /etc/systemd/system/chat-bot.service <