All contributions
Engineeringgogeolitellm

Auditing if ChatGPT cites your brand: an open-source Go tool with LiteLLM and Qdrant

Go script that runs 100 queries against 5 LLMs via LiteLLM proxy, vectorizes responses in Qdrant, and detects semantic citations. React dashboard. Published as numoru/geo-audit.

Numoru EngineeringPublished on May 24, 202615 min read
Share
Implementation proposalgithub.com/numoru-ia/geo-audit

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.

$1.80
Cost per full audit
50 queries × 5 engines
45-90s
Runtime with 50 goroutines
250 API calls
$200-600
Monthly SaaS alternatives
Peec.ai, Profound, AthenaHQ
$40
Monthly infra if self-hosted
DO droplet + Qdrant + Langfuse

Why build this in Go

  • Native concurrency — 500 parallel LLM calls without complexity.
  • Single binarygo build yields 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:

  1. Semantic comparison of the same LLM's answers across runs (model drift).
  2. Detecting when a change to the client's site starts getting cited (success signal).
  3. 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.
Cost and latency per provider (50-query run)

USD cost and median latency (seconds) measured over 10 production runs. Token pricing snapshot from each provider's public rate card, Q1 2026.

gpt-4oclaude-sonnet-4-6gemini-2.0-properplexity-sonar-proai-overview (scrape)02468
  • USD / run
  • Median latency (s)

Numoru internal benchmarks, Feb 2026.

Business & commercial impact

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)

SaaS landscape for LLM citation tracking

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)

Digital agencies
Add citation tracking to their SEO retainers. White-label HTML report.
$900 – 2,500 / mo
Per agency, multi-client
In-house marketing (Series B+)
Weekly share-of-voice dashboard vs 3 competitors.
$1,800 – 4,500 / mo
12 mo
B2B SaaS (post-seed)
One-time audit + 2 quarterly follow-ups.
$3,500 one-time + $900 / quarter
Yr 1 then rolling
Regulated (legal / health)
Self-hosted deploy with compliance hand-off (no data leaves their cloud).
$12,000 setup + $900 / mo support
24 mo
Investor / consultancy
Portfolio-wide SOV reports, 8-20 companies per run.
$4,500 – 9,000 / mo
12 mo

Public benchmarks to anchor pricing

Public case studyAcademic research · USA · 2024

Princeton / Georgia Tech — GEO optimization lift

Challenge
Measure whether content manipulations (statistics, quotations, citations) shift LLM source visibility in a reproducible way.
Solution
10k-query benchmark (GEO-BENCH) against generative search engines. The paper open-sources the dataset and evaluation protocol.
Results
Visibility gain
+40%
Best combo vs baseline
Subjective impression
+41.5%
Across domains
Evaluation queries
10,000
Reusable dataset
Public case studyIndustry analyst · Global · 2024

Gartner — shift in search behaviour

Challenge
Size the shift from traditional organic search to generative engines.
Solution
Gartner's 2024 research agenda combining user panels with marketing-budget sampling.
Results
Search volume drop
-25%
By 2026 vs 2024
Enterprise GEO adoption
50%
Large marketers by 2027
Paid-search pressure
Double digits
Budget re-allocation

Illustrative case — agency reselling the audit

Illustrative caseDigital agency · 8 employees · $600K ARR · Mexico

Boutique SEO agency (Mexico City) adding GEO auditing to 12 existing retainers

Baseline
Classic SEO retainers at $1,200-2,400 / mo per client. Churn 18% / yr, mostly because clients ask "what's happening in ChatGPT?" and the agency has no answer.
Intervention
Self-host the geo-audit stack (one Digital Ocean droplet, LiteLLM + Qdrant + Langfuse). Charge +$400 / mo per client for weekly GEO dashboards. Offer a free audit as a lead-gen magnet.
Projected outcome (12 mo)
Retainer price uplift
+33%
$1,800 avg → $2,400
Incremental ARR
+$57,600
12 clients × $400 × 12
Infra cost (annual)
−$480
DO droplet + model calls
New-client close rate
+18%
Free audit as entry point
Churn
18% → 9%
Clients see AI channel covered
Net gain year 1
+$57,120
Before delivery labour
Uplift numbers derived from Ahrefs' 2024 SEO agency benchmarks and our cost-per-audit math. Synthetic case — not a specific Numoru client.

ROI calculator — self-host vs SaaS

12-month cost: self-hosted Go audit vs Peec.ai/Profound tier

Payback: 2 months
Assumptions
Brands audited per month10
Queries per brand50
Engines5
API cost per full run (blended)$1.80
DO droplet (Qdrant + Langfuse + LiteLLM)$40 / mo
Engineer time, steady-state2 h / mo
SaaS alternative entry tier$349 / mo
SaaS alt for 10 brands (quote)$1,800 / mo
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

Seed audit
$49one-time
Lead magnet. Full HTML report.
  • 50 queries × 5 engines
  • Competitor comparison
  • Static HTML deliverable
  • Share-link to Langfuse trace
  • 48-hour turnaround
Enterprise audit
$1,200one-time
Full deep dive with playbook.
  • 100 queries × 5 engines × 3 runs
  • 30-page PDF deliverable
  • Quick-win content roadmap
  • 60-min review call
  • Includes repeat after 90 days
Intel retainer
$900 – 4,500/ month
Weekly dashboard + alerts.
  • 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_id in 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.

Want results like these for your company?

Start a conversation
Share