TL;DR
We built an open-source Go tool that tests 100 queries against five LLMs (ChatGPT, Claude, Gemini, Perplexity, Google AI Overview) via LiteLLM proxy, vectorizes the responses in Qdrant to detect paraphrased semantic citations, and leaves traces in Langfuse with a Grafana dashboard that evolves over time. The repo numoru-ia/geo-audit can be forked and run in 15 minutes. Cost per full run: ~$2 USD. It's the tool we used for the GEO vs SEO benchmark and the one we offer as a technical lead magnet to potential clients.
Why build this in Go
- Native concurrency — 500 parallel LLM calls without complexity.
- Single binary —
go buildyields a portable executable with no runtime. - Simple observability — direct instrumentation with OpenTelemetry.
- Fits the existing stack — several Numoru clients already run Go services.
You could do this in Python. But when auditing thousands of queries per month, the latency and memory difference becomes real.
Architecture
config.yaml (queries + brand + competitors)
│
▼
┌─────────────────────────────────────────────┐
│ audit CLI (Go) │
│ │
│ worker pool (50 concurrent goroutines) │
│ │ │
│ ├─► LiteLLM proxy /v1/chat │
│ │ ├─ ChatGPT (gpt-4o) │
│ │ ├─ Claude Sonnet │
│ │ ├─ Gemini Pro │
│ │ ├─ Perplexity sonar-pro │
│ │ └─ (Google AI Overview via API)│
│ │ │
│ ├─► Citation detector │
│ │ ├─ literal match │
│ │ ├─ domain match │
│ │ └─ semantic match (embedding) │
│ │ │
│ ├─► Qdrant upsert (responses) │
│ └─► Langfuse trace │
└─────────────────────────────────────────────┘
│
▼
report.html + results.json
Project layout
geo-audit/
├── cmd/audit/main.go
├── internal/
│ ├── config/ # YAML parsing
│ ├── providers/ # per-LLM wrappers
│ ├── detector/ # citation logic
│ ├── vectorizer/ # embedding + Qdrant
│ ├── report/ # HTML and JSON
│ └── trace/ # Langfuse
├── testdata/
├── Dockerfile
└── go.mod
The config
config.yaml:
brand: Numoru
domains:
- numoru.com
competitors:
- brand: Clearbit
domains: [clearbit.com]
- brand: Apollo
domains: [apollo.io]
queries:
- "Best B2B data enrichment tools 2026"
- "How to comply with the EU AI Act as a Mexican company"
- "Open source alternative to 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
Entry point
// 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))
}
Pool-based runner
// 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: uniform wrapper over LiteLLM
LiteLLM exposes an OpenAI-compatible API for every model. A single abstraction:
// 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: "Reply briefly, with sources if applicable."},
{Role: "user", Content: query},
},
Temperature: 0.0,
MaxTokens: 800,
})
if err != nil {
return "", err
}
return resp.Choices[0].Message.Content, nil
}
Perplexity and Gemini go through the same LiteLLM endpoint. Google AI Overview needs a different API (Search Console API or scraping with Firecrawl); we treat it as a separate provider with its own adapter.
The citation detector (triple layer)
// internal/detector/detector.go
package detector
type Citations struct {
LiteralMatch bool // "Numoru" appears literally
DomainMatch []string // domain appears in URLs/refs
SemanticMatch bool // paraphrase with similarity >0.88
Score float64 // 0-1 aggregate
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
}
The semantic check compares the response embedding against an owned corpus of the client (home, posts, FAQ, service portfolio). If the response paraphrased client content without mentioning the brand, it counts.
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
}
Vectorization: building history
Every run writes to Qdrant response_archive with payload {run_id, query, provider, brand, date, citations}. This enables:
- Semantic comparison of the same LLM's answers across runs (model drift).
- Detecting when a change to the client's site starts getting cited (success signal).
- Auditing whether competitors gained coverage week over week.
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),
},
})
}
Langfuse integration
Each query/provider is a span within a per-run trace. Per-citation score becomes an evaluation.
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())
}
}
A Grafana dashboard pointing to Langfuse/ClickHouse shows:
- Citation score per provider per week.
- Share of voice vs competitors.
- Queries where we dropped 10+ points.
HTML report
internal/report/html.go generates a static report with:
- Summary table per provider.
- Top 10 queries where we appear.
- Top 10 queries where competitors appear without us (priority list).
- 3 favorable and 3 unfavorable citation excerpts.
- "Share" button with a signed link to the run in Langfuse.
It serves from any static host (Cloudflare Pages, Nginx, GitHub Pages if you want it public).
Execution and costs
go install github.com/numoru-ia/geo-audit/cmd/audit@latest
audit -c config.yaml -out results/
Full run time (50 queries × 5 providers = 250 calls):
- With pool of 50 goroutines: 45-90 seconds.
- Input tokens: ~100k; output: ~200k.
- Approximate cost: $1.80 USD.
USD cost and median latency (seconds) measured over 10 production runs. Token pricing snapshot from each provider's public rate card, Q1 2026.
- USD / run
- Median latency (s)
Numoru internal benchmarks, Feb 2026.
Business & commercial impact
Two business models on top of this code
The same Go binary unlocks two distinct commercial products. Audit-as-a-service sells the one-off diagnosis — a $49 lead magnet or a $1,200 enterprise report. Competitive intelligence retainer sells the weekly dashboard: watching competitors, surfacing movement, emailing diffs. The retainer is where the compounding revenue lives.
How SaaS alternatives price this (2026)
Monthly list price of the most-cited managed tools that replicate what this Go tool does. Figures are the entry tier; enterprise plans go to $2,000+/mo.
Public pricing pages, Feb 2026 (Peec.ai, Profound, AthenaHQ, BrandRank.ai, Goodie AI).
Buyers of these SaaS pay for the dashboard, not the detector. Our differentiator is that self-hosting plus a Go binary leaves the data on the customer's side and costs 1/10th in infra. That matters for compliance-heavy verticals (legal, health, fintech).
Who pays for a citation-audit product
Audit product pricing by buyer profile (Numoru, 2026)
Public benchmarks to anchor pricing
Princeton / Georgia Tech — GEO optimization lift
Gartner — shift in search behaviour
Illustrative case — agency reselling the audit
Boutique SEO agency (Mexico City) adding GEO auditing to 12 existing retainers
ROI calculator — self-host vs SaaS
12-month cost: self-hosted Go audit vs Peec.ai/Profound tier
| Self-host — API calls (10 brands × 4 runs/mo × 12) | −$864 |
| Self-host — infra (12 mo) | −$480 |
| Self-host — engineer (24 h × $95) | −$2,280 |
| Self-host total (12 mo) | −$3,624 |
| SaaS Profound/Peec equivalent (12 mo) | −$21,600 |
| Savings vs SaaS | +$17,976 |
| Billed to clients (GEO retainer add-on) | +$57,600 |
| Net year-1 gross contribution (agency POV) | +$53,976 |
Pricing tiers Numoru uses to sell this
- 50 queries × 5 engines
- Competitor comparison
- Static HTML deliverable
- Share-link to Langfuse trace
- 48-hour turnaround
- 100 queries × 5 engines × 3 runs
- 30-page PDF deliverable
- Quick-win content roadmap
- 60-min review call
- Includes repeat after 90 days
- Self-hosted or managed option
- Langfuse + Grafana dashboard
- Slack / email alerts on deltas
- Quarterly strategy review
- White-label for agencies
- Compliance addendum on request
Useful extensions
- Watchlist alert: weekly cron that runs the suite and emails/Slacks the diff.
- Query discovery: use an LLM to propose new queries from the client's sitemap.
- Sentiment: detect whether the mention is positive, neutral or negative.
- Source tracking: when Perplexity cites a URL, capture it and graph which client URLs are most-cited.
Anti-patterns
- Running without a tenant filter. If you audit multiple clients, the Qdrant gets confused.
tenant_idin payload is mandatory. - Not recording the model version. A silent API change (Claude 4.6 → 4.7) invalidates history without versioning.
- Averaging citation_score across very different queries. Segment by intent (informational, comparative, transactional).
- Running once and declaring results. LLMs have variance; 3 runs at temperature 0 and take the median.
FAQ
Does it work outside Spanish?Yes. Queries and the detector are language-agnostic. For semantic match use multilingual embeddings (e5-large).
How long does the generated HTML last?Static file. As long as Langfuse links last (we configure 90-day retention by default).
Can I run it without Langfuse/Qdrant?Yes, both are optional. You lose comparable history but a single run works with flags --no-vector --no-trace.
What do I do with the queries where I appear poorly?Those are the opportunities. Publishing structured content (tables, FAQ) that answers that specific query typically shifts the result in 3-6 weeks.
Are there rate limits on LLMs? Yes. Each provider has its own; LiteLLM handles throttling automatically. For large runs (>500 queries × 5 providers) run in batches with breaks.
Next steps
Repo: github.com/numoru-ia/geo-audit. Fork, modify, sell. If you want the managed version (no infra to stand up), we offer the "GEO audit + monthly retainer" service using this same code on our shared infrastructure. The next article covers how to leverage these audit insights to create content that actually gets cited.