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.
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:
| Output | Meaning | Used for |
|---|---|---|
| Mentioned? | At least one match of the brand's name, alias or domain | Mention rate, share of voice |
| First offset | Character position of the first match | Position (rank among brands) |
| Position | 1 for the first tracked brand named, 2 for the next… | Position score, #1 slot share |
| Count | Number of non-overlapping matches | Diagnostics 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:
| Approach | Input | Wrong 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 occurrence | A 548-word Gemini answer repeating a name 9 times | Verbose 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:
# 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.escapemakes dots and plus signs literal, soacme.iodoesn't matchacmexioandC++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\band\bYahoo!\ball 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:
| Brand | Answers naming it (script) | Named first (script) | Tracker |
|---|---|---|---|
| Our brand | 21 | 18 | identical |
| Competitor A | 71 | 46 | identical |
| Competitor B | 63 | 20 | identical |
| Competitor C | 52 | 11 | identical |
| Competitor D | 41 | 8 | identical |
| Competitor E | 36 | 20 | identical |
The raw output:
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% 20Inside 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.
#!/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:
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:
| Input | Result | Verdict |
|---|---|---|
Acme’s pricing (curly apostrophe) | Acme ×1 | ✅ Correct |
**Acme** is fine (Markdown bold) | Acme ×1 | ✅ Correct |
Try ACME.IO now | Acme ×1 (via domain alias) | ✅ Correct |
unlike Acme, Globex is cheaper | Acme ×1 | ✅ A mention; sentiment decides the tone |
Acme-powered audits | Acme ×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 different | none | ✅ Different product |
SE-Ranking review | none | ⚠️ 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:
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); // 1Conclusion
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.
See where your site stands
Run a free BugViso audit for SEO, speed, accessibility and AI search readiness — with fixes you can ship today.