Files
DocsGPT/docsgpt/graphrag/naming.py
T
Alex 022cf69b49 fix(graphrag): fold an entity's singular and plural onto one key
canonical_name dropped "es" from every -ches/-ses/-zes plural, so "caches"
became "cach" while "cache" stayed "cache" -- the singular and plural landed on
two nodes, which is the split the function exists to prevent. The same rule
split the words documentation uses most: databases/database, responses/
response, releases/release, sizes/size.

An "-es" plural cannot say whether it is cache + "s" or batch + "es", so
instead of guessing, both sides now meet at the stem: the singular endings
-che/-she/-se/-ze/-xe drop their "e" the way the plurals drop "es", and a
singular's -ie folds to -y as -ies already did (cookie/cookies). The key is a
merge key that is never shown, so it only has to agree, not be a word. alias,
canvas, atlas and bias join the words that only look plural.

Found by the naming tests CI was missing: the module had no direct tests.
2026-09-19 14:33:14 +01:00

116 lines
4.6 KiB
Python

"""Canonical entity naming for the per-source knowledge graph.
Nodes are merged on ``normalized_name``, which has been ``name.lower()``. That
splits entities a reader would call the same thing: measured on the DocsGPT docs
corpus, ``agent``/``agents``, ``VECTOR_STORE``/``Vector store``/``vector stores``,
``Celery worker``/``Celery workers`` and ``.env file``/``env_file`` all landed as
separate nodes — 58 such collisions across 1,704 entities, with 75% of entities
appearing in exactly one chunk as a result.
:func:`canonical_name` folds the differences that are purely orthographic:
case, surrounding punctuation, underscore/hyphen word breaks, and a *cautious*
plural. Cautious matters: this corpus contains ``postgres``, ``kubernetes``,
``https`` and ``aws``, none of which are plurals, so a naive "strip trailing s"
would corrupt them into new entities rather than merge anything.
The result is a merge key, never shown to anyone, so it only has to be the
same for a word's singular and plural — not to be a word itself.
Always on: every graph is built with canonical names.
"""
from __future__ import annotations
import re
_PUNCT = re.compile(r"[^\w\s]+", re.UNICODE)
_UNDERSCORE = re.compile(r"[_\-]+")
_SPACE = re.compile(r"\s+")
#: Words that end in "s" without being plural. Singularising these would invent
#: entities ("postgre", "kubernete") instead of merging existing ones.
_NOT_PLURAL = frozenset(
{
"postgres", "kubernetes", "https", "aws", "dns", "tls", "cors", "css",
"js", "sas", "gas", "ss", "class", "access", "process", "status",
"analysis", "basis", "axis", "https", "rss", "less", "express",
"redis", "nats", "kibana", "elasticsearch", "os", "ios", "macos",
"always", "sometimes", "series", "docs", "ops", "devops", "sse",
"alias", "canvas", "atlas", "bias", "pandas",
}
)
#: Plural endings that drop ``es``, and the singular endings that meet them.
#: ``caches`` cannot say whether it is ``cache`` + "s" or ``cach`` + "es"
#: (as ``batches`` is ``batch`` + "es"), so rather than guess, both
#: ``caches`` and ``cache`` fold to ``cach`` — as ``databases``/``database``
#: fold to ``databas``. Hardly any real word differs from one of these singulars
#: by its final "e" alone, so the fold merges next to nothing it should not.
_ES_PLURAL = ("ches", "shes", "ses", "zes", "xes")
_E_SINGULAR = ("che", "she", "se", "ze", "xe")
def _singular(word: str) -> str:
"""Fold one word so its singular and plural share a key, else leave it alone.
``-ies`` and a singular's ``-ie`` both fold to ``-y`` (``policies``,
``cookies``/``cookie``). The ``-es`` endings in :data:`_ES_PLURAL` drop
``es`` and the singular endings in :data:`_E_SINGULAR` drop their ``e``, so
both sides of an ambiguous plural meet (``caches``/``cache`` -> ``cach``).
Otherwise a bare trailing ``s`` is dropped on a word long enough to be
safe. Everything in :data:`_NOT_PLURAL`, and anything ending in
``ss``/``us``/``is``, is returned unchanged.
"""
if len(word) < 4 or word in _NOT_PLURAL:
return word
if word.endswith(("ss", "us", "is")):
return word
if word.endswith("ies") and len(word) > 4:
return word[:-3] + "y"
if word.endswith(_ES_PLURAL):
return word[:-2]
if word.endswith("ie") and len(word) > 4:
return word[:-2] + "y"
if word.endswith(_E_SINGULAR):
return word[:-1]
if word.endswith("s"):
return word[:-1]
return word
def canonical_name(name: str) -> str:
"""Merge key for an entity name.
Args:
name: The entity name as the model wrote it.
Returns:
A lowercase, punctuation-free key shared by a name's singular and
plural. Returns ``""`` for an empty or punctuation-only name, which
callers treat as "no entity".
Examples:
``VECTOR_STORE`` and ``Vector stores`` -> ``vector store``;
``.env file`` and ``env_file`` -> ``env file``;
``cache`` and ``caches`` -> ``cach``;
``postgres`` stays ``postgres``.
"""
if not name:
return ""
text = _UNDERSCORE.sub(" ", str(name))
text = _PUNCT.sub(" ", text)
text = _SPACE.sub(" ", text).strip().lower()
if not text:
return ""
return " ".join(_singular(word) for word in text.split())
def normalize_entity_name(name: str) -> str:
"""The key an entity is merged on: its :func:`canonical_name`.
Every graph the corpora were measured on was built this way, so it is the
only mode rather than a flag. A graph built before this used plain
``lower()`` keys; re-extracting it merges onto these instead.
"""
return canonical_name(name)