Skip to content

A Glossary Your Company and Your Model Both Use

10 min read · updated August 4, 2026

Every company has forty words that mean something specific internally and something else in the dictionary. A model does not know that an “activation” is a billing event rather than a marketing one, and neither does your search index. One table fixes both, plus a third problem nobody expects it to.

The problem a glossary solves

Three distinct failures, all from the same cause. A model asked to summarise a churn report uses “churn” in the ordinary sense while your finance team means voluntary cancellations only, excluding downgrades. A user searching for “seat” gets nothing because every document says “licence”. And an assistant tells a customer about “Premium”, a tier renamed eighteen months ago, because half the corpus still says it.

None of these is a model problem. All three are the absence of a machine-readable record of what your words mean.

The term table

CREATE TABLE term (
  id            text PRIMARY KEY,          -- 'term_activation'
  preferred     text NOT NULL,             -- 'activation'
  definition    text NOT NULL,             -- one sentence, no jargon
  scope         text[] NOT NULL DEFAULT '{}',  -- ['billing','analytics']
  aliases       text[] NOT NULL DEFAULT '{}',  -- ['first charge','go-live']
  deprecated    text[] NOT NULL DEFAULT '{}',  -- ['activation event','A-event']
  do_not_use    text[] NOT NULL DEFAULT '{}',  -- terms to never emit
  broader_id    text REFERENCES term(id),
  owner         text NOT NULL,             -- a person, not a team
  status        text NOT NULL DEFAULT 'active'
                  CHECK (status IN ('draft','active','deprecated')),
  updated_at    timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX ON term USING gin (aliases);
CREATE INDEX ON term USING gin (deprecated);

Four columns are doing more work than they look. definition is one sentence because a paragraph will not fit in a prompt and nobody reads it anyway. scope exists because “activation” genuinely means different things in billing and in marketing, and a glossary that pretends otherwise is wrong in both. deprecated is the list of things people still say, which is what makes retrieval and validation work. And owner is a named person, because a term owned by a team is owned by nobody and will be stale within a year.

This is a controlled vocabulary with a broader relation, which makes it the first two rungs of the ladder in ontologies, taxonomies and schemas. If you later want to publish it, SKOS maps onto these columns directly — preferred to skos:prefLabel, aliases to skos:altLabel, deprecated to skos:hiddenLabel.

Selecting the terms that are relevant

The mistake that kills this pattern is injecting the whole glossary. At 900 terms and roughly 30 tokens each that is about 27,000 tokens on every request — more than most system prompts, paid on every call, and it degrades attention to everything else in the context for the sake of 878 definitions that are not relevant.

Select instead. Match the user’s message and the retrieved context against preferred labels, aliases and deprecated forms, and inject only what matched:

import re

def select_terms(text: str, terms: list[dict], scope: str | None = None,
                 limit: int = 12) -> list[dict]:
    haystack = " " + re.sub(r"\s+", " ", text.lower()) + " "
    hits = []
    for t in terms:
        if scope and t["scope"] and scope not in t["scope"]:
            continue
        surfaces = [t["preferred"], *t["aliases"], *t["deprecated"]]
        for s in surfaces:
            if re.search(rf"(?<![a-z0-9]){re.escape(s.lower())}(?![a-z0-9])",
                         haystack):
                hits.append((len(s), t))     # longer match = more specific
                break
    hits.sort(key=lambda p: -p[0])
    return [t for _, t in hits[:limit]]

The lookaround assertions rather than \b matter for multi-word and hyphenated terms, and the length sort means “annual recurring revenue” wins over “revenue” when both match. Cap the count: twelve definitions is a couple of hundred tokens and covers nearly every real message.

Consumer 1: the prompt

def glossary_block(terms: list[dict]) -> str:
    if not terms:
        return ""
    lines = ["Terminology used in this organisation. Use these meanings, and "
             "use the preferred term in your answer:"]
    for t in terms:
        line = f"- {t['preferred']}: {t['definition']}"
        if t["deprecated"]:
            line += f" (previously called: {', '.join(t['deprecated'])})"
        if t["do_not_use"]:
            line += f" (do not use: {', '.join(t['do_not_use'])})"
        lines.append(line)
    return "\n".join(lines)

Put the block after the system instructions and before the retrieved context. Two properties make it worth the tokens: it is generated from the table, so a term edited on Monday is in every prompt on Monday, and the same block is available to every application, so the assistant, the summariser and the support macro generator all speak the same vocabulary without three teams maintaining three prompt fragments.

Consumer 2: retrieval

Lexical search fails on synonyms, which is the standard argument for embeddings. But embeddings do not know that your “seat” is your “licence” either, because that is an organisational convention rather than a general one. The glossary fixes it directly: expand the query with aliases before the lexical leg of a hybrid search runs.

def expand_query(q: str, terms: list[dict]) -> str:
    hits = select_terms(q, terms, limit=4)
    extra = []
    for t in hits:
        extra.extend([t["preferred"], *t["aliases"], *t["deprecated"]])
    seen, uniq = set(), []
    for w in extra:
        if w.lower() not in seen and w.lower() not in q.lower():
            seen.add(w.lower())
            uniq.append(w)
    return q if not uniq else f"{q} {' '.join(uniq)}"

Expand for BM25, not for the vector leg — adding synonyms to the text you embed moves the query vector towards the average of several concepts rather than sharpening it. Normalising terms at ingestion time as well, so that the indexed text carries both the surface form and the canonical one, is the more thorough version and costs a reindex.

Consumer 3: output validation

The consumer nobody plans for and everybody wants once it exists. Scan generated text for deprecated and forbidden terms before it is sent:

def lint_output(text: str, terms: list[dict]) -> list[dict]:
    findings = []
    low = text.lower()
    for t in terms:
        for bad in [*t["deprecated"], *t["do_not_use"]]:
            if re.search(rf"(?<![a-z0-9]){re.escape(bad.lower())}(?![a-z0-9])",
                         low):
                findings.append({"found": bad, "use_instead": t["preferred"],
                                 "severity": "block" if bad in t["do_not_use"]
                                             else "warn"})
    return findings

Two severities, and the distinction is the whole value. A deprecated term is a warning — surface it to a reviewer, or rewrite it silently for internal text. A do_not_use term is a block, and that list is where legal and compliance put the words that must never appear: competitor names, unapproved claims, a product name you no longer have the rights to. That check is deterministic, costs nothing, and is the kind of guarantee no prompt provides.

What a glossary cannot fix

Three problems look like terminology problems and are not, and a glossary asked to solve them makes things worse rather than better:

  • A genuinely contested definition. When finance and growth mean different things by “active user”, writing one compromise definition produces a term that is wrong for both and that neither will use. Write two scoped terms with distinct preferred labels — active user (billing) and active user (product) — and make the ambiguous bare form a deprecated alias of neither, so that a query using it triggers a clarification rather than a silent choice.
  • Terms that are actually entities. “Project Harrier” is not a word with a definition; it is a thing with attributes, a status and an owner. Putting it in the glossary produces a table of hundreds of one-off entries that goes stale immediately. It belongs in the graph, and the glossary should hold only the terms that are used to talk about entities rather than the entities themselves.
  • Terms that encode a calculation. “Net revenue retention” has a formula, and a one-sentence definition of it is a summary of the formula that will diverge from the query the dashboard actually runs. Store the definition and a pointer to the canonical query or metric definition, and let the pointer be the authority.

The common thread: a glossary is for the mapping from surface forms to meanings. The moment an entry starts carrying attributes, structure or logic, it has outgrown the table and belongs in the graph, the metric layer or a rule set.

Keeping it alive

  • Seed it from usage, not from a workshop. Extract candidate terms from your own corpus by frequency and by how much more often they appear internally than in general text. A glossary written in a meeting contains the words people think are important; a glossary mined from the corpus contains the words they use.
  • Log misses. Every term that matched nothing but looked domain-specific is a candidate. Every unanswered question containing a word not in the glossary is a signal.
  • Never delete a term. Set it deprecated with a pointer to its replacement. The deprecated list is what makes retrieval and validation work, and deleting it throws away the history that made the term worth recording.
  • Review on a cadence with the named owner, and let the review be short. Twelve terms reviewed properly each quarter beats a full audit that never happens.