docs(architecture): bring the design doc up to what actually ships

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 <noreply@anthropic.com>
This commit is contained in:
Victor Hugo Vargas 2026-07-30 15:27:30 -07:00
parent 129809067b
commit 9ee722947a
2 changed files with 814 additions and 556 deletions

View file

@ -63,21 +63,27 @@ El bot responde con información precisa extraída de los archivos markdown de p
│ ↓ │ │ ↓ │
│ Agent loop (rony-llm-agent) │ │ 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) │ │ 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 ### 2.2 Componentes principales
| Componente | Path | Responsabilidad | | Componente | Path | Responsabilidad |
|---|---|---| |---|---|---|
| **HTTP server** | `internal/server/` | Gin/chi handlers, SSE streaming | | **HTTP server** | `internal/server/` | chi handlers, SSE streaming |
| **Agent runner** | `internal/agent/` | Wrapper sobre `rony-llm-agent` con config específica | | **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/*.md`, indexa en SQLite FTS5 | | **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` | | **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 ### 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) | | **HTTP router** | `net/http` + `chi` | Stdlib + chi para middleware (CORS, logging) |
| **SSE** | `net/http` Flusher | Stdlib es suficiente, no necesita librería externa | | **SSE** | `net/http` Flusher | Stdlib es suficiente, no necesita librería externa |
| **Config** | `gopkg.in/yaml.v3` | Mismo que harness | | **Config** | `gopkg.in/yaml.v3` | Mismo que harness |
| **RAG backend** | SQLite + FTS5 (BM25) | Sin dependencias externas, un solo archivo, rápido | | **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:1.5b GGUF) — default; Ollama como alternativa | Self-hosted por defecto | | **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 | | **Tests** | stdlib + testify | Consistencia con el resto |
--- ---
@ -214,7 +221,7 @@ dependencia esté colgada.
```json ```json
{ {
"name": "Asistente de Victor Hugo Vargas", "name": "Asistente de Victor Hugo Vargas",
"model": "qwen2.5:1.5b", "model": "qwen2.5-3b-instruct",
"persona": "...", "persona": "...",
"topics": ["proyectos", "experiencia", "skills técnicas"] "topics": ["proyectos", "experiencia", "skills técnicas"]
} }
@ -345,12 +352,16 @@ func (s *Server) rateLimitMiddleware(next http.Handler) http.Handler {
## 🧠 4. RAG (Retrieval-Augmented Generation) ## 🧠 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. > - **Tokenizer FTS5**`unicode61 remove_diacritics 2`, como decía el spec. El
> - **Driver SQLite** — ✅ **DECIDIDO: `modernc.org/sqlite`** (puro Go, sin CGO). Ver benchmark abajo. > stemming no era el cuello de botella; el salto de idioma sí, y eso lo
> - **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. > cierran los embeddings.
> - **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. > - **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 ### 4.0 Decisión de driver: resultados del benchmark
@ -377,259 +388,224 @@ Justificación:
### 4.1 Pipeline de indexación ### 4.1 Pipeline de indexación
``` ```
data/projects/*.md data/projects/*.{md,mdx} kind=project → se anuncia en el catálogo
↓ (read all files) data/docs/*.{md,mdx} kind=doc → se busca, nunca se anuncia
Raw markdown content ↓ (se salta el README de cada carpeta — son instrucciones, no contenido)
↓ (split into chunks, ~500 chars, 50 overlap) Markdown crudo
Chunks [] ↓ (split por heading; las secciones grandes se parten en ###, luego por tamaño)
↓ (insert into SQLite FTS5 virtual table "portfolio_chunks") ↓ (se descarta el chunk de frontmatter)
Indexed corpus 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:** **Cuándo se ejecuta:**
- Al arrancar el bot (si `--reindex-on-start` flag)
- Manualmente: `./chat-bot reindex` - Manualmente: `./chat-bot reindex`
- Al arrancar, con `serve --reindex-on-start`
- Vía HTTP: `POST /api/reindex` - 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 ### 4.2 Pipeline de retrieval
``` ```
User query "¿qué proyectos tiene Victor?" Consulta "¿Con qué se paga en la tienda de ropa?"
↓ (FTS5 MATCH query, BM25 ranking, top_k=5)
Top 5 chunks relevantes ├─→ FTS5 MATCH, ranking BM25 → top 15 (topK × 3)
↓ (format as context block) └─→ embed(query) → coseno vs vecs → top 15 (topK × 3)
System prompt += chunks relevantes ↓ (Reciprocal Rank Fusion, k=60)
↓ (send to LLM) Top 5 chunks
LLM generates answer ↓ (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 ```go
// internal/portfolio/indexer.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 = ` const schema = `
CREATE VIRTUAL TABLE IF NOT EXISTS portfolio_chunks USING fts5( CREATE VIRTUAL TABLE IF NOT EXISTS portfolio_chunks USING fts5(
id UNINDEXED, id UNINDEXED,
project_id UNINDEXED, project_id UNINDEXED,
kind UNINDEXED, -- 'project' | 'doc'
source_file UNINDEXED, source_file UNINDEXED,
section UNINDEXED, -- el heading del que salió el chunk
chunk_index UNINDEXED, chunk_index UNINDEXED,
content, content,
tokenize = 'unicode61 remove_diacritics 2' tokenize = 'unicode61 remove_diacritics 2'
); );
` `
func splitIntoChunks(text string, size, overlap int) []string { // Los vectores viven en una tabla común indexada por chunk id. No hay índice
// Implementación simple: split por tamaño con overlap // ANN: un portafolio son cientos de chunks, no millones, así que un scan
// Versión production usa tokenizer-aware chunking // completo con producto punto son microsegundos y no necesita extensiones.
var chunks []string const vectorSchema = `
for i := 0; i < len(text); i += size - overlap { CREATE TABLE IF NOT EXISTS portfolio_vectors (
end := i + size chunk_id TEXT PRIMARY KEY,
if end > len(text) { content_hash TEXT NOT NULL, -- sha256 del texto exacto embebido
end = len(text) dim INTEGER NOT NULL,
} vec BLOB NOT NULL -- float32 little-endian, normalizado
chunks = append(chunks, text[i:end]) );
} `
return chunks
}
``` ```
### 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 Los vectores se normalizan al escribirlos, así que el producto punto **es** el
// internal/portfolio/search.go coseno y la búsqueda no necesita una división por comparación. `dim` se guarda
package portfolio 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 { **API principal:**
ProjectID string
SourceFile string
ChunkIndex int
Content string
Score float64 // BM25 score devuelto por FTS5
}
func (s *Store) Search(ctx context.Context, query string, topK int) ([]Hit, error) { | Función | Archivo | Para qué |
// Escapar input del usuario: la sintaxis FTS5 puede romperse con caracteres especiales |---|---|---|
ftsQuery := sanitizeFTS5(query) | `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, ` ### 4.4 Armado del prompt
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()
var hits []Hit `agent.Runner.BuildMessages` corre una vez por petición y produce exactamente
for rows.Next() { un mensaje de sistema seguido de los turnos de conversación. El orden de las
var h Hit operaciones importa:
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, " ")
}
```
```go ```go
// internal/agent/runner.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) { // 2. El idioma del visitante decide sobre qué versión del prompt
basePrompt := r.persona.SystemPrompt // 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) // 3. Se resuelve antes del retrieval para que el presupuesto de
if err != nil { // extractos lo tenga en cuenta.
return "", err catalog := r.catalogBlock(ctx)
}
if len(hits) == 0 {
return basePrompt, nil
}
var contextBlock strings.Builder // 4. Recuperación híbrida sobre el último turno del usuario.
contextBlock.WriteString(basePrompt) hits, err := r.store.HybridSearch(ctx, r.embedder, last.Content, r.topK)
contextBlock.WriteString("\n\n## Relevant context\n\n") ragContext = r.limitRAGContext(systemPrompt, catalog, formatHits(hits), history)
for _, h := range hits {
contextBlock.WriteString(fmt.Sprintf("### Source: %s\n%s\n\n",
h.SourceFile, h.Content))
}
return contextBlock.String(), nil
}
func (r *Runner) RunStream(ctx context.Context, messages []llm.Message) iter.Seq2[Chunk, error] { // 5. prompt → catálogo → extractos → resumen → directiva de idioma.
return func(yield func(Chunk, error) bool) { system := botpersona.BuildSystemPrompt(systemPrompt, catalog, ragContext)
lastUserMsg := getLastUserMessage(messages) system += "\n\n" + strings.Join(notes, "\n\n")
systemPrompt, err := r.buildSystemPrompt(ctx, lastUserMsg) system += "\n\n" + r.languageDirective(lang)
if err != nil {
yield(Chunk{}, err)
return
}
messages = prependSystem(messages, systemPrompt) history, err = r.fitHistory(system, history)
return append([]Message{{Role: RoleSystem, Content: system}}, history...), ragContext, nil
for chunk, err := range r.loop.RunStream(ctx, messages) {
if !yield(chunk, err) {
return
}
}
}
} }
``` ```
**Por qué esto es más simple que embeddings:** **Por qué exactamente un mensaje de sistema.** La plantilla de chat de Gemma 3
- Sin modelo de embeddings que descargar ni ejecutar (ahorra ~270MB de RAM y ~200ms por consulta) lanza *"Conversation roles must alternate user/assistant/..."* ante cualquier
- Un archivo (`data/portfolio.db`), un driver, sin procesos extra mensaje de sistema que no sea el primero, y llama-server lo devuelve como HTTP
- BM25 es excelente para retrieval basado en keywords sobre docs estructurados como READMEs 400 — activar la compactación mataba la conversación la primera vez que se
- 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. 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 ## 🗜️ 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 23 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 23 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 ### 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) # 2. Arrancar llama-server (puerto configurable; default de llama.cpp es 8080)
llama-server \ llama-server \
-m $RONY_MODELS_PATH/qwen2.5-3b-instruct-q4_k_m.gguf \ -m $RONY_MODELS_PATH/Qwen2.5/qwen2.5-3b-instruct-q4_k_m.gguf \
--port 9100 \ --port 9100 --host 127.0.0.1 \
--host 127.0.0.1 \ --ctx-size 4096 --parallel 1 \
--ctx-size 4096 \ --device none --threads 2 --mlock \
--mlock # previene swap, crítico en VPS compartido --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 # 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 ./bin/chat-bot serve
# → Sirve en http://localhost:7331 # → Sirve en http://localhost:7331
# → Override: ./bin/chat-bot serve --port 9101 --host 127.0.0.1 # → 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:** **Referencia de puertos:**
| Qué | Default | Cómo cambiarlo | | Qué | Default | Cómo cambiarlo |
|---|---|---| |---|---|---|
| Puerto HTTP de `llama-server` | 8080 (convención de llama.cpp) | flag `--port N` al arrancar `llama-server` | | 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 | | 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 | | 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) ### 6.2 Alternativa: Ollama (más fácil para desarrollo)
@ -890,12 +892,16 @@ providers:
type: llamacpp type: llamacpp
model: qwen2.5-3b-instruct model: qwen2.5-3b-instruct
endpoint: http://localhost:9100/v1 # configurable, ver §6.1 endpoint: http://localhost:9100/v1 # configurable, ver §6.1
context_size: 4096 context_size: 4096 # debe coincidir con --ctx-size
max_tokens: 2048 max_tokens: 640
temperature: 0.7 # defaults publicados de Qwen instruct
top_k: 20
top_p: 0.8
repeat_penalty: 1.05
default: true 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 # Arrancar servidor HTTP
chat-bot serve [--port 7331] [--host 0.0.0.0] [--reindex-on-start] 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 chat-bot reindex
# Pregunta única (sin servidor, útil para tests) # Pregunta única (sin servidor, útil para tests)
@ -982,19 +989,62 @@ func serveCmd() *cobra.Command {
### 8.1 Recomendación: Self-hosted en VPS ### 8.1 Recomendación: Self-hosted en VPS
```bash Target: **2 cores de CPU, 8 GB de RAM, sin GPU.** Tres procesos — el bot y dos
# 1. Instalar dependencias instancias de `llama-server` — así que tres units. El bot depende de los dos.
sudo apt install golang-go ollama
ollama pull qwen2.5:1.5b
# 2. Build ```bash
# 1. Build
go build -o /usr/local/bin/chat-bot ./cmd/chat-bot go build -o /usr/local/bin/chat-bot ./cmd/chat-bot
# 3. systemd service # 2. El LLM
cat > /etc/systemd/system/llama-chat.service <<EOF
[Unit]
Description=llama-server (modelo de chat)
After=network.target
[Service]
Type=simple
User=chatbot
ExecStart=/usr/local/bin/llama-server \\
-m /opt/models/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
Restart=on-failure
# --mlock necesita que la memoria se pueda bloquear
LimitMEMLOCK=infinity
[Install]
WantedBy=multi-user.target
EOF
# 3. El embebedor
cat > /etc/systemd/system/llama-embed.service <<EOF
[Unit]
Description=llama-server (embeddings)
After=network.target
[Service]
Type=simple
User=chatbot
ExecStart=/usr/local/bin/llama-server \\
-m /opt/models/embeddings/nomic-embed-v2-moe.Q5_K_M.gguf \\
--port 9200 --host 127.0.0.1 \\
--embedding --pooling mean \\
--ctx-size 2048 --parallel 1 --device none --threads 2
Restart=on-failure
[Install]
WantedBy=multi-user.target
EOF
# 4. El bot
cat > /etc/systemd/system/chat-bot.service <<EOF cat > /etc/systemd/system/chat-bot.service <<EOF
[Unit] [Unit]
Description=Portfolio Chat Bot Description=Portfolio Chat Bot
After=network.target ollama.service After=network.target llama-chat.service llama-embed.service
Wants=llama-chat.service llama-embed.service
[Service] [Service]
Type=simple Type=simple
@ -1002,15 +1052,27 @@ User=chatbot
WorkingDirectory=/opt/chat-bot WorkingDirectory=/opt/chat-bot
ExecStart=/usr/local/bin/chat-bot serve ExecStart=/usr/local/bin/chat-bot serve
Restart=on-failure Restart=on-failure
Environment=RONY_MODELS_PATH=/opt/models
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
EOF EOF
sudo systemctl enable --now chat-bot sudo systemctl enable --now llama-chat llama-embed chat-bot
# 5. Construir el índice una vez que los dos servidores estén arriba
sudo -u chatbot /usr/local/bin/chat-bot reindex
``` ```
**Presupuesto de memoria.** 3,64 GB (LLM) + 0,91 GB (embebedor) + 0,02 GB (bot)
**4,6 GB residentes**, dejando ~3,4 GB para lo demás que comparta el VPS.
Presupuestá con `--device none` puesto: sin esa flag los números se ven ~1,1 GB
más chicos en una máquina con GPU y después no se reproducen en producción. Ver
[`vps-context-sizing.md`](./vps-context-sizing.md).
`reindex` hay que volver a correrlo después de editar el markdown **y** después
de activar o cambiar el modelo de embeddings — los vectores se construyen al
indexar, y un cambio de modelo altera la dimensión.
### 8.2 Reverse proxy (Caddy) ### 8.2 Reverse proxy (Caddy)
``` ```
@ -1122,6 +1184,29 @@ curl -X POST http://localhost:4321/api/chat \
# 4. Verificar SSE stream # 4. Verificar SSE stream
``` ```
### 9.4 Tests de recuperación
La recuperación es la parte de este bot donde una regresión es silenciosa —
nada falla, las respuestas simplemente empeoran de a poco — así que cada falla
encontrada en las pruebas tiene un test que la fija. Los ejemplos de código de
arriba son ilustrativos; estos son reales.
| Test | Qué fija |
|---|---|
| `TestHybridSearchFindsChunkKeywordSearchCannot` | El hueco pregunta-en-español / documento-en-inglés, la razón de ser de los embeddings |
| `TestHybridSearchFallsBackWhenEmbedderFails` | Un endpoint de embeddings caído degrada a keyword-only, nunca falla la petición |
| `TestVectorSearchIgnoresVectorsWhoseChunkChanged` | Los vectores obsoletos tras editar un cuerpo se ignoran |
| `TestVectorSearchIgnoresMismatchedDimensions` | Cambiar el modelo de embeddings no produce similitudes basura |
| `TestReindexSkipsTheDirectoryReadmes` | El catálogo nunca anuncia `README` como proyecto |
| `TestReindexIndexesMdxAndSeparatesDocsFromProjects` | El `.mdx` se indexa; el CV se busca pero nunca se lista |
| `TestOpenStoreMigratesIndexWithoutKindColumn` | Actualizar una instalación existente no requiere migración |
| `TestSubSplitPrefersH3BoundariesOverByteOffsets` | Los empleos del CV quedan enteros en vez de cortados a mitad de palabra |
| `TestBuildMessagesFoldsSystemNotesIntoOneSystemMessage` | La compactación no puede reintroducir el HTTP 400 |
| `TestLanguageDirectiveIsLastInSystemPrompt` | La directiva conserva la posición que necesita para funcionar |
Corren contra SQLite real y un embebedor de prueba, así que no hace falta
ningún servidor de modelos: `go test ./...` alcanza.
--- ---
## 📂 10. Estructura del Proyecto ## 📂 10. Estructura del Proyecto
@ -1142,10 +1227,15 @@ rony-chat-bot/
│ │ ├── runner.go # Wrapper Stream, inyección de RAG en system prompt │ │ ├── runner.go # Wrapper Stream, inyección de RAG en system prompt
│ │ └── client.go # Factory NewClient: llamacpp / ollama / openai / anthropic │ │ └── client.go # Factory NewClient: llamacpp / ollama / openai / anthropic
│ │ │ │
│ ├── portfolio/ # RAG: markdown → SQLite FTS5 │ ├── portfolio/ # RAG: markdown → SQLite FTS5 + vectores
│ │ ├── chunker.go # Heading-based splitter │ │ ├── chunker.go # Heading-based splitter, sub-split en ###
│ │ ├── indexer.go # Store: schema, Reindex, Search (BM25) │ │ ├── indexer.go # Store: schema, Reindex, Search (BM25), Catalog
│ │ └── chunker_test.go / store_test.go │ │ ├── hybrid.go # HybridSearch: BM25 ⊕ vectores vía RRF, EmbedChunks
│ │ └── chunker_test.go / store_test.go / hybrid_test.go
│ │
│ ├── embed/ # Cliente de embeddings
│ │ ├── embed.go # Embed, Normalize, Similarity, Encode/Decode
│ │ └── embed_test.go
│ │ │ │
│ ├── persona/ # Bridge persona → rony-llm-agent │ ├── persona/ # Bridge persona → rony-llm-agent
│ │ └── persona.go # FromConfig, BuildSystemPrompt (con contexto RAG) │ │ └── persona.go # FromConfig, BuildSystemPrompt (con contexto RAG)
@ -1164,10 +1254,15 @@ rony-chat-bot/
│ └── README.md # Guía de integración (HTML, Astro, Next.js) │ └── README.md # Guía de integración (HTML, Astro, Next.js)
├── data/ ├── data/
│ └── projects/ # ← Markdown por proyecto (un .md por proyecto) │ ├── projects/ # ← Un .md/.mdx por proyecto — se lista en el catálogo
│ ├── rony-tui.md │ │ ├── rony-tui.md
│ ├── rony-llm-agent.md │ │ ├── rony-llm-agent.md
│ └── example-project.md │ │ ├── example-project.md
│ │ └── README.md # Instrucciones; el indexer lo salta
│ │
│ └── docs/ # ← Material de referencia que NO es proyecto
│ ├── cv.mdx # Normalmente un símlink; gitignoreado
│ └── README.md # Instrucciones; el indexer lo salta
├── configs/ ├── configs/
│ └── portfolio-bot.yaml # Provider + RAG + persona config │ └── portfolio-bot.yaml # Provider + RAG + persona config
@ -1186,54 +1281,91 @@ rony-chat-bot/
## 📅 11. Roadmap ## 📅 11. Roadmap
### Fase 1: MVP (2-3 semanas) ### Fase 1: MVP — hecho
- [ ] Setup proyecto (`go mod init`, estructura) - [x] Setup proyecto (`go mod init`, estructura)
- [ ] HTTP server básico con un endpoint `/api/chat` - [x] HTTP server con un endpoint `/api/chat`
- [ ] SSE streaming funcional - [x] SSE streaming funcional
- [ ] RAG indexer (lee `data/projects/*.md` → SQLite FTS5) - [x] RAG indexer (`data/projects/` + `data/docs/`, `.md` + `.mdx` FTS5)
- [ ] RAG retriever (query → top-k chunks) - [x] RAG retriever (query → top-k chunks)
- [ ] Persona loader desde YAML - [x] Persona loader desde YAML
- [ ] Integración con llama.cpp (qwen2.5:1.5b GGUF) - [x] Integración con llama.cpp — **qwen2.5-3b**, no el 1.5b planeado
- [ ] CLI: `serve`, `reindex`, `ask` - [x] CLI: `serve`, `reindex`, `ask`
- [ ] Tests básicos - [x] Tests básicos
### Fase 2: Integración con Astro (1 semana) ### Fase 2: Integración con Astro — hecho, de otra forma
- [ ] Astro API route del proxy - [x] Widget drop-in en vanilla JS — **reemplazó** al componente React y a la
- [ ] React component del chat widget ruta proxy de Astro que estaban planeados. Sin build step, sin atarse a
- [ ] E2E test: Astro → chat-bot → respuesta un framework, y funciona en las tres topologías de §5.1 en vez de solo
- [ ] Styling del widget (TailwindCSS) detrás de un proxy
- [x] Styling del widget — CSS scoped con custom properties, **no**
TailwindCSS; un widget drop-in no puede asumir el toolchain del sitio
- [ ] E2E test automatizado: Astro → chat-bot → respuesta (sigue siendo manual, §9.3)
### Fase 3: Polish (1 semana) ### Fase 3: Polish — hecho
- [ ] Rate limiting robusto - [x] Rate limiting por IP
- [ ] Logging estructurado (JSON) - [x] Logging estructurado (JSON)
- [ ] Health checks para monitoring - [x] Health checks para monitoring
- [ ] systemd service file - [x] systemd service files (§8.1)
- [ ] README + docs de deployment - [x] README + docs de deployment
### Fase 4: Opcionales ### Fase 4: Opcionales — casi todo hecho
- [ ] Soporte para múltiples conversaciones (session ID) - [x] Múltiples conversaciones (session ID)
- [ ] Historial de chats persistido - [x] Historial de chats persistido
- [x] Multi-idioma (EN/ES) — detección más un prompt completo en español, §4.4
- [x] Auto-compactación para hilos largos, §4.5
- [ ] Análisis de preguntas frecuentes - [ ] Análisis de preguntas frecuentes
- [ ] Multi-idioma (EN/ES switch)
- [ ] Versión standalone CLI más pulida (`chat-bot ask`) - [ ] Versión standalone CLI más pulida (`chat-bot ask`)
### Fase 5: Calidad de respuesta — hecho
Todo lo de acá salió de medir respuestas reales, no del plan original; cada
punto existe porque algo estaba observablemente mal.
- [x] Recuperación híbrida (BM25 ⊕ embeddings, RRF) — §4.2
- [x] Guarda de content-hash contra vectores obsoletos — §4.2
- [x] Catálogo de proyectos inyectado en cada turno, para frenar los nombres inventados
- [x] Documentos de referencia separados de los proyectos, para que el CV se
pueda buscar sin quedar listado como proyecto — §4.1
- [x] Parámetros de sampling del fabricante cableados desde la config a llama.cpp
- [x] `context_size` bajado de 8192 a 4096 según el uso medido — §4.5
### Conocido y sin arreglar
Anotado para que no se vuelva a reportar como bug nuevo:
- El modelo lee bien las fechas del CV pero hace mal la aritmética sobre ellas
— "Jul 2024 Jun 2026" reportado como tres años.
- A veces atribuye un dato al archivo fuente equivocado.
- *"¿Dónde ha trabajado Victor?"* responde con proyectos en vez de empleadores.
Depende del fraseo: *"¿En qué empresas ha trabajado?"* y *"¿Cuánto tiempo
estuvo en Metrimex?"* responden bien.
--- ---
## 📐 12. Especificaciones de Calidad ## 📐 12. Especificaciones de Calidad
### 12.1 Métricas de rendimiento ### 12.1 Métricas de rendimiento
| Métrica | Target | | Métrica | Target | Medido en el target de 2 cores solo-CPU |
|---|---| |---|---|---|
| TTFT (Time-to-first-token) | <500ms con llama.cpp local | | Latencia de recuperación (top-5, híbrida) | <50ms | **~40ms** 37ms son el round-trip de embeber la consulta; BM25 y el scan de vectores son sub-milisegundo |
| End-to-end (pregunta → respuesta completa) | <3s para respuestas típicas | | Memoria del proceso del bot | <150MB | **~20MB** |
| Memoria en reposo | <150MB | | Huella total (bot + LLM + embebedor) | entra en 8GB con margen | **~4,6GB** (3,64 + 0,91 + 0,02), dejando ~3,4GB para el resto del host |
| RAG indexing speed | ~100 docs/segundo | | Throughput de generación | — | **21,0 tok/s** en estado estable, ~11 tok/s en la primera petición en frío |
| Retrieval latency | <50ms para top-5 | | TTFT (Time-to-first-token) | <500ms | **No se cumple, y no es alcanzable acá.** Dos threads tienen que hacer el prefill de un prompt de ~1100 tokens antes del primer token. El target original asumía una máquina con GPU |
| End-to-end (pregunta → respuesta completa) | <3s | **No se cumple: ~21s** para una respuesta típica. El bot no es el cuello de botella; un modelo 3B sobre 2 cores |
Las dos últimas filas son el costo honesto de la restricción de hardware, y el
widget está construido alrededor de eso: las respuestas se transmiten token a
token, así que el visitante ve texto moviéndose en un par de segundos en vez de
esperar 21s por un bloque. Recuperar cualquiera de los dos targets significa un
modelo más chico, y el benchmark de 20 preguntas en `configs/portfolio-bot.yaml`
mide lo que eso cuesta en precisión — gemma-3-1b promedia 13,8s contra los 21,0s
de qwen, y responde 6 preguntas menos de cada 10 correctamente.
### 12.2 Pruebas requeridas ### 12.2 Pruebas requeridas
@ -1268,7 +1400,9 @@ rony-chat-bot/
- **Ollama API:** https://github.com/ollama/ollama/blob/main/docs/api.md - **Ollama API:** https://github.com/ollama/ollama/blob/main/docs/api.md
- **SQLite FTS5:** https://www.sqlite.org/fts5.html - **SQLite FTS5:** https://www.sqlite.org/fts5.html
- **Go SQLite driver:** https://github.com/mattn/go-sqlite3 (CGO) o https://modernc.org/sqlite (Go puro) - **Go SQLite driver:** https://github.com/mattn/go-sqlite3 (CGO) o https://modernc.org/sqlite (Go puro)
- **qwen2.5:** https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct - **qwen2.5-3b-instruct:** https://huggingface.co/Qwen/Qwen2.5-3B-Instruct
- **nomic-embed-text-v2-moe:** https://huggingface.co/nomic-ai/nomic-embed-text-v2-moe-GGUF
- **Reciprocal Rank Fusion:** Cormack, Clarke & Büttcher (2009), *Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods*
- **Astro API routes:** https://docs.astro.build/en/guides/endpoints/ - **Astro API routes:** https://docs.astro.build/en/guides/endpoints/
- **rony-llm-agent:** https://github.com/VictorVargas/rony-llm-agent - **rony-llm-agent:** https://github.com/VictorVargas/rony-llm-agent

View file

@ -63,19 +63,25 @@ The bot responds with accurate information extracted from the projects' markdown
│ ↓ │ │ ↓ │
│ Agent loop (rony-llm-agent) │ │ Agent loop (rony-llm-agent) │
│ ↓ │ │ ↓ │
│ RAG retrieval → SQLite FTS5 over data/projects/*.md │ │ Hybrid RAG → SQLite FTS5 (BM25) ⊕ vectors, fused with RRF │
│ ↓ over data/projects/ + data/docs/ │
│ ├─→ embeddings server (:9200, nomic-embed-v2-moe) │
│ ↓ │ │ ↓ │
│ LLM (llama.cpp local default / Ollama or Anthropic optional) │ │ LLM (llama.cpp local default / Ollama or Anthropic optional) │
└─────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────┘
``` ```
Two local model servers, not one. Both are plain `llama-server` processes the
bot talks to over HTTP; neither is linked into the binary.
### 2.2 Main components ### 2.2 Main components
| Component | Path | Responsibility | | Component | Path | Responsibility |
|---|---|---| |---|---|---|
| **HTTP server** | `internal/server/` | Gin/chi handlers, SSE streaming | | **HTTP server** | `internal/server/` | chi handlers, SSE streaming |
| **Agent runner** | `internal/agent/` | Wrapper over `rony-llm-agent` with specific config | | **Agent runner** | `internal/agent/` | Wrapper over `rony-llm-agent`: prompt assembly, retrieval, language selection, compaction |
| **Portfolio loader** | `internal/portfolio/` | Reads `data/projects/*.md`, indexes in SQLite FTS5 | | **Portfolio loader** | `internal/portfolio/` | Reads `data/projects/` and `data/docs/` (`.md` + `.mdx`), indexes into SQLite FTS5 + vectors, hybrid search |
| **Embeddings client** | `internal/embed/` | OpenAI-compatible embeddings, normalisation, float32 codec |
| **Persona** | `internal/persona/` | Loads persona from `configs/portfolio-bot.yaml` | | **Persona** | `internal/persona/` | Loads persona from `configs/portfolio-bot.yaml` |
| **CLI** | `cmd/chat-bot/` | Commands: `serve`, `reindex`, `ask`, `version` | | **CLI** | `cmd/chat-bot/` | Commands: `serve`, `reindex`, `ask`, `version` |
@ -87,8 +93,9 @@ The bot responds with accurate information extracted from the projects' markdown
| **HTTP router** | `net/http` + `chi` | Stdlib + chi for middleware (CORS, logging) | | **HTTP router** | `net/http` + `chi` | Stdlib + chi for middleware (CORS, logging) |
| **SSE** | `net/http` Flusher | Stdlib is enough, no external library needed | | **SSE** | `net/http` Flusher | Stdlib is enough, no external library needed |
| **Config** | `gopkg.in/yaml.v3` | Same as harness | | **Config** | `gopkg.in/yaml.v3` | Same as harness |
| **RAG backend** | SQLite + FTS5 (BM25) | Zero external deps, single file, fast | | **RAG backend** | SQLite + FTS5 (BM25) ⊕ dense vectors | Zero external deps, single file. No ANN index: a portfolio is hundreds of chunks, so a full scan is microseconds |
| **LLM** | llama.cpp (qwen2.5:1.5b GGUF) — default; Ollama as alt | Self-hosted by default | | **LLM** | llama.cpp (qwen2.5-3b-instruct Q4_K_M) — default; Ollama as alt | Self-hosted by default. 3B, not 1.5B: see the benchmark in `configs/portfolio-bot.yaml` |
| **Embeddings** | `nomic-embed-v2-moe` Q5_K_M, 768-dim | Multilingual — the whole point is matching Spanish questions to English documents |
| **Tests** | stdlib + testify | Consistency with the rest | | **Tests** | stdlib + testify | Consistency with the rest |
--- ---
@ -279,7 +286,7 @@ dependency hangs.
```json ```json
{ {
"name": "Rony Chat Bot", "name": "Rony Chat Bot",
"model": "qwen2.5:1.5b", "model": "qwen2.5-3b-instruct",
"persona": "...", "persona": "...",
"topics": ["projects", "experience", "technical skills"] "topics": ["projects", "experience", "technical skills"]
} }
@ -466,12 +473,15 @@ of the bot (proxy) that gates the conversation endpoints.
## 🧠 4. RAG (Retrieval-Augmented Generation) ## 🧠 4. RAG (Retrieval-Augmented Generation)
> ⚠️ **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. > - **FTS5 tokenizer**`unicode61 remove_diacritics 2`, as specced. Stemming
> - **Driver SQLite** — ✅ **DECIDIDO: `modernc.org/sqlite`** (puro Go, sin CGO). Ver benchmark abajo. > was not the bottleneck; the language gap was, and embeddings close it.
> - **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. > - **SQLite driver**`modernc.org/sqlite` (pure Go, no CGO). Benchmark below.
> - **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. > - **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 ### 4.0 Driver decision: benchmark results
@ -498,258 +508,217 @@ Justificación:
### 4.1 Indexing pipeline ### 4.1 Indexing pipeline
``` ```
data/projects/*.md data/projects/*.{md,mdx} kind=project → announced in the catalogue
↓ (read all files) data/docs/*.{md,mdx} kind=doc → retrievable, never announced
Raw markdown content ↓ (skip each directory's README — those are instructions, not content)
↓ (split into chunks, ~500 chars, 50 overlap) Raw markdown
Chunks [] ↓ (split by heading; sections over the limit split at ###, then by size)
↓ (insert into SQLite FTS5 virtual table "portfolio_chunks") ↓ (drop the frontmatter chunk)
Chunks
├─→ SQLite FTS5 virtual table "portfolio_chunks"
└─→ embeddings endpoint → "portfolio_vectors" (id, content_hash, dim, vec)
Indexed corpus 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:** **When it runs:**
- On bot startup (if `--reindex-on-start` flag)
- Manually: `./chat-bot reindex` - Manually: `./chat-bot reindex`
- On startup with `serve --reindex-on-start`
- Via HTTP: `POST /api/reindex` - 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 ### 4.2 Retrieval pipeline
``` ```
User query "what projects does Victor have?" User query "¿Con qué se paga en la tienda de ropa?"
↓ (FTS5 MATCH query, BM25 ranking, top_k=5)
Top 5 relevant chunks ├─→ FTS5 MATCH, BM25 ranking → top 15 (topK × 3)
↓ (format as context block) └─→ embed(query) → cosine vs vecs → top 15 (topK × 3)
System prompt += relevant chunks ↓ (Reciprocal Rank Fusion, k=60)
↓ (send to LLM) Top 5 chunks
↓ (system prompt + project catalogue + excerpts + language directive)
LLM generates answer 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 ```go
// internal/portfolio/indexer.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 = ` const schema = `
CREATE VIRTUAL TABLE IF NOT EXISTS portfolio_chunks USING fts5( CREATE VIRTUAL TABLE IF NOT EXISTS portfolio_chunks USING fts5(
id UNINDEXED, id UNINDEXED,
project_id UNINDEXED, project_id UNINDEXED,
kind UNINDEXED, -- 'project' | 'doc'
source_file UNINDEXED, source_file UNINDEXED,
section UNINDEXED, -- the heading this chunk came from
chunk_index UNINDEXED, chunk_index UNINDEXED,
content, content,
tokenize = 'unicode61 remove_diacritics 2' tokenize = 'unicode61 remove_diacritics 2'
); );
` `
func splitIntoChunks(text string, size, overlap int) []string { // Vectors live in an ordinary table keyed by chunk id. There is no ANN index:
// Simple implementation: split by size with overlap // a portfolio is hundreds of chunks, not millions, so a full scan with a dot
// Production version uses tokenizer-aware chunking // product is microseconds and needs no extension.
var chunks []string const vectorSchema = `
for i := 0; i < len(text); i += size - overlap { CREATE TABLE IF NOT EXISTS portfolio_vectors (
end := i + size chunk_id TEXT PRIMARY KEY,
if end > len(text) { content_hash TEXT NOT NULL, -- sha256 of the exact text embedded
end = len(text) dim INTEGER NOT NULL,
} vec BLOB NOT NULL -- little-endian float32, unit-normalised
chunks = append(chunks, text[i:end]) );
} `
return chunks
}
``` ```
### 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 Vectors are unit-normalised at write time, so a dot product **is** the cosine
// internal/portfolio/search.go and retrieval needs no division per comparison. `dim` is stored so a change of
package portfolio embedding model is detected rather than silently producing garbage similarity:
rows whose dimension does not match the query vector are skipped.
type Hit struct { **Key API:**
ProjectID string
SourceFile string
ChunkIndex int
Content string
Score float64 // BM25 score from FTS5
}
func (s *Store) Search(ctx context.Context, query string, topK int) ([]Hit, error) { | Function | File | Purpose |
// Escape user input: FTS5 syntax can break with special chars |---|---|---|
ftsQuery := sanitizeFTS5(query) | `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, ` ### 4.4 Assembling the prompt
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()
var hits []Hit `agent.Runner.BuildMessages` runs once per request and produces exactly one
for rows.Next() { system message followed by the conversational turns. The order of operations
var h Hit is load-bearing:
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, " ")
}
```
```go ```go
// internal/agent/runner.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) { // 2. The visitor's language selects which rendition of the prompt we
basePrompt := r.persona.SystemPrompt // 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) // 3. Resolved before retrieval so the excerpt budget accounts for it.
if err != nil { catalog := r.catalogBlock(ctx)
return "", err
}
if len(hits) == 0 {
return basePrompt, nil
}
var contextBlock strings.Builder // 4. Hybrid retrieval on the last user turn.
contextBlock.WriteString(basePrompt) hits, err := r.store.HybridSearch(ctx, r.embedder, last.Content, r.topK)
contextBlock.WriteString("\n\n## Relevant context\n\n") ragContext = r.limitRAGContext(systemPrompt, catalog, formatHits(hits), history)
for _, h := range hits {
contextBlock.WriteString(fmt.Sprintf("### Source: %s\n%s\n\n",
h.SourceFile, h.Content))
}
return contextBlock.String(), nil
}
func (r *Runner) RunStream(ctx context.Context, messages []llm.Message) iter.Seq2[Chunk, error] { // 5. prompt → catalogue → excerpts → summary → language directive.
return func(yield func(Chunk, error) bool) { system := botpersona.BuildSystemPrompt(systemPrompt, catalog, ragContext)
lastUserMsg := getLastUserMessage(messages) system += "\n\n" + strings.Join(notes, "\n\n")
systemPrompt, err := r.buildSystemPrompt(ctx, lastUserMsg) system += "\n\n" + r.languageDirective(lang)
if err != nil {
yield(Chunk{}, err)
return
}
messages = prependSystem(messages, systemPrompt) history, err = r.fitHistory(system, history)
return append([]Message{{Role: RoleSystem, Content: system}}, history...), ragContext, nil
for chunk, err := range r.loop.RunStream(ctx, messages) {
if !yield(chunk, err) {
return
}
}
}
} }
``` ```
**Why this is simpler than embeddings:** **Why exactly one system message.** Gemma 3's chat template raises
- No embedding model to download or run (saves ~270MB of RAM and ~200ms per query) *"Conversation roles must alternate user/assistant/..."* on any system message
- One file (`data/portfolio.db`), one driver, no extra process after the first, which llama-server surfaces as HTTP 400 — enabling compaction
- BM25 ranking is excellent for keyword-based retrieval over structured docs like project READMEs used to kill the conversation outright the first time it fired. Anthropic's API
- 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. 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 ## 🗜️ 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 23 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 23 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 ### 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) # 2. Start llama-server (port is configurable; default llama.cpp is 8080)
llama-server \ llama-server \
-m $RONY_MODELS_PATH/qwen2.5-3b-instruct-q4_k_m.gguf \ -m $RONY_MODELS_PATH/Qwen2.5/qwen2.5-3b-instruct-q4_k_m.gguf \
--port 9100 \ --port 9100 --host 127.0.0.1 \
--host 127.0.0.1 \ --ctx-size 4096 --parallel 1 \
--ctx-size 4096 \ --device none --threads 2 --mlock \
--mlock # prevents swap, critical on shared VPS --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 # 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 ./bin/chat-bot serve
# → Serves on http://localhost:7331 # → Serves on http://localhost:7331
# → Override with: ./bin/chat-bot serve --port 9101 --host 127.0.0.1 # → 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:** **Port reference:**
| What | Default | How to change | | What | Default | How to change |
|---|---|---| |---|---|---|
| `llama-server` HTTP port | 8080 (llama.cpp convention) | `--port N` flag when starting `llama-server` | | `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 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 | | 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) ### 6.2 Alternative: Ollama (easier for development)
@ -1009,12 +1003,16 @@ providers:
type: llamacpp type: llamacpp
model: qwen2.5-3b-instruct model: qwen2.5-3b-instruct
endpoint: http://localhost:9100/v1 # configurable, see §6.1 endpoint: http://localhost:9100/v1 # configurable, see §6.1
context_size: 4096 context_size: 4096 # must match --ctx-size
max_tokens: 2048 max_tokens: 640
temperature: 0.7 # Qwen's published instruct defaults
top_k: 20
top_p: 0.8
repeat_penalty: 1.05
default: true 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 # Start HTTP server
chat-bot serve [--port 7331] [--host 0.0.0.0] [--reindex-on-start] 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 chat-bot reindex
# Single question (no server, useful for tests) # Single question (no server, useful for tests)
@ -1101,19 +1100,62 @@ func serveCmd() *cobra.Command {
### 8.1 Recommendation: Self-hosted on VPS ### 8.1 Recommendation: Self-hosted on VPS
```bash Target: **2 CPU cores, 8 GB RAM, no GPU.** Three processes — the bot and two
# 1. Install dependencies `llama-server` instances — so three units. The bot depends on both.
sudo apt install golang-go ollama
ollama pull qwen2.5:1.5b
# 2. Build ```bash
# 1. Build
go build -o /usr/local/bin/chat-bot ./cmd/chat-bot go build -o /usr/local/bin/chat-bot ./cmd/chat-bot
# 3. systemd service # 2. The LLM
cat > /etc/systemd/system/llama-chat.service <<EOF
[Unit]
Description=llama-server (chat model)
After=network.target
[Service]
Type=simple
User=chatbot
ExecStart=/usr/local/bin/llama-server \\
-m /opt/models/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
Restart=on-failure
# --mlock needs the memory to be lockable
LimitMEMLOCK=infinity
[Install]
WantedBy=multi-user.target
EOF
# 3. The embedder
cat > /etc/systemd/system/llama-embed.service <<EOF
[Unit]
Description=llama-server (embeddings)
After=network.target
[Service]
Type=simple
User=chatbot
ExecStart=/usr/local/bin/llama-server \\
-m /opt/models/embeddings/nomic-embed-v2-moe.Q5_K_M.gguf \\
--port 9200 --host 127.0.0.1 \\
--embedding --pooling mean \\
--ctx-size 2048 --parallel 1 --device none --threads 2
Restart=on-failure
[Install]
WantedBy=multi-user.target
EOF
# 4. The bot
cat > /etc/systemd/system/chat-bot.service <<EOF cat > /etc/systemd/system/chat-bot.service <<EOF
[Unit] [Unit]
Description=Portfolio Chat Bot Description=Portfolio Chat Bot
After=network.target ollama.service After=network.target llama-chat.service llama-embed.service
Wants=llama-chat.service llama-embed.service
[Service] [Service]
Type=simple Type=simple
@ -1121,15 +1163,27 @@ User=chatbot
WorkingDirectory=/opt/chat-bot WorkingDirectory=/opt/chat-bot
ExecStart=/usr/local/bin/chat-bot serve ExecStart=/usr/local/bin/chat-bot serve
Restart=on-failure Restart=on-failure
Environment=RONY_MODELS_PATH=/opt/models
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
EOF EOF
sudo systemctl enable --now chat-bot sudo systemctl enable --now llama-chat llama-embed chat-bot
# 5. Build the index once both model servers are up
sudo -u chatbot /usr/local/bin/chat-bot reindex
``` ```
**Memory budget.** 3.64 GB (LLM) + 0.91 GB (embedder) + 0.02 GB (bot) ≈
**4.6 GB resident**, leaving ~3.4 GB for whatever else shares the VPS. Budget
with `--device none` in place: without it the numbers look ~1.1 GB smaller on
a machine with a GPU and then do not reproduce in production. See
[`vps-context-sizing.md`](./vps-context-sizing.md).
`reindex` has to be re-run after editing the markdown **and** after enabling
or changing the embeddings model — vectors are built at index time, and a
model change alters the dimension.
### 8.2 Reverse proxy (Caddy) ### 8.2 Reverse proxy (Caddy)
``` ```
@ -1241,6 +1295,28 @@ curl -X POST http://localhost:4321/api/chat \
# 4. Verify SSE stream # 4. Verify SSE stream
``` ```
### 9.4 Retrieval tests
Retrieval is the part of this bot where a regression is silent — nothing
errors, answers just get subtly worse — so each failure found in testing has a
test pinning it. The code samples above are illustrative; these are real.
| Test | What it pins |
|---|---|
| `TestHybridSearchFindsChunkKeywordSearchCannot` | The Spanish-question / English-document gap, the reason embeddings exist |
| `TestHybridSearchFallsBackWhenEmbedderFails` | A dead embeddings endpoint degrades to keyword-only, never fails the request |
| `TestVectorSearchIgnoresVectorsWhoseChunkChanged` | Stale vectors after a body edit are skipped |
| `TestVectorSearchIgnoresMismatchedDimensions` | Changing the embedding model does not produce garbage similarity |
| `TestReindexSkipsTheDirectoryReadmes` | The catalogue never announces `README` as a project |
| `TestReindexIndexesMdxAndSeparatesDocsFromProjects` | `.mdx` is indexed; the CV is retrievable but never listed |
| `TestOpenStoreMigratesIndexWithoutKindColumn` | Upgrading an existing install needs no migration step |
| `TestSubSplitPrefersH3BoundariesOverByteOffsets` | CV jobs stay whole instead of being cut mid-word |
| `TestBuildMessagesFoldsSystemNotesIntoOneSystemMessage` | Compaction cannot re-introduce the HTTP 400 |
| `TestLanguageDirectiveIsLastInSystemPrompt` | The directive keeps the position it needs to work |
They run against real SQLite and a stub embedder, so no model server is
needed: `go test ./...` is enough.
--- ---
## 📂 10. Project Structure ## 📂 10. Project Structure
@ -1262,11 +1338,16 @@ rony-chat-bot/
│ │ ├── runner.go # Stream wrapper, RAG injection into system prompt │ │ ├── runner.go # Stream wrapper, RAG injection into system prompt
│ │ └── client.go # NewClient factory: llamacpp / ollama / openai / anthropic │ │ └── client.go # NewClient factory: llamacpp / ollama / openai / anthropic
│ │ │ │
│ ├── portfolio/ # RAG: markdown → SQLite FTS5 + conversation persistence │ ├── portfolio/ # RAG: markdown → SQLite FTS5 + vectors, conversation persistence
│ │ ├── chunker.go # Heading-based splitter │ │ ├── chunker.go # Heading-based splitter, ### sub-split
│ │ ├── indexer.go # Store: schema, Reindex, Search (BM25) │ │ ├── indexer.go # Store: schema, Reindex, Search (BM25), Catalog
│ │ ├── hybrid.go # HybridSearch: BM25 ⊕ vectors via RRF, EmbedChunks
│ │ ├── conversations.go # Conversation + Message CRUD, persisted alongside RAG │ │ ├── conversations.go # Conversation + Message CRUD, persisted alongside RAG
│ │ └── chunker_test.go / store_test.go │ │ └── chunker_test.go / store_test.go / hybrid_test.go
│ │
│ ├── embed/ # Embeddings client
│ │ ├── embed.go # Embed, Normalize, Similarity, Encode/Decode
│ │ └── embed_test.go
│ │ │ │
│ ├── persona/ # Persona bridge to rony-llm-agent │ ├── persona/ # Persona bridge to rony-llm-agent
│ │ └── persona.go # FromConfig, BuildSystemPrompt (with RAG context) │ │ └── persona.go # FromConfig, BuildSystemPrompt (with RAG context)
@ -1285,10 +1366,15 @@ rony-chat-bot/
│ └── README.md # Integration guide (HTML, Astro, Next.js) │ └── README.md # Integration guide (HTML, Astro, Next.js)
├── data/ ├── data/
│ └── projects/ # ← Markdown per project (one .md per project) │ ├── projects/ # ← One .md/.mdx per project — listed in the catalogue
│ ├── rony-harness.md │ │ ├── rony-harness.md
│ ├── rony-llm-agent.md │ │ ├── rony-llm-agent.md
│ └── example-project.md │ │ ├── example-project.md
│ │ └── README.md # Instructions; skipped by the indexer
│ │
│ └── docs/ # ← Reference material that is NOT a project
│ ├── cv.mdx # Usually a symlink; gitignored
│ └── README.md # Instructions; skipped by the indexer
├── configs/ ├── configs/
│ └── portfolio-bot.yaml # Provider + RAG + persona config │ └── portfolio-bot.yaml # Provider + RAG + persona config
@ -1307,54 +1393,90 @@ rony-chat-bot/
## 📅 11. Roadmap ## 📅 11. Roadmap
### Phase 1: MVP (2-3 weeks) ### Phase 1: MVP — done
- [ ] Project setup (`go mod init`, structure) - [x] Project setup (`go mod init`, structure)
- [ ] Basic HTTP server with `/api/chat` endpoint - [x] HTTP server with `/api/chat` endpoint
- [ ] Functional SSE streaming - [x] Functional SSE streaming
- [ ] RAG indexer (reads `data/projects/*.md` → SQLite FTS5) - [x] RAG indexer (`data/projects/` + `data/docs/`, `.md` + `.mdx` FTS5)
- [ ] RAG retriever (query → top-k chunks) - [x] RAG retriever (query → top-k chunks)
- [ ] Persona loader from YAML - [x] Persona loader from YAML
- [ ] llama.cpp integration (qwen2.5:1.5b GGUF) - [x] llama.cpp integration — **qwen2.5-3b**, not the 1.5b originally planned
- [ ] CLI: `serve`, `reindex`, `ask` - [x] CLI: `serve`, `reindex`, `ask`
- [ ] Basic tests - [x] Basic tests
### Phase 2: Integration with Astro (1 week) ### Phase 2: Integration with Astro — done, differently
- [ ] Astro API route of the proxy - [x] Drop-in vanilla-JS widget — **replaced** the planned React component and
- [ ] React component of the chat widget Astro proxy route. No build step, no framework lock-in, and it works in
- [ ] E2E test: Astro → chat-bot → response all three topologies in §5.1 rather than only behind a proxy
- [ ] Widget styling (TailwindCSS) - [x] Widget styling — scoped CSS with custom properties, **not** TailwindCSS;
a drop-in widget cannot assume the host site's toolchain
- [ ] Automated E2E test: Astro → chat-bot → response (still manual, §9.3)
### Phase 3: Polish (1 week) ### Phase 3: Polish — done
- [ ] Robust rate limiting - [x] Rate limiting per IP
- [ ] Structured logging (JSON) - [x] Structured logging (JSON)
- [ ] Health checks for monitoring - [x] Health checks for monitoring
- [ ] systemd service file - [x] systemd service files (§8.1)
- [ ] README + deployment docs - [x] README + deployment docs
### Phase 4: Optionals ### Phase 4: Optionals — mostly done
- [ ] Support for multiple conversations (session ID) - [x] Multiple conversations (session ID)
- [ ] Persisted chat history - [x] Persisted chat history
- [x] Multi-language (EN/ES) — detection plus a full Spanish prompt, §4.4
- [x] Auto-compaction for long threads, §4.5
- [ ] Analysis of frequent questions - [ ] Analysis of frequent questions
- [ ] Multi-language (EN/ES switch)
- [ ] More polished standalone CLI version (`chat-bot ask`) - [ ] More polished standalone CLI version (`chat-bot ask`)
### Phase 5: Answer quality — done
Everything here came out of measuring real answers rather than from the
original plan; each item exists because something was observably wrong.
- [x] Hybrid retrieval (BM25 ⊕ embeddings, RRF) — §4.2
- [x] Content-hash guard against stale vectors — §4.2
- [x] Project catalogue injected every turn, to stop invented project names
- [x] Reference documents separate from projects, so the CV is retrievable
without being listed as a project — §4.1
- [x] Vendor sampling parameters wired through config to llama.cpp
- [x] `context_size` cut from 8192 to 4096 on measured usage — §4.5
### Known and unfixed
Recorded 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 — "Jul 2024 Jun 2026" reported as three years.
- It occasionally attributes a fact to the wrong source file.
- *"¿Dónde ha trabajado Victor?"* answers with projects rather than employers.
Phrasing-specific: *"¿En qué empresas ha trabajado?"* and *"¿Cuánto tiempo
estuvo en Metrimex?"* both answer correctly.
--- ---
## 📐 12. Quality Specifications ## 📐 12. Quality Specifications
### 12.1 Performance metrics ### 12.1 Performance metrics
| Metric | Target | | Metric | Target | Measured on the 2-core CPU-only target |
|---|---| |---|---|---|
| TTFT (Time-to-first-token) | <500ms with llama.cpp local | | Retrieval latency (top-5, hybrid) | <50ms | **~40ms** 37ms of it is the query embedding round-trip; BM25 and the vector scan are sub-millisecond |
| End-to-end (question → complete response) | <3s for typical responses | | Bot process memory | <150MB | **~20MB** |
| Memory at rest | <150MB | | Total footprint (bot + LLM + embedder) | fits in 8GB with room to spare | **~4.6GB** (3.64 + 0.91 + 0.02), leaving ~3.4GB for the rest of the host |
| RAG indexing speed | ~100 docs/second | | Generation throughput | — | **21.0 tok/s** steady state, ~11 tok/s on the first cold request |
| Retrieval latency | <50ms for top-5 | | TTFT (Time-to-first-token) | <500ms | **Not met, and not reachable here.** Two threads have to prefill a ~1100-token prompt before the first token. The original target assumed a machine with a GPU |
| End-to-end (question → complete response) | <3s | **Not met: ~21s** for a typical answer. The bot is not the bottleneck; a 3B model on 2 cores is |
The last two rows are the honest cost of the hardware constraint, and the
widget is built around it: responses stream token by token, so the visitor
sees text moving within a couple of seconds rather than waiting 21s for a
block. Buying either target back means a smaller model, and the 20-question
benchmark in `configs/portfolio-bot.yaml` measures what that costs in
accuracy — gemma-3-1b averages 13.8s against qwen's 21.0s, and answers 6 fewer
of every 10 questions correctly.
### 12.2 Required tests ### 12.2 Required tests
@ -1389,7 +1511,9 @@ rony-chat-bot/
- **Ollama API:** https://github.com/ollama/ollama/blob/main/docs/api.md - **Ollama API:** https://github.com/ollama/ollama/blob/main/docs/api.md
- **SQLite FTS5:** https://www.sqlite.org/fts5.html - **SQLite FTS5:** https://www.sqlite.org/fts5.html
- **Go SQLite driver:** https://github.com/mattn/go-sqlite3 (CGO) or https://modernc.org/sqlite (pure Go) - **Go SQLite driver:** https://github.com/mattn/go-sqlite3 (CGO) or https://modernc.org/sqlite (pure Go)
- **qwen2.5:** https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct - **qwen2.5-3b-instruct:** https://huggingface.co/Qwen/Qwen2.5-3B-Instruct
- **nomic-embed-text-v2-moe:** https://huggingface.co/nomic-ai/nomic-embed-text-v2-moe-GGUF
- **Reciprocal Rank Fusion:** Cormack, Clarke & Büttcher (2009), *Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods*
- **Astro API routes:** https://docs.astro.build/en/guides/endpoints/ - **Astro API routes:** https://docs.astro.build/en/guides/endpoints/
- **rony-llm-agent:** https://github.com/VictorVargas/rony-llm-agent - **rony-llm-agent:** https://github.com/VictorVargas/rony-llm-agent