Brand Mention Detection in AI Answers: Aliases & Edge Cases

Brand mention detection in AI answers: match names, aliases and domains with the right word edges, resolve overlaps and avoid false matches. Includes a script.

BugViso

13 min read

Brand mention detection in AI answers needs four things. Match every name, alias and domain case-insensitively, with edges that can't touch a letter or digit. Give overlapping matches to the longest name. Rank brands by their first mention. Exclude empty and refused answers. That's simple to state, but most homegrown trackers get at least one of these wrong.

Every AI visibility metric sits on this step. Mention rate, share of voice, position and sentiment all start with "was the brand named in this answer?" A matcher that counts "Apple" inside "pineapple" or misses "acme.io" corrupts everything downstream.

This post explains the mechanics, shows the edge cases with real outputs, and gives you a standard-library detector. Run on our dataset, it reproduces BugViso's tracker counts exactly.

Data used: 280 unbranded answers from ChatGPT, Gemini, Perplexity and Claude (35 prompts × 4 engines × 2 runs, 2026-10-20), collected by BugViso's AI Visibility Tracker for bugviso.com against five anonymised competitors.

What Brand Mention Detection Has to Do

For each answer and each tracked brand, the detector produces:

OutputMeaningUsed for
Mentioned?At least one match of the brand's name, alias or domainMention rate, share of voice
First offsetCharacter position of the first matchPosition (rank among brands)
Position1 for the first tracked brand named, 2 for the next…Position score, #1 slot share
CountNumber of non-overlapping matchesDiagnostics only (never scored per mention)

The hard part is deciding what counts as a match.

Why Naive Matching Fails

Here are the classic failures, each a one-line bug in a tracker:

ApproachInputWrong result
"apple" in text.lower()"I love pineapple juice."Counts Apple
\b + name + \b"We use C++ and .NET"\b needs a word character on one side, so names that start or end with punctuation (C++, .NET, Yahoo!) never match
Exact string "SE Ranking""Compare SE↵Ranking" (line break)Misses the brand
Separate patterns, all matches counted"SE Ranking" with a competitor called "Ranking"One span counted for two brands
Name only, no aliases"Try acme.io"Misses the brand
Count every occurrenceA 548-word Gemini answer repeating a name 9 timesVerbose engines dominate

That last row isn't hypothetical. When a tracked brand was named in our unbranded answers, it was named 2.18 times on average, up to 9 times in one answer. Only 43% of mentions were single. That's why every metric counts a brand once per answer.

The Mechanics, Step by Step

1. Build the term list per brand

For each brand, collect the canonical name, aliases (product names, former names, common spellings) and the registered domain. Drop terms under 2 characters, de-duplicate case-insensitively, and sort longest first.

2. Compile edge-safe patterns

Use look-arounds instead of \b:

python
# Edges must not touch a letter or digit; spaces inside the name match any whitespace.
body = r"\s+".join(re.escape(part) for part in name.split())
pattern = re.compile(r"(?<!\w)" + body + r"(?!\w)", re.IGNORECASE)
  • (?<!\w) and (?!\w) mean "not preceded or followed by a letter, digit or underscore". So "Acme" never matches inside "Acmeworks", but does match in "Acme's", "Acme" or "Acme-powered".
  • re.escape makes dots and plus signs literal, so acme.io doesn't match acmexio and C++ compiles at all.
  • Unlike \b, the look-arounds also work for names that start or end with punctuation. In our tests \bC\+\+\b, \b\.NET\b and \bYahoo!\b all failed to match, and the look-around versions matched.
  • Joining name parts with \s+ handles line breaks and double spaces inside multi-word names.

Python's re documentation covers look-behind assertions. In JavaScript the same idea works with the u flag and Unicode property escapes (MDN on assertions).

3. Resolve overlaps: longest span wins

Collect every match of every brand as (start, end, brand). Sort by length (longest first), then by start, and keep a span only if it doesn't overlap one already kept. "SE Ranking" (10 characters) claims the span before a competitor named "Ranking" (7) can.

4. Derive position from first offsets

Sort the kept brands by their first offset; the first is position 1. Position means "order among tracked brands", not "rank in a numbered list". An answer that names only you gives you position 1 by default, which is why position must be read alongside mention rate (AI share of voice covers this trap).

5. Flag answers that can't be scored

Empty answers and short refusals ("I can't help with that") are flagged and excluded from denominators. Counting them as misses would turn a provider outage into a visibility drop.

The Benchmark: Parity with a Production Tracker

We ran the detector below over the 280 unbranded answers and compared it with what BugViso's tracker had stored for the same answers:

BrandAnswers naming it (script)Named first (script)Tracker
Our brand2118identical
Competitor A7146identical
Competitor B6320identical
Competitor C5211identical
Competitor D418identical
Competitor E3620identical

The raw output:

text
answers: 280   naming >=1 tracked brand: 123 (43.9%)
entity           answers    rate  named first
BugViso               21    7.5%           18
Competitor A          71   25.4%           46
Competitor B          63   22.5%           20
Competitor C          52   18.6%           11
Competitor D          41   14.6%            8
Competitor E          36   12.9%           20

Inside the tracker, matching is also gated by a 24-answer hand-labelled test set that must score 100% precision and recall, plus correct order and failure flags, on every change.

The Detector

Standard-library Python. --test runs the edge-case suite; otherwise it reads a CSV with an answer column.

python
#!/usr/bin/env python3
"""Detect brand mentions in AI answers: word boundaries, aliases, longest match wins.

Usage:
  python3 mention_detector.py --test
  python3 mention_detector.py answers.csv --brand "Acme" --alias "Acme=acme.com,AcmeHQ" \
      --competitor "Globex" --competitor "Initech"
answers.csv needs an `answer` column (other columns are ignored).
"""
import argparse, csv, re, sys
from collections import Counter

def build_patterns(entities):
    """entities: {canonical_name: [aliases...]} -> list of (canonical, compiled regex, surface length)."""
    pats = []
    for name, aliases in entities.items():
        for surface in {name, *aliases}:
            s = surface.strip()
            if not s:
                continue
            # Edges must not touch a letter or digit ("Acme" never matches inside "Acmeworks");
            # \b would also fail on names that start or end with punctuation. Spaces match any whitespace.
            body = r"\s+".join(re.escape(part) for part in s.split())
            rx = re.compile(r"(?<!\w)" + body + r"(?!\w)", re.IGNORECASE)
            pats.append((name, rx, len(s)))
    return pats

def detect(text, pats):
    """Return {canonical: {"count": n, "first": char offset}} plus the order of first mention."""
    spans = []
    for name, rx, ln in pats:
        for m in rx.finditer(text):
            spans.append((m.start(), m.end(), name))
    # Overlapping matches go to the longest span ("SE Ranking" beats "Ranking").
    spans.sort(key=lambda s: (-(s[1] - s[0]), s[0]))
    kept = []
    for st, en, name in spans:
        if all(en <= k[0] or st >= k[1] for k in kept):
            kept.append((st, en, name))
    kept.sort()
    found = {}
    for st, en, name in kept:
        f = found.setdefault(name, {"count": 0, "first": st})
        f["count"] += 1
    order = sorted(found, key=lambda n: found[n]["first"])
    for i, n in enumerate(order, 1):
        found[n]["position"] = i
    return found

def self_test():
    pats = build_patterns({"Apple": [], "SE Ranking": ["seranking.com"], "Ranking Labs": [], "Acme": ["acme.io"]})
    cases = [
        ("I love pineapple juice.", {}),
        ("Apple's new tool and APPLE support.", {"Apple": 2}),
        ("Try SE Ranking or seranking.com today.", {"SE Ranking": 2}),
        ("Ranking Labs leads; SE Ranking follows.", {"Ranking Labs": 1, "SE Ranking": 1}),
        ("See acme.io/pricing for Acme-like tools.", {"Acme": 2}),
        ("Compare SE\nRanking and Acme.", {"SE Ranking": 1, "Acme": 1}),
        ("Acmeworks is a different company.", {}),
    ]
    ok = True
    for text, want in cases:
        got = {k: v["count"] for k, v in detect(text, pats).items()}
        status = "ok " if got == want else "FAIL"
        ok &= got == want
        print(f"{status} {text!r} -> {got}")
    return ok

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("csv", nargs="?")
    ap.add_argument("--brand"); ap.add_argument("--competitor", action="append", default=[])
    ap.add_argument("--alias", action="append", default=[], help='"Name=alias1,alias2"')
    ap.add_argument("--test", action="store_true")
    a = ap.parse_args()
    if a.test or not a.csv:
        sys.exit(0 if self_test() else 1)
    ents = {n: [] for n in [a.brand, *a.competitor] if n}
    for spec in a.alias:
        name, _, al = spec.partition("=")
        ents.setdefault(name, []).extend(x for x in al.split(",") if x)
    pats = build_patterns(ents)
    answers = mentioned = 0
    per, first = Counter(), Counter()
    with open(a.csv, newline="", encoding="utf-8") as f:
        for row in csv.DictReader(f):
            answers += 1
            found = detect(row["answer"], pats)
            mentioned += bool(found)
            for n, v in found.items():
                per[n] += 1
                if v["position"] == 1:
                    first[n] += 1
    print(f"answers: {answers}   naming >=1 tracked brand: {mentioned} ({100 * mentioned / max(1, answers):.1f}%)")
    print(f"{'entity':<16}{'answers':>8}{'rate':>8}{'named first':>13}")
    for n in ents:
        print(f"{n:<16}{per[n]:>8}{100 * per[n] / max(1, answers):>7.1f}%{first[n]:>13}")

if __name__ == "__main__":
    main()

The self-test output:

text
ok  'I love pineapple juice.' -> {}
ok  "Apple's new tool and APPLE support." -> {'Apple': 2}
ok  'Try SE Ranking or seranking.com today.' -> {'SE Ranking': 2}
ok  'Ranking Labs leads; SE Ranking follows.' -> {'Ranking Labs': 1, 'SE Ranking': 1}
ok  'See acme.io/pricing for Acme-like tools.' -> {'Acme': 2}
ok  'Compare SE\nRanking and Acme.' -> {'SE Ranking': 1, 'Acme': 1}
ok  'Acmeworks is a different company.' -> {}

Edge Cases We Tested

We ran the detector on tricky inputs. These are its actual results:

InputResultVerdict
Acme’s pricing (curly apostrophe)Acme ×1✅ Correct
**Acme** is fine (Markdown bold)Acme ×1✅ Correct
Try ACME.IO nowAcme ×1 (via domain alias)✅ Correct
unlike Acme, Globex is cheaperAcme ×1✅ A mention; sentiment decides the tone
Acme-powered auditsAcme ×1✅ Correct (hyphen is an edge)
[Acme](https://acme.io)Acme ×2⚠️ Name and domain in one link count twice; harmless when scoring once per answer
Acme2 is differentnone✅ Different product
SE-Ranking reviewnone⚠️ Hyphenated spelling needs an alias
我推荐Acme工具 (no spaces)none❌ CJK characters count as \w, so the edge check fails
take notion of the risk (brand "Notion")Notion ×1❌ Common-word brand: needs disambiguation

The last three rows need different fixes:

Hyphenated and spaced variants: add them as aliases. Engines write brand names the way the web does, so scan a sample of answers for variants before you freeze the alias list.

Text without spaces (Chinese, Japanese): \w includes CJK characters in Python's Unicode mode, so (?<!\w) blocks the match. For these languages, use edges based on script changes, for example "not preceded by a Latin letter or digit": (?<![A-Za-z0-9]).

Common-word brands: no regex can tell "Notion the app" from "notion of the risk". Flag the brand as a common word, and send matched answers to a classifier that judges whether the match means the company.

How BugViso Detects Brand Mentions

BugViso's AI Visibility Tracker uses the same approach in production, with the extra layers a tracker needs:

  • Names, aliases and the registered domain for the brand and every competitor, matched case-insensitively with edges that can't touch a letter or digit. Spaces in a name match any whitespace.
  • Longest match wins on overlaps, so one span never counts for two brands. Position is the order of first mention among tracked brands.
  • Failure flags. Empty answers and refusals (including Claude's stop_reason: refusal) are excluded from scores, not counted as misses.
  • Common-word brands. For a brand flagged as a common word, the same call that scores sentiment also judges whether each match means the company. If it doesn't, the mention is removed automatically.
  • Manual corrections. You can remove or restore a mention, and that run's scores are recomputed from the stored answers. Saved reports stay frozen.
  • A labelled gate. A 24-answer hand-labelled set must pass at 100% precision and recall before matching changes ship.

See how mentions feed the scorecard on the AI visibility tracker page.

Anti-Patterns

Matching inside URLs and treating it as a mention. A citation to acme.io isn't the engine naming Acme to the reader. Detect mentions in the answer text, and track citations separately (AI search citation sources).

Changing aliases mid-period. Adding an alias raises the mention rate with no change in the answers. Freeze aliases with the prompt set and note any change in the report.

Fuzzy matching by default. Edit-distance matching catches typos but creates false positives between similar brand names. Prefer explicit aliases and review a sample.

Counting the brand in its own branded prompt. "Is Acme worth it?" answers will name Acme. Score visibility on unbranded prompts only (prompt set guide).

FAQ

How do I detect brand mentions in ChatGPT answers?

Collect the answers, then match the brand's name, aliases and domain case-insensitively with edges that can't touch a letter or digit. Give overlapping matches to the longest name and count the brand once per answer. The script above does this in about 80 lines.

Why not use \b word boundaries?

\b needs a word character on one side of the boundary, so it never matches names that start or end with punctuation, such as C++, .NET or Yahoo!. Look-arounds like (?<!\w)…(?!\w) state the rule you actually want: no letter or digit on either side.

How do I handle brand names that are common words?

Flag them and add a disambiguation step: for each match, ask a classifier (or a person) whether it refers to the company. Remove matches that don't, and keep an audit trail so scores can be recomputed.

Should a domain mention count as a brand mention?

Yes, when the domain appears in the answer text ("try acme.io"). A link in the citation list isn't a mention; it's a citation, tracked separately.

How accurate is regex-based brand mention detection?

With explicit aliases and the edge rules above, it reproduced a production tracker's counts exactly on 280 answers. The remaining risks are common-word brands, unlisted spellings and languages without spaces.

Can I run the same detection in JavaScript?

Yes. Modern engines support look-behind, and the u flag with \p{L} and \p{N} gives Unicode-aware edges:

javascript
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const brandRx = (name) =>
  new RegExp(`(?<![\\p{L}\\p{N}_])${name.split(/\s+/).map(escape).join("\\s+")}(?![\\p{L}\\p{N}_])`, "giu");
console.log("See Acme's pricing on acme.io".match(brandRx("Acme")).length); // 2: "Acme's" and the "acme" of acme.io
console.log(("Pineapple juice".match(brandRx("Apple")) || []).length);       // 0
console.log(("We use C++ daily".match(brandRx("C++")) || []).length);        // 1

Conclusion

Reliable AI visibility metrics start with a strict detector: edge-safe patterns, aliases and domains, longest-match overlaps and once-per-answer counting, which is the foundation BugViso's AI visibility tracking scores every answer on.

Found this useful? Share it.

See where your site stands

Run a free BugViso audit for SEO, speed, accessibility and AI search readiness — with fixes you can ship today.