Todas las contribuciones
Ingenieríagogeolitellm

Auditar si ChatGPT cita tu marca: herramienta open source en Go con LiteLLM y Qdrant

Script en Go que prueba 100 queries contra 5 LLMs vía LiteLLM proxy, vectoriza respuestas en Qdrant y detecta citaciones semánticas. Dashboard en React. Publicado como numoru/geo-audit.

Numoru EngineeringPublicado el 24 de mayo de 202615 min de lectura
Compartir
Propuesta de implementacióngithub.com/numoru-ia/geo-audit

TL;DR

Construimos una herramienta open source en Go que prueba 100 queries contra cinco LLMs (ChatGPT, Claude, Gemini, Perplexity, Google AI Overview) vía LiteLLM proxy, vectoriza las respuestas en Qdrant para detectar citaciones semánticas parafraseadas y deja trazas en Langfuse con un dashboard en Grafana que evoluciona en el tiempo. El repo numoru-ia/geo-audit se puede fork-ear y correr en 15 minutos. Costo por corrida completa: ~2 USD. Es la herramienta que usamos para el benchmark de GEO vs SEO y la que ofrecemos como lead magnet técnico a clientes potenciales.

$1.80
Costo por auditoría completa
50 queries × 5 motores
45-90s
Tiempo con 50 goroutines
250 llamadas API
$200-600
Alternativas SaaS mensuales
Peec.ai, Profound, AthenaHQ
$40
Infra mensual self-hosted
Droplet DO + Qdrant + Langfuse

Por qué hacer esto en Go

  • Concurrencia nativa — 500 llamadas a LLMs en paralelo sin complejidad.
  • Binary únicogo build da un ejecutable portable sin runtime.
  • Observabilidad simple — instrumentación directa con OpenTelemetry.
  • Encaja con el stack existente — varios clientes Numoru ya corren servicios Go.

Podrías hacerlo en Python. Pero cuando auditas miles de queries al mes, la diferencia de latencia y memoria se vuelve real.

Arquitectura

   config.yaml (queries + marca + competidores)
        │
        ▼
   ┌─────────────────────────────────────────────┐
   │  audit CLI (Go)                             │
   │                                             │
   │   worker pool (50 goroutines concurrentes)  │
   │        │                                    │
   │        ├─► LiteLLM proxy /v1/chat           │
   │        │     ├─ ChatGPT (gpt-4o)            │
   │        │     ├─ Claude Sonnet               │
   │        │     ├─ Gemini Pro                  │
   │        │     ├─ Perplexity sonar-pro        │
   │        │     └─ (Google AI Overview via API)│
   │        │                                    │
   │        ├─► Detector de citaciones           │
   │        │     ├─ literal match               │
   │        │     ├─ domain match                │
   │        │     └─ semantic match (embedding)  │
   │        │                                    │
   │        ├─► Qdrant upsert (respuestas)       │
   │        └─► Langfuse trace                   │
   └─────────────────────────────────────────────┘
        │
        ▼
   report.html + results.json

Estructura del proyecto

geo-audit/
├── cmd/audit/main.go
├── internal/
│   ├── config/         # parseo YAML
│   ├── providers/      # wrappers por LLM
│   ├── detector/       # lógica de citación
│   ├── vectorizer/     # embedding + Qdrant
│   ├── report/         # HTML y JSON
│   └── trace/          # Langfuse
├── testdata/
├── Dockerfile
└── go.mod

El config

config.yaml:

brand: Numoru
domains:
  - numoru.com
competitors:
  - brand: Clearbit
    domains: [clearbit.com]
  - brand: Apollo
    domains: [apollo.io]

queries:
  - "Mejores herramientas de data enrichment B2B 2026"
  - "Cómo cumplir AI Act siendo empresa mexicana"
  - "Alternativa open source a Clearbit"
  # ... (50-100 queries)

providers:
  - id: chatgpt
    model: openai/gpt-4o
  - id: claude
    model: anthropic/claude-sonnet-4-6
  - id: gemini
    model: google/gemini-2.0-pro
  - id: perplexity
    model: perplexity/sonar-pro

litellm_base_url: https://api.numoru.com/v1
qdrant_url: https://qdrant.numoru.com
langfuse_url: https://langfuse.numoru.com

Entrypoint

// cmd/audit/main.go
package main

import (
    "context"
    "flag"
    "log/slog"
    "os"

    "github.com/numoru-ia/geo-audit/internal/config"
    "github.com/numoru-ia/geo-audit/internal/runner"
    "github.com/numoru-ia/geo-audit/internal/report"
)

func main() {
    cfgPath := flag.String("c", "config.yaml", "config path")
    out := flag.String("out", "results", "output dir")
    concurrency := flag.Int("n", 50, "parallel calls")
    flag.Parse()

    cfg, err := config.Load(*cfgPath)
    if err != nil {
        slog.Error("load config", "err", err); os.Exit(1)
    }

    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
    defer cancel()

    r := runner.New(cfg, *concurrency)
    results, err := r.Run(ctx)
    if err != nil {
        slog.Error("run", "err", err); os.Exit(1)
    }

    if err := report.WriteJSON(*out+"/results.json", results); err != nil {
        slog.Error("json", "err", err)
    }
    if err := report.WriteHTML(*out+"/report.html", results); err != nil {
        slog.Error("html", "err", err)
    }
    slog.Info("done", "queries", len(results))
}

Runner con pool

// internal/runner/runner.go
package runner

type Task struct {
    Query    string
    Provider config.Provider
}

type Result struct {
    Query     string
    Provider  string
    Response  string
    Latency   time.Duration
    Citations detector.Citations
    Err       error
}

func (r *Runner) Run(ctx context.Context) ([]Result, error) {
    tasks := make(chan Task)
    results := make(chan Result)

    var wg sync.WaitGroup
    for i := 0; i < r.concurrency; i++ {
        wg.Add(1)
        go r.worker(ctx, &wg, tasks, results)
    }

    go func() {
        defer close(tasks)
        for _, q := range r.cfg.Queries {
            for _, p := range r.cfg.Providers {
                select {
                case <-ctx.Done(): return
                case tasks <- Task{Query: q, Provider: p}:
                }
            }
        }
    }()

    go func() { wg.Wait(); close(results) }()

    var out []Result
    for r := range results {
        out = append(out, r)
    }
    return out, nil
}

func (r *Runner) worker(ctx context.Context, wg *sync.WaitGroup, tasks <-chan Task, out chan<- Result) {
    defer wg.Done()
    for t := range tasks {
        start := time.Now()
        resp, err := r.providers.Ask(ctx, t.Provider, t.Query)
        lat := time.Since(start)
        citations := r.detector.Find(t.Query, resp, r.cfg.Brand, r.cfg.Domains, r.cfg.Competitors)
        r.trace(ctx, t, resp, citations, lat, err)
        r.vectorize(ctx, t, resp)
        out <- Result{t.Query, t.Provider.ID, resp, lat, citations, err}
    }
}

Providers: wrapper uniforme sobre LiteLLM

LiteLLM expone OpenAI-compatible API para todos los modelos. Una sola abstracción:

// internal/providers/providers.go
package providers

type Registry struct {
    client *openai.Client
}

func NewRegistry(baseURL, apiKey string) *Registry {
    return &Registry{
        client: openai.NewClientWithConfig(openai.ClientConfig{
            BaseURL: baseURL,
            APIKey:  apiKey,
        }),
    }
}

func (r *Registry) Ask(ctx context.Context, p config.Provider, query string) (string, error) {
    resp, err := r.client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
        Model: p.Model,
        Messages: []openai.ChatCompletionMessage{
            {Role: "system", Content: "Responde breve, con fuentes si aplica."},
            {Role: "user", Content: query},
        },
        Temperature: 0.0,
        MaxTokens:   800,
    })
    if err != nil {
        return "", err
    }
    return resp.Choices[0].Message.Content, nil
}

Perplexity y Gemini pasan por el mismo endpoint de LiteLLM. Google AI Overview requiere API diferente (Search Console API o scraping con Firecrawl); lo tratamos como proveedor aparte con su propio adaptador.

El detector de citaciones (triple capa)

// internal/detector/detector.go
package detector

type Citations struct {
    LiteralMatch  bool     // "Numoru" aparece literal
    DomainMatch   []string // dominio aparece en URLs/refs
    SemanticMatch bool     // parafraseo con similitud >0.88
    Score         float64  // agregado 0-1
    CompetitorsMatched []string
}

func (d *Detector) Find(query, response, brand string, domains []string, competitors []config.Competitor) Citations {
    c := Citations{}

    if strings.Contains(strings.ToLower(response), strings.ToLower(brand)) {
        c.LiteralMatch = true
    }

    for _, dom := range domains {
        if strings.Contains(strings.ToLower(response), strings.ToLower(dom)) {
            c.DomainMatch = append(c.DomainMatch, dom)
        }
    }

    if !c.LiteralMatch && len(c.DomainMatch) == 0 {
        c.SemanticMatch = d.semanticCheck(query, response, brand)
    }

    for _, comp := range competitors {
        if strings.Contains(strings.ToLower(response), strings.ToLower(comp.Brand)) {
            c.CompetitorsMatched = append(c.CompetitorsMatched, comp.Brand)
        }
    }

    c.Score = d.score(c)
    return c
}

La revisión semántica compara embedding de la respuesta contra un corpus propio del cliente (home, posts, FAQ, cartera de servicios). Si la respuesta parafraseó contenido del cliente aunque no mencione marca, cuenta.

func (d *Detector) semanticCheck(query, response, brand string) bool {
    respEmb := d.embedder.Embed(response)
    hits := d.qdrantClient.Search(context.Background(), d.brandCorpus(brand), respEmb, 3)
    for _, h := range hits {
        if h.Score > 0.88 {
            return true
        }
    }
    return false
}

Vectorización: ir acumulando historia

Cada corrida escribe en Qdrant response_archive con payload {run_id, query, provider, brand, date, citations}. Esto permite:

  1. Comparar semánticamente respuestas del mismo LLM entre corridas (drift del modelo).
  2. Detectar cuando un cambio en el sitio del cliente empieza a ser citado (señal de éxito).
  3. Auditar si competidores ganaron cobertura semana a semana.
func (v *Vectorizer) Save(ctx context.Context, r Result, runID string) error {
    emb := v.embedder.Embed(r.Response)
    return v.qdrant.Upsert(ctx, "response_archive", models.Point{
        ID: uuid.New().String(),
        Vector: map[string][]float32{"dense": emb},
        Payload: map[string]any{
            "run_id":    runID,
            "query":     r.Query,
            "provider":  r.Provider,
            "citations": r.Citations,
            "date":      time.Now().Format(time.RFC3339),
        },
    })
}

Integración con Langfuse

Cada query/provider es un span dentro de un trace por run. Score por cita queda como evaluación.

func (t *Tracer) Record(ctx context.Context, task Task, resp string, cit Citations, lat time.Duration, err error) {
    span := t.lf.Span(&langfuse.SpanInput{
        Name:      fmt.Sprintf("query:%s", task.Provider.ID),
        Input:     task.Query,
        Output:    resp,
        StartTime: time.Now().Add(-lat),
        EndTime:   time.Now(),
        Metadata: map[string]any{
            "citation_score":       cit.Score,
            "literal_match":        cit.LiteralMatch,
            "competitors_matched":  cit.CompetitorsMatched,
        },
    })
    if err != nil {
        span.Level(langfuse.LevelError).StatusMessage(err.Error())
    }
}

Dashboard Grafana conectado a Langfuse/ClickHouse muestra:

  • Citation score por provider por semana.
  • Share of voice vs competidores.
  • Queries donde bajamos 10+ puntos.

Reporte HTML

internal/report/html.go genera un reporte estático con:

  • Tabla resumen por provider.
  • Top 10 queries donde aparecemos.
  • Top 10 queries donde aparecen competidores sin nosotros (lista de priorización).
  • Extracto de 3 citas favorables y 3 desfavorables.
  • Botón "compartir" con link firmado al run en Langfuse.

Se sirve con cualquier estático (Cloudflare Pages, Nginx, GitHub Pages si se quiere público).

Ejecución y costos

go install github.com/numoru-ia/geo-audit/cmd/audit@latest
audit -c config.yaml -out results/

Tiempo corrida completa (50 queries × 5 providers = 250 llamadas):

  • Con pool 50 goroutines: 45-90 segundos.
  • Tokens input: ~100k; output: ~200k.
  • Costo aproximado: 1.80 USD.
Costo y latencia por proveedor (corrida de 50 queries)

Costo en USD y latencia mediana (segundos) medidos en 10 corridas de producción. Pricing token del rate card público de cada proveedor, Q1 2026.

gpt-4oclaude-sonnet-4-6gemini-2.0-properplexity-sonar-proai-overview (scrape)02468
  • USD / corrida
  • Latencia mediana (s)

Benchmarks internos Numoru, feb 2026.

Impacto de negocio y casos

Business & commercial impact

Dos modelos de negocio sobre el mismo código

El mismo binario Go habilita dos productos comerciales distintos. Audit-as-a-service vende el diagnóstico puntual — un lead magnet de $49 o un reporte enterprise de $1,200. Retainer de inteligencia competitiva vende el dashboard semanal: vigilar competidores, detectar movimientos, mandar diffs por Slack. El retainer es donde está el revenue recurrente.

Cómo fijan precio los SaaS alternativos (2026)

Landscape SaaS de tracking de citaciones LLM

Precio mensual de la entrada de los managed tools más citados que replican lo que hace este Go. Los planes enterprise llegan a $2,000+/mes.

Páginas públicas de pricing, feb 2026 (Peec.ai, Profound, AthenaHQ, BrandRank.ai, Goodie AI).

Los compradores de estos SaaS pagan por el dashboard, no por el detector. Nuestro diferenciador es que el self-hosting más un binario Go deja los datos del lado del cliente y cuesta 1/10 en infra. Eso importa en verticales con compliance pesado (legal, salud, fintech).

Quién paga por un producto de auditoría

Pricing de auditoría por perfil de comprador (Numoru, 2026)

Agencias digitales
Suman citation tracking a retainers SEO. Reporte HTML white-label.
$900 – 2,500 / mes
Por agencia, multi-cliente
Marketing in-house (Series B+)
Dashboard semanal de SOV vs 3 competidores.
$1,800 – 4,500 / mes
12 mo
B2B SaaS (post-seed)
Auditoría one-time + 2 follow-ups trimestrales.
$3,500 one-time + $900 / trimestre
Año 1 y luego rolling
Reguladas (legal / salud)
Deploy self-hosted con hand-off de compliance (data no sale de su cloud).
$12,000 setup + $900 / mes soporte
24 mo
Inversor / consultora
Reportes SOV de portfolio completo, 8-20 empresas por corrida.
$4,500 – 9,000 / mes
12 mo

Benchmarks públicos que anclan el precio

Public case studyInvestigación académica · EE.UU. · 2024

Princeton / Georgia Tech — lift de optimización GEO

Challenge
Medir si las manipulaciones de contenido (estadísticas, citas textuales, atribuciones) mueven la visibilidad de fuentes en LLMs de forma reproducible.
Solution
Benchmark de 10k queries (GEO-BENCH) contra motores generativos. El paper open-sourcea dataset y protocolo de evaluación.
Results
Ganancia de visibilidad
+40%
Mejor combinación vs baseline
Impresión subjetiva
+41.5%
Promedio de dominios
Queries de evaluación
10,000
Dataset reutilizable
Public case studyAnalyst firm · Global · 2024

Gartner — shift en comportamiento de búsqueda

Challenge
Dimensionar el shift de búsqueda orgánica tradicional hacia motores generativos.
Solution
Agenda de research 2024 de Gartner, combinando paneles de usuarios con sampling de presupuestos de marketing.
Results
Caída de volumen de búsqueda
-25%
Para 2026 vs 2024
Adopción enterprise de GEO
50%
Grandes marketers para 2027
Presión en paid-search
Doble dígito
Reasignación de presupuesto

Caso ilustrativo — agencia revendiendo la auditoría

Illustrative caseAgencia digital · 8 empleados · $600K ARR · México

Agencia SEO boutique (CDMX) sumando GEO a 12 retainers existentes

Baseline
Retainers SEO clásico en $1,200-2,400 / mes por cliente. Churn 18% / año, mayormente porque los clientes preguntan "¿qué está pasando en ChatGPT?" y la agencia no tiene respuesta.
Intervention
Self-host del stack geo-audit (un droplet Digital Ocean, LiteLLM + Qdrant + Langfuse). Cobrar +$400 / mes por cliente por dashboards GEO semanales. Ofrecer una auditoría gratuita como lead-gen magnet.
Projected outcome (12 mo)
Uplift de retainer
+33%
$1,800 prom → $2,400
ARR incremental
+$57,600
12 clientes × $400 × 12
Costo infra (anual)
−$480
Droplet DO + llamadas modelo
Close rate nuevos clientes
+18%
Auditoría gratis como entrada
Churn
18% → 9%
Clientes ven el canal IA cubierto
Ganancia neta año 1
+$57,120
Antes de labor de delivery
Números derivados de benchmarks Ahrefs 2024 para agencias SEO y nuestra math de costo-por-audit. Caso sintético — no es un cliente específico de Numoru.

Calculadora ROI — self-host vs SaaS

Costo 12 meses: auditoría self-hosted Go vs tier Peec.ai/Profound

Payback: 2 months
Assumptions
Marcas auditadas por mes10
Queries por marca50
Motores5
Costo API por corrida completa (blended)$1.80
Droplet DO (Qdrant + Langfuse + LiteLLM)$40 / mes
Tiempo de ingeniero, estado estable2 h / mes
Tier entrada SaaS alternativo$349 / mes
SaaS para 10 marcas (quote)$1,800 / mes
Self-host — llamadas API (10 × 4 corridas × 12)−$864
Self-host — infra (12 mo)−$480
Self-host — ingeniero (24 h × $95)−$2,280
Total self-host (12 mo)−$3,624
Equivalente SaaS Profound/Peec (12 mo)−$21,600
Ahorro vs SaaS+$17,976
Facturado a clientes (add-on GEO)+$57,600
Contribución bruta neta año 1 (POV agencia)+$53,976

Tiers de pricing que Numoru usa para venderlo

Auditoría seed
$49one-time
Lead magnet. Reporte HTML completo.
  • 50 queries × 5 motores
  • Comparativa de competencia
  • Entregable HTML estático
  • Share-link a traza Langfuse
  • Turnaround 48 horas
Auditoría enterprise
$1,200one-time
Deep dive con playbook.
  • 100 queries × 5 motores × 3 corridas
  • Entregable PDF de 30 páginas
  • Roadmap de quick wins
  • Call de review de 60 min
  • Incluye repetición a 90 días
Retainer inteligencia
$900 – 4,500/ mes
Dashboard semanal + alertas.
  • Opción self-hosted o managed
  • Dashboard Langfuse + Grafana
  • Alertas Slack / email en deltas
  • Revisión estratégica trimestral
  • White-label para agencias
  • Adendo de compliance si aplica

Extensiones útiles

  • Watchlist alert: job cron semanal que corre la suite y envía diff a Slack/email.
  • Query discovery: usar un LLM para proponer queries nuevas a partir del sitemap del cliente.
  • Sentiment: detectar si la mención es positiva, neutral o negativa.
  • Source tracking: cuando Perplexity cita URL, capturarla y graficar qué URLs del cliente son más citadas.

Anti-patrones

  • Ejecutar sin filtro de tenant. Si corres auditorías para varios clientes, el Qdrant se confunde. Payload tenant_id mandatorio.
  • No guardar la versión del modelo. Un cambio silencioso en la API (Claude 4.6 → 4.7) invalida comparaciones históricas sin versioning.
  • Promediar citation_score entre queries muy distintas. Segmenta por intent (informacional, comparativo, transaccional).
  • Correr 1 sola vez y declarar resultados. Los LLMs tienen varianza; 3 corridas con temperatura 0 y mediana.

FAQ

¿Funciona fuera de español?Sí. Las queries y el detector son language-agnostic. Para semantic match usa embeddings multilingües (e5-large).

¿Cuánto dura el HTML generado?Estático puro. Dura lo que duren los enlaces a Langfuse (configuramos retención de 90 días por default).

¿Puedo correrlo sin Langfuse/Qdrant?Sí, ambos son opcionales. Pierdes historia comparable pero la corrida única funciona con flags --no-vector --no-trace.

¿Qué hago con los queries donde aparezco mal?Son las oportunidades. Publicar contenido estructurado (tablas, FAQ) que responda esa query específica suele cambiar el resultado en 3-6 semanas.

¿Hay limits de rate en LLMs? Sí. Cada provider tiene sus propios; LiteLLM maneja throttling automático. Para corridas grandes (>500 queries × 5 providers) mejor correr por lotes con breaks.

Próximos pasos

Repo: github.com/numoru-ia/geo-audit. Fork, modifica, vende. Si quieres la versión manejada (sin que montes infra), ofrecemos el servicio "Auditoría GEO + retainer mensual" que usa este mismo código sobre nuestra infraestructura compartida. El siguiente artículo cubre cómo aprovechar los insights de estas auditorías para crear contenido que sí se cite.

¿Quieres resultados así para tu empresa?

Iniciar conversación
Compartir