Skip to main content

Command Palette

Search for a command to run...

Building a Service Taxonomy Classifier for Complex Websites with Python

Updated
21 min readView as Markdown

A website with ten pages is easy to understand.

A website with hundreds of service pages is not.

As the number of pages grows, categories begin to overlap.

A procedure may belong to body contouring, aesthetic medicine, injectable treatments, facial rejuvenation, or several concepts at the same time.

The URL may suggest one category.

The navigation may suggest another.

The page title may introduce a third.

If software needs to index, search, recommend, migrate, audit, or analyze those pages, relying on a single folder name is not enough.

This article builds a Python architecture for classifying complex service pages into a stable taxonomy.

The example data comes from public health service pages, but the architecture is useful for marketplaces, SaaS documentation, ecommerce catalogs, educational platforms, directories, and any website where one page can have several meaningful attributes.

The main idea is simple:

Do not classify a page from one string. Collect several signals, preserve their provenance, and resolve the taxonomy afterward.

Why URL folders are not a taxonomy

Suppose a website contains routes that conceptually look like this:

services/body_contouring/page_a
services/body_sculpting/page_b
services/cosmetic_injections/page_c
services/face_rejuvenation/page_d

It is tempting to use the second path segment as the page category.

Sometimes that works.

But it creates several problems.

A service can logically belong to more than one concept.

A body treatment may involve both tissue tightening and contouring.

An injectable treatment can be classified by product type, anatomy, and treatment objective.

A facial laser page may belong to both laser procedures and facial rejuvenation.

The route tells us how the website owner organized the content.

It does not necessarily describe the complete semantic identity of the page.

So instead of building a classifier around one category field, we need a richer model.

Represent classification as several dimensions

A useful taxonomy can separate different kinds of information.

For example:

from dataclasses import dataclass
from typing import Optional


@dataclass
class ServiceClassification:
    family: Optional[str]
    modality: Optional[str]
    anatomy: list[str]
    objective: list[str]
    route_group: Optional[str]

This immediately gives us more flexibility.

A page might have:

ServiceClassification(
    family="aesthetic_procedure",
    modality="laser",
    anatomy=["body"],
    objective=["contouring", "skin_tightening"],
    route_group="body_contouring",
)

Another page may contain:

ServiceClassification(
    family="cosmetic_injection",
    modality="botulinum_toxin",
    anatomy=["forehead", "glabella"],
    objective=["dynamic_line_reduction"],
    route_group="cosmetic_injections",
)

The page no longer needs to fit inside one rigid label.

Build a real test fixture set

Synthetic examples are useful for unit tests.

Real websites are useful for finding assumptions your tests forgot.

For this classifier, we can use several public pages as fixtures because their routes represent different service families.

The examples include a body Endolift service page, a 360 body contouring service page, a forehead and frown line Botox service page, a lip filler service page, and a laser facial rejuvenation service page.

The purpose of these URLs is not to evaluate the provider or the procedures.

They are simply useful test documents because they expose several classification problems at once.

Two pages share the same injection folder but represent different modalities.

Two body related pages use different route families.

The facial laser page combines anatomy, technology, and treatment objective.

That makes the set useful for testing taxonomy logic.

Start with page observations

Before deciding what a page is, store what the parser observes.

from dataclasses import dataclass


@dataclass
class PageObservation:
    field: str
    value: str
    source: str
    confidence: float

Possible observations include:

PageObservation(
    field="route_group",
    value="cosmetic_injections",
    source="url",
    confidence=0.95,
)

PageObservation(
    field="modality",
    value="filler",
    source="title",
    confidence=0.85,
)

PageObservation(
    field="anatomy",
    value="lip",
    source="h1",
    confidence=0.90,
)

The classifier should not immediately collapse these observations into a final label.

First preserve them.

Then resolve them.

Parse the URL structure

Python makes path inspection straightforward.

from urllib.parse import urlparse
from urllib.parse import unquote


def path_segments(url):
    parsed = urlparse(url)
    decoded = unquote(parsed.path)

    return [
        segment
        for segment in decoded.split("/")
        if segment
    ]

For a route such as:

/services/cosmetic_injections/lip_filler/

we conceptually receive:

[
    "services",
    "cosmetic_injections",
    "lip_filler",
]

The route already provides two useful signals.

The parent folder indicates a content group.

The final segment suggests the individual procedure.

Neither signal should be treated as complete truth.

Represent route meaning separately

A route parser can extract candidate labels without classifying the page itself.

@dataclass
class RouteFeatures:
    root: str | None
    group: str | None
    slug: str | None

Then:

def route_features(url):
    segments = path_segments(url)

    if not segments:
        return RouteFeatures(
            root=None,
            group=None,
            slug=None,
        )

    root = (
        segments[0]
        if len(segments) >= 1
        else None
    )

    group = (
        segments[1]
        if len(segments) >= 2
        else None
    )

    slug = (
        segments[2]
        if len(segments) >= 3
        else None
    )

    return RouteFeatures(
        root=root,
        group=group,
        slug=slug,
    )

This layer describes the URL.

It does not yet decide the taxonomy.

Why this separation matters

Imagine the URL says:

body_contouring

but the page content describes a laser procedure.

Both facts are useful.

The route group can remain:

body_contouring

while the modality can independently become:

laser

There is no reason one value must overwrite the other.

The route tells us where the page lives.

The modality tells us how the service is performed.

These are different dimensions.

Extract the page title and headings

The next set of signals comes from visible document structure.

from bs4 import BeautifulSoup


def extract_document_features(html):
    soup = BeautifulSoup(
        html,
        "html.parser",
    )

    title = None

    if soup.title:
        title = soup.title.get_text(
            " ",
            strip=True,
        )

    h1 = None

    first_h1 = soup.find("h1")

    if first_h1:
        h1 = first_h1.get_text(
            " ",
            strip=True,
        )

    headings = []

    for tag in soup.find_all(
        ["h2", "h3"]
    ):
        value = tag.get_text(
            " ",
            strip=True,
        )

        if value:
            headings.append(value)

    return {
        "title": title,
        "h1": h1,
        "headings": headings,
    }

The H1 often deserves more weight than a navigation label.

The title is also useful, although it may contain branding or geographic modifiers.

Normalize text before classification

Real websites rarely use perfectly consistent text.

We should normalize before matching.

import re


def normalize_text(value):
    value = value.lower()
    value = value.replace("_", " ")
    value = value.replace("/", " ")

    value = re.sub(
        r"\s+",
        " ",
        value,
    )

    return value.strip()

For multilingual websites, additional Unicode normalization should happen here too.

The classifier should not depend on visual equality alone.

Define taxonomy vocabularies

Now we can define explicit vocabularies.

FAMILY_TERMS = {
    "body_contouring": {
        "body contouring",
        "body endolift",
    },
    "body_sculpting": {
        "body sculpting",
        "360 body contouring",
    },
    "cosmetic_injection": {
        "botox",
        "filler",
        "cosmetic injection",
    },
    "face_rejuvenation": {
        "facial rejuvenation",
        "laser rejuvenation",
    },
}

A second vocabulary can represent modality.

MODALITY_TERMS = {
    "laser": {
        "laser",
        "endolift",
    },
    "botulinum_toxin": {
        "botox",
        "botulinum",
    },
    "dermal_filler": {
        "filler",
    },
    "surgical_contouring": {
        "abdominoplasty",
    },
}

And anatomy can remain independent.

ANATOMY_TERMS = {
    "body": {
        "body",
        "abdomen",
        "waist",
    },
    "forehead": {
        "forehead",
    },
    "glabella": {
        "frown line",
        "glabella",
    },
    "lip": {
        "lip",
        "lips",
    },
    "face": {
        "face",
        "facial",
    },
}

This model allows one page to match several independent dimensions.

Create a generic matcher

We can use one matching function for all dictionaries.

def match_terms(
    text,
    taxonomy,
):
    normalized = normalize_text(
        text
    )

    matches = []

    for label, terms in taxonomy.items():
        for term in terms:
            if term in normalized:
                matches.append(label)
                break

    return matches

Now:

text = (
    "forehead botox and frown line"
)

print(
    match_terms(
        text,
        MODALITY_TERMS,
    )
)

can return:

botulinum_toxin

while anatomy detection can independently return:

forehead
glabella

That is exactly what a multidimensional taxonomy needs.

Combine several page signals

The route should not have the same weight as every other field.

We can assign weights.

SOURCE_WEIGHTS = {
    "route_group": 0.95,
    "slug": 0.90,
    "h1": 0.90,
    "title": 0.80,
    "heading": 0.65,
    "body": 0.45,
    "navigation": 0.30,
}

Now classifications can accumulate evidence.

from collections import defaultdict


def add_score(
    scores,
    label,
    source,
):
    weight = SOURCE_WEIGHTS.get(
        source,
        0,
    )

    scores[label] += weight

The goal is not to pretend the numbers are probabilities.

They are ranking signals.

Build a family classifier

def classify_family(
    observations
):
    scores = defaultdict(float)

    for item in observations:
        matches = match_terms(
            item.value,
            FAMILY_TERMS,
        )

        for label in matches:
            add_score(
                scores,
                label,
                item.source,
            )

    return dict(scores)

A page can now receive several candidate families.

The highest score may become the primary family.

Other meaningful matches can remain secondary labels.

Do not discard secondary categories

Suppose a body laser page matches both:

body_contouring

and:

face_rejuvenation

The second match may be wrong.

Or it may have been caused by a global navigation element.

This is why source provenance matters.

If the match came only from navigation, its weight should remain low.

If it appears in the H1 and URL, it is much stronger.

A classifier should explain its decision.

Return classification evidence

Instead of returning only a label:

"body_contouring"

return a richer object.

@dataclass
class ClassificationResult:
    primary: str | None
    secondary: list[str]
    scores: dict[str, float]
    evidence: list[PageObservation]

Now a developer can inspect why the page received its classification.

That makes debugging dramatically easier.

Classifying injectable pages

The two injection examples expose an important taxonomy problem.

Both belong to the same route family.

But one relates to botulinum toxin and the other to dermal filler.

A folder only classifier would treat them as identical.

A multidimensional classifier does not.

The first can conceptually resolve to:

ServiceClassification(
    family="cosmetic_injection",
    modality="botulinum_toxin",
    anatomy=[
        "forehead",
        "glabella",
    ],
    objective=[
        "dynamic_line_reduction",
    ],
    route_group="cosmetic_injections",
)

The second can become:

ServiceClassification(
    family="cosmetic_injection",
    modality="dermal_filler",
    anatomy=[
        "lip",
    ],
    objective=[
        "volume_modification",
    ],
    route_group="cosmetic_injections",
)

The route is the same.

The semantic identity is not.

Separate objective from modality

This distinction is useful outside medical websites too.

A modality tells us how something is done.

An objective tells us why it exists.

In ecommerce:

modality = subscription
objective = data_backup

In education:

modality = video_course
objective = python_learning

In this service taxonomy:

modality = laser
objective = tissue_contouring

Keeping these concepts separate makes downstream search much more flexible.

Build objective vocabulary

OBJECTIVE_TERMS = {
    "body_contouring": {
        "body contouring",
        "body shaping",
    },
    "skin_tightening": {
        "tightening",
        "skin laxity",
    },
    "dynamic_line_reduction": {
        "frown line",
        "forehead line",
    },
    "volume_modification": {
        "volume",
        "lip filler",
    },
    "facial_rejuvenation": {
        "facial rejuvenation",
        "skin rejuvenation",
    },
}

Again, this vocabulary should describe page intent.

It should not make medical effectiveness claims.

Create one feature object

Once all fields are extracted, a page can be represented with one feature container.

@dataclass
class PageFeatures:
    url: str
    route_group: str | None
    slug: str | None
    title: str | None
    h1: str | None
    headings: list[str]
    body_text: str | None

This becomes the input to every classifier.

The parser is responsible for extracting features.

The taxonomy layer is responsible for meaning.

Keep parsing and classification separate

This architectural separation matters.

The parser should not contain logic such as:

if "botox" in title:
    category = "cosmetic_injection"

That couples HTML parsing to taxonomy rules.

A better architecture looks like:

Fetcher

HTML Parser

Feature Extractor

Normalizer

Taxonomy Matcher

Confidence Resolver

Classification Store

Each component has one responsibility.

Extract body text carefully

Body text provides useful fallback signals.

It also contains the most noise.

def visible_body_text(html):
    soup = BeautifulSoup(
        html,
        "html.parser",
    )

    for tag in soup(
        [
            "script",
            "style",
            "noscript",
        ]
    ):
        tag.decompose()

    return soup.get_text(
        " ",
        strip=True,
    )

This is acceptable for a prototype.

For production systems, section aware extraction is usually better.

Navigation, footer text, related pages, and repeated templates can distort classification.

Give content sections different weights

A page section near the main heading should often count more than footer text.

We can extend observations.

@dataclass
class PageObservation:
    field: str
    value: str
    source: str
    confidence: float
    section: str | None = None

Then section weights can be added.

SECTION_WEIGHTS = {
    "hero": 1.0,
    "main_content": 0.9,
    "faq": 0.7,
    "related_services": 0.4,
    "navigation": 0.3,
    "footer": 0.2,
}

This reduces false matches caused by global site elements.

Why navigation creates classification errors

Imagine a Botox page whose navigation contains links to:

lip filler
laser rejuvenation
body contouring

A naive keyword scanner may classify the page into all four service families.

That is obviously wrong.

The mistake comes from ignoring document structure.

The keyword itself is not the problem.

The source of the keyword is.

Build a section aware score

def weighted_score(
    source,
    section,
):
    source_weight = SOURCE_WEIGHTS.get(
        source,
        0,
    )

    section_weight = SECTION_WEIGHTS.get(
        section,
        1,
    )

    return (
        source_weight
        * section_weight
    )

Now a term appearing in the hero section matters more than the same term appearing in the footer.

Distinguishing similar body categories

Body contouring and body sculpting illustrate another difficult case.

Humans understand that these concepts can overlap.

A rigid classifier may try to force them into unrelated categories.

A better taxonomy can introduce hierarchy.

TAXONOMY = {
    "body_services": {
        "body_contouring",
        "body_sculpting",
    },
    "cosmetic_injections": {
        "botulinum_toxin",
        "dermal_filler",
    },
    "facial_energy_treatments": {
        "laser_rejuvenation",
    },
}

Now related categories share a parent.

Search can operate at either level.

Parent categories improve recall

If a user searches for:

body services

both body related pages can match.

If the search is specifically:

body sculpting

only the more precise child category needs a high score.

Hierarchical taxonomies give search systems both precision and recall.

Create canonical service identifiers

Labels change.

Internal identifiers should not.

Instead of using display text as primary keys, define stable IDs.

SERVICE_IDS = {
    "svc_body_contouring": {
        "label": "Body Contouring",
    },
    "svc_body_sculpting": {
        "label": "Body Sculpting",
    },
    "svc_botulinum_toxin": {
        "label": "Botulinum Toxin",
    },
    "svc_dermal_filler": {
        "label": "Dermal Filler",
    },
    "svc_laser_rejuvenation": {
        "label": "Laser Rejuvenation",
    },
}

The visible label can change later.

The identifier remains stable.

Taxonomy versioning is important

Taxonomies evolve.

Today the system may have:

body_contouring

Tomorrow the product team may split it into:

laser_body_contouring
surgical_body_contouring
device_body_contouring

Without versioning, historical classifications become difficult to interpret.

@dataclass
class TaxonomyVersion:
    version: str
    created_at: str
    labels: dict

Every classification result should know which taxonomy version produced it.

Store classification provenance

A resolved category should preserve its evidence.

@dataclass
class ResolvedCategory:
    label: str
    score: float
    sources: list[str]
    taxonomy_version: str

For example:

ResolvedCategory(
    label="dermal_filler",
    score=2.65,
    sources=[
        "route_group",
        "slug",
        "h1",
    ],
    taxonomy_version="1.0",
)

Now classification is explainable.

Multi label classification is often better

A common modeling mistake is forcing exactly one category.

Real service pages rarely behave that neatly.

A facial laser page may legitimately be:

face
laser
rejuvenation
energy_based_service

These are not competing labels.

They describe different properties.

A better API could return:

{
    "family": [
        "face_rejuvenation"
    ],
    "modality": [
        "laser"
    ],
    "anatomy": [
        "face"
    ],
    "objective": [
        "facial_rejuvenation"
    ]
}

This representation is more useful than:

{
    "category": "laser"
}

Search becomes easier with dimensions

Once taxonomy dimensions exist, users can query combinations.

For example:

family = cosmetic_injection
anatomy = lip

or:

modality = laser
anatomy = body

This supports faceted search without inventing hundreds of manually curated categories.

Create an inverted index

A small prototype can build an index in memory.

from collections import defaultdict


def build_index(records):
    index = defaultdict(set)

    for page_id, record in records.items():

        if record.family:
            index[
                ("family", record.family)
            ].add(page_id)

        if record.modality:
            index[
                ("modality", record.modality)
            ].add(page_id)

        for anatomy in record.anatomy:
            index[
                ("anatomy", anatomy)
            ].add(page_id)

        for objective in record.objective:
            index[
                ("objective", objective)
            ].add(page_id)

    return index

The same principle scales to Elasticsearch, OpenSearch, Typesense, Meilisearch, or a relational database.

Detect uncertain classifications

A useful classifier should know when it is unsure.

def classification_margin(
    scores,
):
    ordered = sorted(
        scores.values(),
        reverse=True,
    )

    if len(ordered) < 2:
        return None

    return ordered[0] - ordered[1]

If two categories have nearly identical scores, the page may require manual review.

The goal should not be to force confidence where none exists.

Add review states

REVIEW_STATES = {
    "auto_approved",
    "needs_review",
    "manually_approved",
    "rejected",
}

A classification with a strong margin may be automatically accepted.

An ambiguous page can enter a review queue.

def review_state(
    confidence,
    margin,
):
    if (
        confidence >= 0.85
        and margin is not None
        and margin >= 0.25
    ):
        return "auto_approved"

    return "needs_review"

The exact thresholds should come from evaluation data.

Build an evaluation dataset

No classifier should be tuned entirely by intuition.

Create a labeled fixture set.

@dataclass
class LabeledPage:
    url: str
    expected_family: str
    expected_modality: str

Then evaluate predictions.

def accuracy(
    expected,
    predicted,
):
    correct = 0

    for expected_value, predicted_value in zip(
        expected,
        predicted,
    ):
        if expected_value == predicted_value:
            correct += 1

    return correct / len(expected)

This is the simplest possible metric.

For multi label classification, precision, recall, and F1 become more useful.

Measure confusion between categories

A classifier may repeatedly confuse:

body_contouring

with:

body_sculpting

That error is more informative than raw accuracy.

from collections import Counter


def confusion_pairs(
    expected,
    predicted,
):
    pairs = Counter()

    for truth, guess in zip(
        expected,
        predicted,
    ):
        if truth != guess:
            pairs[
                (truth, guess)
            ] += 1

    return pairs

Frequent confusion usually means either the classifier needs better features or the taxonomy itself is too ambiguous.

Sometimes the taxonomy is the bug

Developers often assume misclassification means the model is weak.

Sometimes the categories are poorly designed.

If two labels cannot be consistently distinguished by humans, software will struggle too.

A good taxonomy should have clear semantic boundaries.

Document those boundaries.

For example:

CATEGORY_RULES = {
    "body_contouring": (
        "Non surgical or minimally invasive "
        "body contour related services"
    ),
    "body_sculpting": (
        "Procedures primarily organized around "
        "larger scale body shape modification"
    ),
}

The exact definitions depend on the product.

The important thing is that definitions exist.

Do not infer effectiveness from taxonomy

A page being classified as:

laser_rejuvenation

means only that the page is about that service family.

It does not tell us whether the treatment is effective.

It does not tell us whether the provider is appropriate for a patient.

It does not tell us whether one service is better than another.

Taxonomy is descriptive.

Clinical evaluation is a different problem.

Keeping those layers separate is essential in health related software.

The same applies to rankings

A classifier can state:

This page belongs to cosmetic injections.

It cannot logically conclude:

This is the best cosmetic injection provider.

Ranking requires an independent methodology and a separate data model.

This principle applies beyond health care.

Product categorization is not product quality.

Restaurant categorization is not restaurant quality.

Course categorization is not teaching quality.

Classification and ranking should never be silently merged.

Add structured data as another signal

Many service websites expose JSON LD.

import json


def extract_json_ld(html):
    soup = BeautifulSoup(
        html,
        "html.parser",
    )

    blocks = []

    for script in soup.select(
        'script[type="application/ld+json"]'
    ):
        if not script.string:
            continue

        try:
            blocks.append(
                json.loads(
                    script.string
                )
            )
        except json.JSONDecodeError:
            pass

    return blocks

Structured data may contain service names, organization information, breadcrumbs, or page types.

These signals can improve classification.

They should not automatically override visible content.

Breadcrumbs often expose the intended site hierarchy.

If a page contains:

Services
Cosmetic Injections
Lip Filler

that is strong evidence about content organization.

A breadcrumb parser can convert it into observations.

def breadcrumb_observations(
    items,
):
    results = []

    for index, value in enumerate(
        items
    ):
        results.append(
            PageObservation(
                field="breadcrumb",
                value=value,
                source="breadcrumb",
                confidence=0.85,
            )
        )

    return results

Again, the final classifier combines signals rather than trusting one field.

Detect taxonomy drift

Websites evolve.

A page may move from:

body_contouring

to:

body_sculpting

without changing much of its content.

That can be intentional.

It can also expose a structural inconsistency.

We can compare historical classification.

@dataclass
class ClassificationSnapshot:
    url: str
    family: str
    taxonomy_version: str
    observed_at: str

If the family changes unexpectedly, create an alert.

Content migrations benefit from this model

Imagine migrating hundreds of pages to a new CMS.

If taxonomy is derived only from old folder structure, existing mistakes are copied into the new platform.

A classifier that uses route, title, H1, breadcrumbs, structured data, and content sections can detect inconsistencies before migration.

This makes taxonomy classification useful beyond search.

It becomes a content quality tool.

Build page fingerprints

A crawler does not need to reclassify every unchanged page on every run.

import hashlib


def page_fingerprint(html):
    return hashlib.sha256(
        html.encode("utf8")
    ).hexdigest()

Store the fingerprint with the classification.

If the document has not changed and the taxonomy version has not changed, the previous result may still be valid.

Reclassify when taxonomy changes

Even an unchanged page may need reclassification when the taxonomy changes.

The cache key should therefore include both values.

Conceptually:

cache_key = (
    page_fingerprint(html),
    taxonomy_version,
)

This prevents old classification logic from surviving after a taxonomy update.

Add parser versioning too

The same principle applies to parsing.

PARSER_VERSION = "1.0"
TAXONOMY_VERSION = "1.0"

A stored record should contain both.

@dataclass
class StoredClassification:
    url: str
    parser_version: str
    taxonomy_version: str
    result: ServiceClassification

Now data changes can be explained.

Create unit tests for route classification

def test_injection_route():
    url = (
        "https://example.com/"
        "services/"
        "cosmetic_injections/"
        "lip_filler/"
    )

    features = route_features(
        url
    )

    assert (
        features.group
        == "cosmetic_injections"
    )

A separate test can validate the procedure slug.

def test_lip_filler_slug():
    url = (
        "https://example.com/"
        "services/"
        "cosmetic_injections/"
        "lip_filler/"
    )

    features = route_features(
        url
    )

    assert (
        features.slug
        == "lip_filler"
    )

These small tests protect assumptions that other components depend on.

Test semantic dimensions independently

def test_filler_modality():
    text = "Lip filler treatment"

    matches = match_terms(
        text,
        MODALITY_TERMS,
    )

    assert (
        "dermal_filler"
        in matches
    )

And:

def test_lip_anatomy():
    text = "Lip filler treatment"

    matches = match_terms(
        text,
        ANATOMY_TERMS,
    )

    assert "lip" in matches

This is much cleaner than testing one huge classification function.

Explain every classification

An explainable result might look like this:

{
    "family": {
        "value": "cosmetic_injection",
        "evidence": [
            "route_group",
            "breadcrumb",
            "h1",
        ],
    },
    "modality": {
        "value": "dermal_filler",
        "evidence": [
            "slug",
            "title",
            "h1",
        ],
    },
    "anatomy": {
        "values": [
            "lip"
        ],
        "evidence": [
            "slug",
            "h1",
        ],
    },
}

This is much easier to debug than a black box label.

A clean production architecture

The final system might contain these components:

Fetcher

HTML Parser

Route Parser

Text Normalizer

Structured Data Parser

Feature Store

Taxonomy Matcher

Confidence Resolver

Review Queue

Classification Store

Search Index

Quality Monitor

Each component has one clear responsibility.

The fetcher retrieves content.

The parsers extract signals.

The normalizer makes signals comparable.

The feature store preserves observations.

The matcher proposes categories.

The resolver calculates confidence.

The review queue handles ambiguity.

The classification store keeps resolved data.

The search index exposes the taxonomy.

The quality monitor detects drift.

Why this architecture works

The biggest improvement comes from refusing to treat taxonomy as a property of the URL alone.

A service page is represented by several independent signals.

Route structure describes site organization.

The slug describes page specificity.

The heading describes page intent.

Breadcrumbs describe hierarchy.

Structured data describes machine readable entities.

Body sections add supporting context.

None of these sources is perfect.

Together they create a much stronger representation.

Final thoughts

Complex websites do not really have one taxonomy.

They usually contain several overlapping taxonomies.

Content type is one dimension.

Procedure or product family is another.

Technology or modality can be another.

Anatomy, audience, use case, objective, geography, and commercial category may all exist independently.

Trying to compress all of that into one folder name eventually creates problems.

A stronger system collects evidence first and classifies later.

The core principles are straightforward.

Treat routes as signals rather than truth.

Preserve source provenance.

Separate parsing from classification.

Use multidimensional labels.

Keep stable internal identifiers.

Model hierarchy explicitly.

Give document sections different weights.

Allow multiple valid categories.

Detect uncertainty.

Create a manual review path.

Version the taxonomy.

Version the parser.

Test categories independently.

Measure confusion between labels.

Keep classification separate from quality or ranking.

Once those foundations exist, the same architecture can power site search, content migration, internal linking systems, navigation generation, analytics, knowledge graphs, recommendation engines, and large scale content audits.

The difficult part is not assigning a label.

The difficult part is designing labels that still make sense when the website becomes ten times larger.