rony-chat-bot/cmd/chat-bot/main.go
Victor Hugo Vargas 129809067b feat(rag): hybrid retrieval, reference documents, and vendor sampling
Answers were short, sometimes in the wrong language, and occasionally about
projects that do not exist. Measured on a 20-question battery against the real
corpus in both Spanish and English, this takes grounded content from 3/10 to
9/10 and language matching from 7/10 to 10/10.

Retrieval
- Fuse FTS5 keyword search with dense vectors via Reciprocal Rank Fusion.
  Both halves are load-bearing: the corpus is English and visitors ask in
  Spanish, so the meaningful words score zero. "paga" appears 0 times in a
  document that says "Payments: Stripe" — the question "¿Con qué se paga en la
  tienda de ropa?" retrieved nothing at all. Embeddings put all three of that
  project's chunks on top. RRF ranks by agreement rather than comparing a BM25
  score against a cosine, quantities with no shared scale.
- internal/embed: OpenAI-compatible embeddings client, unit-normalised so a
  dot product is the cosine. Reorders by the response `index` field.
- Store a content hash beside each vector and skip rows where it no longer
  matches the chunk. Chunk ids survive body edits, so without this an edited
  document keeps serving embeddings that describe text that is gone —
  reproduced live by changing a payment provider and watching the old one keep
  coming back.
- Degrade to keyword-only when the embedder is down instead of failing.

Reference documents that are not projects
- Index `.mdx` alongside `.md`, and split sources into projects (announced in
  the catalogue) and reference material (retrievable, never listed). A CV is
  what someone deciding whether to hire actually reads, and it was unreachable
  while it lived only in the Astro site — but filing it under projects made
  the bot list "cv" as one of Victor's works.
- Skip each directory's README. `data/projects/README.md` was being indexed,
  so the catalogue injected into every prompt announced "README" and
  "README.es" as projects of Victor's.
- Exclude frontmatter from retrieval. It is dense metadata in a very short
  chunk, which makes it a magnet for short queries: a CV's `location:` field
  answered "¿Dónde ha trabajado Victor?" with a city instead of a work history.
- Split oversized sections at `###` before falling back to byte offsets. A CV's
  Experience section is a list of jobs, and size-splitting cut one mid-word,
  stranding the employer's name in the previous chunk.

Prompt and sampling
- Inject the full project catalogue every turn. Top-K search returns the best
  matching sections, so "list every project" cannot be answered from retrieval
  alone, and a small model asked to enumerate from partial hits invents the
  rest. ~10 tokens per project; this is what stopped the invented names.
- Wire the sampling parameters the model authors publish (top_k, top_p, min_p,
  repeat_penalty, presence_penalty) through config to llama.cpp. Leaving them
  at llama.cpp's defaults produced 16-token stub answers.
- Localised system prompt selected by detected language. The English prompt
  plus "reply in the user's language" answered 1/5 Spanish questions in
  Spanish; few-shot examples fixed the language but got copied verbatim into
  real answers.
- Fold compaction's system notes into the leading system message. Gemma's chat
  template rejects a system message that is not first, and the whole request
  failed with HTTP 400 the moment compaction fired.

Configuration and docs
- context_size 4096, down from 8192. The largest prompt this bot ever built
  over 20 real requests was 1255 tokens, compaction starts at ~3070, and the
  cut saved 212 MB resident with zero truncations and identical throughput.
- Correct the RAM figures throughout. They were measured with a GPU absorbing
  llama.cpp's buffers; on a GPU-less VPS those come out of system RAM, which
  is 1.1 GB more for qwen2.5-3b and 2.8 GB more for granite. Both READMEs
  still started gemma-3-1b while the config defaulted to qwen, and neither
  started the embedder at all.

Measured on the 2-core, 8 GB CPU-only target: 3.64 GB LLM + 0.91 GB embedder
+ 0.02 GB bot, 21.0 tok/s steady state.

Known and unfixed, so they are not re-filed as new bugs: the model reads dates
out of the CV correctly but does the arithmetic on them wrong, and "¿Dónde ha
trabajado Victor?" still answers with projects rather than employers, though
"¿En qué empresas ha trabajado?" works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:45:36 -07:00

326 lines
8.5 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/spf13/cobra"
"github.com/VictorVargas/rony-chat-bot/internal/agent"
"github.com/VictorVargas/rony-chat-bot/internal/config"
"github.com/VictorVargas/rony-chat-bot/internal/embed"
"github.com/VictorVargas/rony-chat-bot/internal/persona"
"github.com/VictorVargas/rony-chat-bot/internal/portfolio"
"github.com/VictorVargas/rony-chat-bot/internal/server"
)
const version = "0.2.0-dev"
var (
cfgFile string
reindexOn bool
askNoStrm bool
)
func main() {
root := &cobra.Command{
Use: "chat-bot",
Short: "Portfolio chatbot HTTP server (rony-llm-agent + SQLite FTS5)",
Long: "Rony Chat Bot — HTTP server that answers questions about your portfolio using a local LLM and SQLite FTS5 RAG.",
}
root.PersistentFlags().StringVar(&cfgFile, "config", "configs/portfolio-bot.yaml", "Path to YAML config")
root.AddCommand(serveCmd())
root.AddCommand(reindexCmd())
root.AddCommand(askCmd())
root.AddCommand(configCmd())
root.AddCommand(healthCmd())
root.AddCommand(versionCmd())
if err := root.Execute(); err != nil {
os.Exit(1)
}
}
func loadConfig() (*config.Config, error) {
return config.Load(cfgFile)
}
func setupLogging(c *config.Config) {
level := slog.LevelInfo
switch strings.ToLower(c.Logging.Level) {
case "debug":
level = slog.LevelDebug
case "warn":
level = slog.LevelWarn
case "error":
level = slog.LevelError
}
opts := &slog.HandlerOptions{Level: level}
var h slog.Handler
if strings.ToLower(c.Logging.Format) == "text" {
h = slog.NewTextHandler(os.Stderr, opts)
} else {
h = slog.NewJSONHandler(os.Stderr, opts)
}
slog.SetDefault(slog.New(h))
}
func serveCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "serve",
Short: "Start the HTTP server",
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
setupLogging(cfg)
slog.Info("starting chat-bot",
"version", version,
"provider", cfg.DefaultProvider().Name,
"rag_enabled", cfg.RAG.Enabled,
"addr", cfg.Addr(),
)
if reindexOn {
if err := runReindex(cfg); err != nil {
slog.Error("reindex-on-start failed", "err", err)
}
}
store, err := portfolio.OpenStore(cfg.RAG.DBPath)
if err != nil {
return fmt.Errorf("open rag store: %w", err)
}
defer store.Close()
provider := cfg.DefaultProvider()
client, err := agent.NewClient(*provider)
if err != nil {
return fmt.Errorf("init provider %s: %w", provider.Name, err)
}
slog.Info("provider ready", "name", client.Name(), "type", provider.Type, "model", provider.Model)
p, err := persona.FromConfig(cfg)
if err != nil {
return err
}
runner := agent.New(client, p, cfg.SystemPrompt, store, cfg.RAG.TopK).
WithCatalog(cfg.RAG.IncludeCatalog).
WithLocalizedPrompt("es", cfg.SystemPromptES).
WithEmbedder(newEmbedder(cfg)).
WithCompaction(agent.CompactionConfig{
Enabled: cfg.Compaction.Enabled,
ThresholdRatio: cfg.Compaction.ThresholdRatio,
KeepRecentTurns: cfg.Compaction.KeepRecentTurns,
SummarySystemPrompt: cfg.Compaction.SummarySystemPrompt,
})
h := server.NewHandlers(cfg, runner, store, version).WithEmbedder(newEmbedder(cfg))
srv := server.New(cfg, h)
ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
errCh := make(chan error, 1)
go func() { errCh <- srv.Start() }()
select {
case <-ctx.Done():
slog.Info("shutdown signal received")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
case err := <-errCh:
return err
}
},
}
cmd.Flags().BoolVar(&reindexOn, "reindex-on-start", false, "Re-index RAG before serving")
return cmd
}
func reindexCmd() *cobra.Command {
return &cobra.Command{
Use: "reindex",
Short: "Rebuild the SQLite FTS5 index from data/projects/",
RunE: func(_ *cobra.Command, _ []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
setupLogging(cfg)
return runReindex(cfg)
},
}
}
// newEmbedder returns the embeddings client, or nil when the feature is off.
// Nil is a supported value everywhere downstream: retrieval falls back to
// keyword-only rather than failing.
func newEmbedder(cfg *config.Config) portfolio.Embedder {
if !cfg.Embeddings.Enabled || cfg.Embeddings.Endpoint == "" {
return nil
}
return embed.New(embed.Config{
BaseURL: cfg.Embeddings.Endpoint,
Model: cfg.Embeddings.Model,
BatchSize: cfg.Embeddings.BatchSize,
TimeoutMS: cfg.Embeddings.TimeoutMS,
})
}
func runReindex(cfg *config.Config) error {
dur, files, chunks, err := portfolio.ReindexOnDisk(cfg.RAG.DBPath, portfolio.SourcesFor(cfg.RAG.DataPath, cfg.RAG.DocsPath), portfolio.DefaultChunkerConfig(), newEmbedder(cfg))
if err != nil {
return err
}
slog.Info("reindex complete",
"files", files,
"chunks", chunks,
"duration_ms", dur.Milliseconds(),
"db", cfg.RAG.DBPath,
)
fmt.Printf("Indexed %d files → %d chunks in %s (%dms)\n", files, chunks, cfg.RAG.DBPath, dur.Milliseconds())
return nil
}
func askCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "ask <question>",
Short: "Ask a single question (no HTTP server, useful for smoke tests)",
Args: cobra.MinimumNArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
setupLogging(cfg)
question := strings.Join(args, " ")
return runAsk(cfg, question, askNoStrm)
},
}
cmd.Flags().BoolVar(&askNoStrm, "no-stream", false, "Disable streaming output")
return cmd
}
func runAsk(cfg *config.Config, question string, noStream bool) error {
provider := cfg.DefaultProvider()
fmt.Fprintf(os.Stderr, "[%s via %s] %s\n", provider.Name, provider.Type, version)
store, err := portfolio.OpenStore(cfg.RAG.DBPath)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: rag store unavailable (%v); answering without RAG\n", err)
}
defer func() {
if store != nil {
_ = store.Close()
}
}()
client, err := agent.NewClient(*provider)
if err != nil {
return err
}
p, err := persona.FromConfig(cfg)
if err != nil {
return err
}
runner := agent.New(client, p, cfg.SystemPrompt, store, cfg.RAG.TopK).
WithCatalog(cfg.RAG.IncludeCatalog).
WithLocalizedPrompt("es", cfg.SystemPromptES).
WithEmbedder(newEmbedder(cfg)).
WithCompaction(agent.CompactionConfig{
Enabled: cfg.Compaction.Enabled,
ThresholdRatio: cfg.Compaction.ThresholdRatio,
KeepRecentTurns: cfg.Compaction.KeepRecentTurns,
SummarySystemPrompt: cfg.Compaction.SummarySystemPrompt,
})
history := []agent.Message{{Role: agent.RoleUser, Content: question}}
if noStream {
var full strings.Builder
for chunk, err := range runner.Stream(context.Background(), history) {
if err != nil {
return err
}
full.WriteString(chunk.Delta)
}
fmt.Println(full.String())
return nil
}
for chunk, err := range runner.Stream(context.Background(), history) {
if err != nil {
return err
}
fmt.Print(chunk.Delta)
}
fmt.Println()
return nil
}
func configCmd() *cobra.Command {
cmd := &cobra.Command{Use: "config", Short: "Config utilities"}
cmd.AddCommand(&cobra.Command{
Use: "validate",
Short: "Validate the YAML config file",
RunE: func(_ *cobra.Command, _ []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
fmt.Printf("OK — server=%s, provider=%s (%s), rag=%v\n",
cfg.Addr(), cfg.DefaultProvider().Name, cfg.DefaultProvider().Type, cfg.RAG.Enabled)
return nil
},
})
return cmd
}
func healthCmd() *cobra.Command {
return &cobra.Command{
Use: "health",
Short: "Hit /api/health on a running server",
RunE: func(_ *cobra.Command, _ []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
url := fmt.Sprintf("http://%s/api/health", cfg.Addr())
resp, err := http.Get(url)
if err != nil {
return fmt.Errorf("connect %s: %w", url, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return fmt.Errorf("health %d: %s", resp.StatusCode, string(body))
}
fmt.Println(string(body))
return nil
},
}
}
func versionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print version",
Run: func(_ *cobra.Command, _ []string) {
out := map[string]string{"version": version}
_ = json.NewEncoder(os.Stdout).Encode(out)
},
}
}