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.
Por qué hacer esto en Go
- Concurrencia nativa — 500 llamadas a LLMs en paralelo sin complejidad.
- Binary único —
go buildda 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:
- Comparar semánticamente respuestas del mismo LLM entre corridas (drift del modelo).
- Detectar cuando un cambio en el sitio del cliente empieza a ser citado (señal de éxito).
- 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 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.
- USD / corrida
- Latencia mediana (s)
Benchmarks internos Numoru, feb 2026.
Impacto de negocio y casos
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)
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)
Benchmarks públicos que anclan el precio
Princeton / Georgia Tech — lift de optimización GEO
Gartner — shift en comportamiento de búsqueda
Caso ilustrativo — agencia revendiendo la auditoría
Agencia SEO boutique (CDMX) sumando GEO a 12 retainers existentes
Calculadora ROI — self-host vs SaaS
Costo 12 meses: auditoría self-hosted Go vs tier Peec.ai/Profound
| 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
- 50 queries × 5 motores
- Comparativa de competencia
- Entregable HTML estático
- Share-link a traza Langfuse
- Turnaround 48 horas
- 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
- 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_idmandatorio. - 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.