Taxonomy & Tagging Guide
Companion to CONTENT_AUTOTAGGING_GUIDE.md. That guide covers how content gets ingested, classified, and bridged into tags. This guide covers the tag taxonomy itself — how it is shaped, scoped, governed, grown, embedded, reviewed, and pruned. Read both before doing serious work in this area.
Overview
Section titled “Overview”MemberJunction ships a unified, metadata-driven tag taxonomy that powers:
- Automatic classification of ingested content (the autotagger)
- Semantic search across business records via per-tag embeddings
- Faceted navigation in Knowledge Hub and other consumers
- Multi-tenant scoping so one customer’s tags do not bleed into another’s
- Human-in-the-loop governance with a review queue, ancestor-walk policy, and audit log
The taxonomy is intentionally opinionated about what is a tag and how tags get created, but flexible enough to span the full spectrum from “a hand-curated list of 80 categories nobody can change” through to “every noun the LLM saw becomes a tag.” Every customer picks where on that spectrum they sit, per content source.
Audience. Engineers building on top of the tag system, customer-success engineers configuring it for a tenant, and AI/ML engineers tuning the classifier and review queue.
Mental model
Section titled “Mental model”There are five core entities you must hold in your head simultaneously. They form a polymorphic chain that is easy to misread on first contact, so spend a minute on this section.
erDiagram
Tag ||--o{ Tag : "ParentID (hierarchy)"
Tag ||--o{ TagScope : "scope rows"
Tag ||--o{ TagSynonym : "alternate names"
Tag ||--o{ TagSuggestion : "BestMatchTagID"
Tag ||--o{ TaggedItem : "applied to entity records"
Tag ||--o{ ContentItemTag : "raw classifier output"
Tag ||--o{ TagAuditLog : "governance trail"
Tag ||--o{ TagCoOccurrence : "TagAID / TagBID"
ContentItem ||--o{ ContentItemTag : "tags found in this item"
ContentItem }o--|| ContentSource : "owned by"
TagScope }o--|| Entity : "ScopeEntityID"
TaggedItem }o--|| Entity : "EntityID"
Tag {
uuid ID PK
nvarchar Name
uuid ParentID FK
nvarchar Status "Active|Merged|Deprecated|Deleted"
bit IsGlobal
bit AllowAutoGrow
bit IsFrozen
int MaxChildren
int MaxDescendantDepth
decimal MinWeight
bit RequiresReview
nvarchar EmbeddingVector "JSON-encoded float array"
uuid EmbeddingModelID FK
}
ContentItemTag {
uuid ID PK
uuid ItemID FK
nvarchar Tag "raw free text from LLM"
uuid TagID FK "resolved Tag, NULL when unresolved"
decimal Weight
}
TaggedItem {
uuid ID PK
uuid TagID FK
uuid EntityID FK
nvarchar RecordID "polymorphic record key"
decimal Weight
}
TagScope {
uuid ID PK
uuid TagID FK
uuid ScopeEntityID FK
nvarchar ScopeRecordID "polymorphic"
}
TagSynonym {
uuid ID PK
uuid TagID FK
nvarchar Synonym
nvarchar Source "Manual|LLM|Imported|Merged"
}
TagSuggestion {
uuid ID PK
nvarchar ProposedName
uuid ProposedParentID FK
uuid BestMatchTagID FK
decimal BestMatchScore
nvarchar Reason
nvarchar Status "Pending|Approved|Merged|Rejected"
}
The cast in plain English
Section titled “The cast in plain English”| Entity | Role | Lifetime |
|---|---|---|
MJ:Tags | The taxonomy node itself: a name, a parent, governance flags, an embedding. The “noun” everything else points at. | Persistent. Soft-deleted via Status. |
MJ:Content Items | One ingested unit (a row, a doc, a feed entry). Owned by a ContentSource. | Persistent; updated when source content changes. |
MJ:Content Item Tags | What the LLM said about a ContentItem — raw, free-text tag plus a weight. Has a TagID FK that gets populated when (and only when) the bridge resolves the free text to a formal Tag. | Persistent; rebuilt when an item is reprocessed. |
MJ:Tagged Items | The polymorphic application of a Tag to any entity record (EntityID, RecordID). This is the table the rest of MJ queries when it asks “what tags does record X have?” | Persistent; the read surface. |
MJ:Tag Scopes | Polymorphic visibility rows for a non-global tag. A tag with one or more scope rows is visible only inside those (Entity, Record) scopes. | Persistent. |
MJ:Tag Synonyms | Alternate names that resolve to the same Tag. Consulted before any new-tag creation path. | Persistent. |
MJ:Tag Suggestions | The human-in-the-loop queue. Anything that would have been auto-created but was governance-blocked or in the suggestion threshold band lands here. | Pending → Approved/Merged/Rejected. |
MJ:Tag Audit Logs | Append-only history of every governance action. | Persistent forever. |
MJ:Tag Co Occurrences | Computed pair counts (how often Tag A and Tag B appear on the same item). Powers Tag Health and the suggestion emitters. | Recomputed after each autotag run. |
The two-layer write model
Section titled “The two-layer write model”There is one detail people miss on first pass: the autotagger writes to ContentItemTag before it knows whether the free-text tag will resolve to a formal Tag. The TagID column on ContentItemTag is intentionally nullable. This dual-storage shape means:
- Raw classifier output is never lost, even when governance rejects the tag, the threshold is too low, or the taxonomy is constrained and there is no match.
- Re-running the bridge after a taxonomy change can pick up previously-unresolved free-text tags and link them retroactively.
- The suggestion queue can show the source text that drove a proposal, because the
ContentItemTagrow is still there.
When TagID does get populated, a parallel row is also written to TaggedItem for the underlying entity record. That is the row Knowledge Hub, the tag facet, and consumer apps actually query. See TagEngineBase.CreateTaggedItem().
The autotag pipeline
Section titled “The autotag pipeline”The pipeline is orchestrated by AutotagBaseEngine with per-source-type providers (AutotagEntity, AutotagRSS, AutotagWebsite, etc.) registered via the ClassFactory. Per-source-type details live in CONTENT_AUTOTAGGING_GUIDE.md; here we focus on what happens to a tag as it flows through.
flowchart TD
subgraph Ingestion["1. Ingestion"]
S[ContentSource] --> CI[ContentItem rows]
CI --> Text[Text extraction]
end
subgraph Classify["2. LLM classification"]
Text --> Prompt["Content Autotagging prompt<br/>(taxonomy injected)"]
Prompt --> LLM[AIPromptRunner]
LLM --> Raw["Free-text tags<br/>+ weights<br/>+ optional parent"]
end
subgraph Persist["3. Persist raw output"]
Raw --> CIT["MJ:Content Item Tags<br/>Tag=text, TagID=NULL"]
end
subgraph Bridge["4. Bridge (per saved ContentItemTag)"]
CIT --> Hook{"OnContentItemTagSaved<br/>callback fires"}
Hook --> Resolve["TagEngine.ResolveTag()<br/>(see 4+1 tiers below)"]
Resolve --> Decision{Outcome}
Decision -->|matched / created| Apply[Set ContentItemTag.TagID]
Apply --> TI["MJ:Tagged Items row<br/>(polymorphic entity link)"]
Decision -->|governance blocked<br/>or threshold band| Sugg["MJ:Tag Suggestions row<br/>TagID stays NULL"]
Decision -->|constrained miss| Skip[No tag link]
end
subgraph Post["5. Post-run analytics"]
TI --> Co[TagCoOccurrenceEngine recompute]
Co --> Health[TagHealthJob emits suggestions]
end
style Hook fill:#f9f,stroke:#333
style Sugg fill:#ffe,stroke:#333
The OnContentItemTagSaved plugin point is the seam where everything interesting happens. It is declared at AutotagBaseEngine.ts:766, wired up at AutotagBaseEngine.ts:384, and invoked at AutotagBaseEngine.ts:807. The default implementation calls into AutotagEntity.BridgeContentItemTagToTaxonomy(), which reads per-source config and delegates to TagEngine.ResolveTag().
If you want to intercept the bridge — for example to add custom governance, route to a different review surface, or attach domain-specific metadata to a TaggedItem — replace the callback. Do not subclass BridgeContentItemTagToTaxonomy; the seam is the callback.
Tag resolution: the 4+1 tiers
Section titled “Tag resolution: the 4+1 tiers”TagEngine.ResolveTag() (TagEngine.ts:385) is the heart of the system. It maps a piece of free text from the LLM (or any other producer) to a formal MJ:Tag record, falling through five tiers in strict order. Concurrent calls are serialized by an internal mutex so parallel batch processing cannot create duplicates.
flowchart TD
Start(["ResolveTag(text, weight, mode, rootID,<br/>thresholds, scopeContext)"]) --> T0{"Tier 0:<br/>Synonym<br/>Map lookup"}
T0 -->|hit| RetSyn[Return matched Tag]
T0 -->|miss| T1{"Tier 1:<br/>Exact name<br/>(case-insensitive)"}
T1 -->|hit| Subtree1{In rootID subtree?}
Subtree1 -->|yes / no rootID| RetExact[Return matched Tag]
Subtree1 -->|no| T2{"Tier 2:<br/>Fuzzy normalize<br/>(plurals, hyphens)"}
T1 -->|miss| T2
T2 -->|hit| Subtree2{In rootID subtree?}
Subtree2 -->|yes / no rootID| RetFuzzy[Return matched Tag]
Subtree2 -->|no| T3
T2 -->|miss| T3{"Tier 3: Semantic<br/>cosine similarity<br/>against tag vectors"}
T3 --> Score{Score band}
Score -->|s ≥ MatchThreshold| RetSem[Return matched Tag]
Score -->|SuggestThreshold ≤ s < MatchThreshold| Enq1["Enqueue TagSuggestion<br/>Reason='BelowThreshold'<br/>Return null"]
Score -->|s < SuggestThreshold| T4
T3 -->|no vector service| T4
T4["Tier 4: handleNoMatch — branch on TaxonomyMode"] --> Mode{Mode}
Mode -->|constrained| EnqC["Enqueue, Reason='ConstrainedMode'<br/>Return null"]
Mode -->|hybrid| EnqH["Enqueue, Reason from governance<br/>Return null"]
Mode -->|auto-grow / free-flow| Gov{"ValidateAutoGrow<br/>(ancestor walk)"}
Gov -->|blocked| EnqG["Enqueue with governance Reason<br/>Return null"]
Gov -->|ok| Create["createAndEmbedTag<br/>(snapshot scope from parent)"]
Create --> RetNew[Return new Tag]
style T0 fill:#e0f7fa
style T3 fill:#fff3e0
style Enq1 fill:#fff9c4
style EnqC fill:#fff9c4
style EnqH fill:#fff9c4
style EnqG fill:#fff9c4
Why tier 0 (synonym) is first
Section titled “Why tier 0 (synonym) is first”Synonyms are the cheapest, most precise signal. A curated synonym "Artificial Intelligence" → AI is unambiguous and authoritative; running it through fuzzy or semantic matching is wasted work and can lose. Synonyms also let you absorb merge history: when you MergeTags(['Old Name'], 'New ID'), the merge writes a Source='Merged' synonym row so any future free-text “Old Name” still resolves correctly. See TagGovernanceEngine.MergeTags().
The two thresholds
Section titled “The two thresholds”TagMatchThreshold (default 0.9) is the auto-apply floor. SuggestThreshold (default TagMatchThreshold - 0.05) is the route-to-review floor. Anything below SuggestThreshold falls through to the auto-create / suggest path determined by TagTaxonomyMode. The band between the two is what makes the whole system feel responsive without being rash:
| Score | Action | Reason |
|---|---|---|
s ≥ MatchThreshold (e.g. ≥ 0.90) | Auto-apply | High-confidence match |
SuggestThreshold ≤ s < MatchThreshold (e.g. 0.85–0.89) | Suggestion | BelowThreshold |
s < SuggestThreshold (e.g. < 0.85) | Fall through to mode-based handling | — |
Lower the band and you trade precision for recall (more auto-applied, fewer reviewed). Raise it and you trade recall for precision (more in the queue, fewer applied). Tune per content source.
What “create” actually means
Section titled “What “create” actually means”createAndEmbedTag() does four things atomically (under the resolution mutex):
- Calls
TagEngineBase.CreateTag()to insert and save theMJ:Tagrow. - Snapshots the parent’s
TagScoperows to the child (or marks the childIsGlobal=1if the parent is global). - Embeds the new tag’s name + description and writes the JSON vector +
EmbeddingModelIDback to the row viaSave(). - Inserts the embedding into the in-memory
SimpleVectorServiceso subsequent calls in the same run can match it.
Taxonomy modes
Section titled “Taxonomy modes”Each ContentSource picks one of four modes via IContentSourceConfiguration.TagTaxonomyMode. The mode controls only what happens at Tier 4 (no match). Tiers 0–3 always run.
stateDiagram-v2
[*] --> Constrained
[*] --> AutoGrow
[*] --> FreeFlow
[*] --> Hybrid
Constrained: Constrained\nNo new tags ever\nAll misses → suggestion queue\n(Reason='ConstrainedMode')
AutoGrow: AutoGrow\nNew tags only under TagRootID\nGovernance gate per parent\nBlocked → suggestion queue
FreeFlow: FreeFlow\nNew tags anywhere (root level)\nMinimal governance\nPower mode for bootstrapping
Hybrid: Hybrid\nNo auto-creation\nAll auto-create paths route\nthrough suggestion queue
| Mode | New tags? | Constrained to subtree? | Governance gate runs? | When to use |
|---|---|---|---|---|
constrained | Never | N/A | N/A | Curated taxonomy where the customer has signed off on the exact set. Misses become suggestions for the steward to approve. |
auto-grow | Yes, under TagRootID | Yes (semantic search and creation both bounded by the subtree) | Yes — full ancestor walk | The 80% case. Gives the LLM permission to extend the taxonomy where you have said it’s safe. |
free-flow | Yes, anywhere (no parent) | No | Light | Bootstrapping a brand-new taxonomy from a corpus. Run once, review, then convert to constrained or auto-grow. |
hybrid | Never auto; only via approved suggestions | N/A | Suggestion-routed | Production taxonomies where every new node demands human review, but you still want similarity matching and below-threshold reviews to flow. |
Mode is read once per content source per run from the typed _IContentSourceConfiguration accessor and threaded through ResolveTag().
Per-tag governance
Section titled “Per-tag governance”The taxonomy mode is set per content source. Governance flags are set per tag node and are inherited via ancestor walk. Both must agree for an auto-create to happen.
| Field | Meaning | What it blocks |
|---|---|---|
IsGlobal | When 1, visible to every scope. Mutually exclusive with TagScope rows. | Cannot coexist with TagScope rows (enforced in entity Save() overrides). |
AllowAutoGrow | When 0, autotagger may not auto-create children under this tag. | Tier 4 auto-create when this is the proposed parent → suggestion Reason='AutoGrowDisabled'. |
IsFrozen | When 1, no auto-creation in this subtree. Inherited downward. | Any auto-create where any ancestor has IsFrozen=1 → Reason='ParentFrozen'. |
MaxChildren | Cap on direct children of this tag. | Auto-create when proposed parent is at cap → Reason='MaxChildrenExceeded'. |
MaxDescendantDepth | Cap on descendant depth from this tag. Inherited. | Auto-create that would exceed any ancestor’s depth limit → Reason='MaxDepthExceeded'. |
MinWeight | Per-node confidence floor for applying the tag. | Items below this weight → Reason='BelowMinWeight'. |
RequiresReview | When 1, every classifier hit on this tag goes through review. | All applies → Reason='RequiresReview'. |
The ancestor walk
Section titled “The ancestor walk”When the resolver wants to auto-create a child C under proposed parent P, it walks the ancestor chain of P evaluating each policy:
flowchart LR
C[Proposed child C] --> P[Proposed parent P]
P --> A1[Ancestor A1]
A1 --> A2[Ancestor A2]
A2 --> Root[Root]
P -. checks AllowAutoGrow,<br/>MaxChildren .-> Gate{Per-node gates}
A1 -. checks IsFrozen,<br/>MaxDescendantDepth .-> Gate
A2 -. checks IsFrozen,<br/>MaxDescendantDepth .-> Gate
Root -. checks IsFrozen,<br/>MaxDescendantDepth .-> Gate
Gate -->|all pass| OK[Create C]
Gate -->|any fail| Block[Suggestion w/ first-fail Reason]
The first failing rule wins. Order of checks (per spec): IsFrozen → AllowAutoGrow → MaxChildren → MaxDescendantDepth → MinWeight. The Reason written to the suggestion is the exact code listed in the table above, and the ancestor that triggered the block is captured in ReviewerNotes (or in a structured detail field — see the Save hook implementation).
RequiresReview is special: it is checked at apply time (after a successful resolution), not at create time. A successful Tier 1 hit on a tag with RequiresReview=1 still routes through the queue.
Hybrid mode + RequiresReview
Section titled “Hybrid mode + RequiresReview”When mode is hybrid and the resolved tag has RequiresReview=1, the suggestion is written exactly once with Reason='RequiresReview' (it takes precedence over Reason='AmbiguousMatch' in this case). This is documented at the routing site in TagEngine.ts.
Scoping model
Section titled “Scoping model”Scopes turn a single shared taxonomy into a per-tenant or per-context taxonomy without forking it. The shape mirrors the polymorphic MJ:Tagged Items table you already know: (ScopeEntityID, ScopeRecordID) is a polymorphic pointer to any record in any entity.
The invariants
Section titled “The invariants”Tag.IsGlobal⊕TagScoperows. A tag is either global (everyone sees it) or scoped (only the listed scopes see it). Never both. Enforced in entitySave()overrides on bothMJ:TagsandMJ:Tag Scopes.- Children inherit parent scope by default. When the autotagger creates a child under a non-global parent, the parent’s
TagScoperows are snapshotted (copied, not linked) onto the child. When the parent is global, the child is createdIsGlobal=1. - No widen. A child’s scope cannot be a strict superset of its parent’s. Promotion to global is admin-only and goes through
TagScopeFilterBuilder.validateChildScope(). - The only DB-level guard is the unique constraint on
TagScope (TagID, ScopeEntityID, ScopeRecordID). All other invariants are enforced by entitySave()overrides — cleaner errors, multi-provider safe, no SQL trigger debugging.
Visibility filter SQL shape
Section titled “Visibility filter SQL shape”The conceptual filter is:
Status = 'Active'AND ( IsGlobal = 1 OR ID IN ( SELECT TagID FROM TagScope WHERE (ScopeEntityID, ScopeRecordID) IN (... caller's scope set ...) ))TagScopeFilterBuilder.buildVisibilityFilter() produces this predicate at runtime. The composite index IDX_TagScope_Scope_Tag (ScopeEntityID, ScopeRecordID, TagID) is the critical hot-path index.
Multi-tenant taxonomy example
Section titled “Multi-tenant taxonomy example”graph TD
subgraph Global["Global subtree (visible everywhere)"]
Root["Industry<br/>IsGlobal=1"]
Root --> Tech["Technology<br/>IsGlobal=1"]
Root --> Fin["Finance<br/>IsGlobal=1"]
end
subgraph TenantA["Acme Corp (scope: Account=Acme)"]
Tech --> AcmeA["Acme Internal Codes<br/>IsGlobal=0<br/>TagScope: Account/Acme"]
AcmeA --> Acme1[Project Stardust]
AcmeA --> Acme2[Project Lightning]
end
subgraph TenantB["BetaCo (scope: Account=BetaCo)"]
Tech --> BetaA["BetaCo Internal Codes<br/>IsGlobal=0<br/>TagScope: Account/BetaCo"]
BetaA --> Beta1[Initiative Alpha]
BetaA --> Beta2[Initiative Beta]
end
style Global fill:#e8f5e9
style TenantA fill:#e3f2fd
style TenantB fill:#fce4ec
A Knowledge Hub query running with Acme’s scope context sees: Industry, Technology, Finance, Acme Internal Codes, Project Stardust, Project Lightning. It does not see BetaCo Internal Codes or its descendants. The classifier prompt for an Acme-scoped content source is built from the same filtered tree, so the LLM is never even tempted to suggest a BetaCo tag.
Synonyms
Section titled “Synonyms”Synonyms are the first thing the resolver checks, before exact match. They turn taxonomy churn into a non-event: rename a tag, run merge — old free text still resolves.
| Source | When written | By whom |
|---|---|---|
Manual | Steward types into the taxonomy admin UI | Human |
LLM | LLM volunteered “also known as” output during a generation pass | Classifier |
Imported | Bulk-loaded from an external taxonomy file | Sync / migration |
Merged | TagGovernanceEngine.MergeTags() records the source tag’s Name + every source TagSynonym row as synonyms of the surviving tag | Governance op |
Synonyms are loaded once at Config() time into a Map<lowercased synonym, TagID> and consulted via GetTagBySynonym(synonym, ctx?). Uniqueness is enforced per-tag via UQ_TagSynonym_Tag_Synonym.
Always seed synonyms generously. “AI” / “Artificial Intelligence” / “ML” / “Machine Learning” — different teams will say each. The synonym table is your shock absorber.
The suggestion queue (MJ:Tag Suggestions)
Section titled “The suggestion queue (MJ:Tag Suggestions)”The suggestion queue is the single human-in-the-loop surface for everything ambiguous. Three independent producers feed it:
- The classifier resolver when a match is in the suggest band, governance is blocked, or the mode is constrained / hybrid.
- The
TagHealthJobpost-run analytics: merge candidates, low-usage deprecation candidates, wide-node alerts. - Manual operators who need a structured way to propose changes.
stateDiagram-v2
[*] --> Pending: Producer writes row
Pending --> Approved: PromoteSuggestion (accept-as-new)
Pending --> Merged: PromoteSuggestion (BestMatchTagID, accept-as-merge)
Pending --> Rejected: RejectSuggestion(reviewerNotes)
Approved --> [*]
Merged --> [*]
Rejected --> [*]
note right of Approved
Creates new MJ:Tag under
ProposedParentID
ResolvedTagID set to new ID
end note
note right of Merged
Re-points existing
ContentItemTag rows
where Tag = ProposedName
to BestMatchTagID
ResolvedTagID = BestMatchTagID
end note
Reason vocabulary
Section titled “Reason vocabulary”Reason is a free-form NVARCHAR(50) for forward compatibility, but the conventional values are documented in the column’s MS_Description and in TagSuggestion’s zod schema:
| Reason | Producer | Meaning |
|---|---|---|
ConstrainedMode | Resolver (Tier 4, mode=constrained) | LLM emitted a tag that doesn’t exist; mode forbids creation. |
BelowThreshold | Resolver (Tier 3) | Semantic match landed in [SuggestThreshold, MatchThreshold). |
ParentFrozen | Resolver / governance | Auto-create blocked because an ancestor has IsFrozen=1. |
AutoGrowDisabled | Resolver / governance | Proposed parent has AllowAutoGrow=0. |
MaxChildrenExceeded | Resolver / governance | Proposed parent has hit its MaxChildren cap. |
MaxDepthExceeded | Resolver / governance | Auto-create would exceed an ancestor’s MaxDescendantDepth. |
BelowMinWeight | Resolver / governance | Item weight is below the proposed tag’s MinWeight. |
RequiresReview | Resolver | Resolved tag has RequiresReview=1. |
MaxItemTagsExceeded | Autotag engine (RunBudget) | The item has hit MaxNewTagsPerItem. |
MergeCandidate | TagHealthJob | Two tags co-occur heavily AND have similar names AND similar embeddings. |
LowUsage | TagHealthJob | Tag has < maxUsage applications in the lookback window. |
WideNode | TagHealthJob | Tag has too many direct children (> MaxChildren if set, else > maxImplicitChildren). |
Reviewers in the inbox UI see the reason, the source content item (when applicable), the source text snippet, the best-match tag (when applicable), and the score. Each suggestion can be approved, merged, or rejected; bulk approval is supported for the MergeCandidate cohort.
Tag Health
Section titled “Tag Health”Tag Health is co-occurrence-driven analytics that emits suggestions to keep the taxonomy from rotting. It runs in the post-run hook (AutotagBaseEngine.ts:218 / 1728) immediately after TagCoOccurrenceEngine.RecomputeCoOccurrence().
The three emitters
Section titled “The three emitters”| Emitter | Triggers when | Suggestion |
|---|---|---|
EmitMergeSuggestions | Two active tags have CoOccurrenceCount ≥ minCoOccurrence AND name-similarity passes AND embedding-cosine passes | Reason='MergeCandidate', BestMatchTagID set, reviewer can one-click merge |
EmitLowUsageSuggestions | An active tag has fewer than maxUsage applications in the lookback window | Reason='LowUsage', ProposedParentID=tag.ParentID for context |
EmitWideNodeSuggestions | A tag’s direct-child count exceeds MaxChildren (when set) or maxImplicitChildren | Reason='WideNode', reviewer adds intermediate categorization |
Each emitter is idempotent: it skips pairs/tags that already have a pending suggestion of the same reason. Re-running the job many times is safe and cheap.
When it runs
Section titled “When it runs”Today: opportunistically after each autotag run. There is a code path to wire it as a scheduled action when you want predictable cadence; see Phase 1e of the implementation plan and TagHealthJob (post-implementation). Recommended cadence is once per day for low-volume customers, once per hour for high-volume.
Per-source configuration knobs
Section titled “Per-source configuration knobs”All knobs live on ContentSource.Configuration, a JSON blob that CodeGen exposes as the typed _IContentSourceConfiguration accessor. Source: IContentSourceConfiguration.ts.
| Field | Type | Default | What it controls |
|---|---|---|---|
TagTaxonomyMode | 'constrained' | 'auto-grow' | 'free-flow' | 'hybrid' | 'auto-grow' | The Tier 4 branch. See Taxonomy modes. |
TagRootID | string | null | null | When set, semantic search and auto-create are bounded to this subtree. Required for constrained and auto-grow to be useful. |
TagMatchThreshold | number (0.0–1.0) | 0.9 | Auto-apply floor for Tier 3. |
ShareTaxonomyWithLLM | boolean | true | Whether to inject the taxonomy tree into the classification prompt. Disable for blind classification. |
EnableVectorization | boolean | true | Whether to embed/vectorize content for semantic search. Independent of tag matching. |
SuggestThreshold | number (0.0–1.0) | TagMatchThreshold - 0.05 | Lower bound of the suggestion band. Below this, we fall through to mode-based handling. |
MaxNewTagsPerRun | number | null | null (unlimited) | Pause the run gracefully once this many new tags have been auto-created. Resumes from LastProcessedOffset. |
MaxNewTagsPerItem | number | null | null (unlimited) | Per-item budget. Once exceeded, additional free-text tags route to MJ:Tag Suggestions with Reason='MaxItemTagsExceeded'. |
MaxTokensPerRun | number | null | null (unlimited) | Pause when cumulative LLM tokens exceed this. |
MaxCostPerRun | number | null | null (unlimited) | Pause when cumulative cost exceeds this (USD). |
Budget enforcement uses the existing CancellationRequested / LastProcessedOffset machinery — runs pause, they do not fail. A paused run resumes cleanly when restarted.
Embeddings
Section titled “Embeddings”Tag embeddings power the Tier 3 semantic-match path and the merge-candidate emitter. Each tag has two new columns:
EmbeddingVector—NVARCHAR(MAX)JSON-encoded float array (the same shape used byMJ:AI Agent Notes.EmbeddingVector).EmbeddingModelID— FK toAIModelso we can detect “the global embedding model changed → these vectors are stale.”
The Save() hook refresh
Section titled “The Save() hook refresh”MJTagEntityServer overrides Save() to:
- Validate the
IsGlobal⊕TagScopeinvariant. - Detect when
NameorDescriptionis dirty (or it’s the first save) and re-embed viaGenerateEmbeddingByFieldName('Name', 'EmbeddingVector', 'EmbeddingModelID'). IfNameis empty, it nulls out the cache. - Sync the in-memory
SimpleVectorServiceonTagEngine.Instanceso the new vector is searchable immediately.
This means the autotagger does not pay a cold-start embedding cost — refreshTagEmbeddings() (TagEngine.ts:186) hydrates the in-memory vector service from the DB cache, falling back to the LLM only for tags that are missing a vector or whose EmbeddingModelID doesn’t match the currently configured model.
RebuildTagEmbeddings utility
Section titled “RebuildTagEmbeddings utility”When you change the global embedding model, every cached vector is silently stale. Run TagEngine.Instance.RebuildTagEmbeddings(contextUser, options?) once. It:
- Iterates active tags whose
EmbeddingModelIDdoesn’t match the current model. - Re-embeds each in batches of 50 via
AIModelRunner.RunEmbedding. - Writes the new vector +
EmbeddingModelIDback viaSave()(which triggers the in-memory sync). - Logs cache-hits vs. computed-fresh for telemetry.
Wire this to a scheduled action if your team rotates models; otherwise call it ad-hoc after a model change.
Implementation guides
Section titled “Implementation guides””I want a constrained taxonomy for my customer”
Section titled “”I want a constrained taxonomy for my customer””Goal: a fixed list of categories, no auto-creation, every miss goes to review.
- Bootstrap the tree. Create the root tag plus all child categories via the metadata-files approach (see Seeding the taxonomy).
- Mark the root frozen (
IsFrozen=1). This is belt-and-suspenders — the mode setting alone would suffice, butIsFrozenprotects against accidental mode changes. - On the
ContentSource:TagRootID→ the root’s IDTagTaxonomyMode→'constrained'TagMatchThreshold→0.85(slightly lower since you want the LLM to find existing tags, not invent new ones)
- Seed synonyms for every category — when the LLM emits a near-miss, you want it caught at Tier 0.
- Provision a steward. Misses become
MJ:Tag SuggestionswithReason='ConstrainedMode'. The steward triages them (most are noise; the rare good ones become new categories via the suggestion-promote path).
”I want auto-growth but only inside a subtree”
Section titled “”I want auto-growth but only inside a subtree””Goal: an open taxonomy under one branch, locked down everywhere else.
- Pick the branch. Set
IsFrozen=1on the root and on every other top-level node. Only the open branch stays unfrozen. - On the
ContentSource:TagRootID→ the open branch’s IDTagTaxonomyMode→'auto-grow'
- Set guardrails on the open branch:
MaxDescendantDepth=3to prevent runaway depthMaxChildren=50on intermediate nodes to keep the LLM from emitting one tag per nounMinWeight=0.6to filter low-confidence applications
- Watch the budget knobs. Set
MaxNewTagsPerRun=20initially; relax once you see steady-state behavior.
”I want per-tenant tag visibility”
Section titled “”I want per-tenant tag visibility””Goal: tenant Acme has their own internal codes that BetaCo cannot see, both share a global “Industry” tree.
- Keep the shared roots global.
Industry,Technology, etc. →IsGlobal=1, noTagScoperows. - Create the tenant subroot scoped. When you create
Acme Internal Codes, setIsGlobal=0and immediately add aTagScoperow pointing to the Account record for Acme:(ScopeEntityID=Accounts, ScopeRecordID=<Acme Account ID>). - Children inherit automatically. Anything the autotagger creates under
Acme Internal Codessnapshots that scope row. You don’t manage it per child. - Wire scope context. The autotag engine derives
TagScopeContextfrom the source’sTagRootIDancestry; if the root is non-global, its scope flows through. For Knowledge Hub, build the context from the logged-in user’s tenant binding.
”I want a review gate on a sensitive subtree”
Section titled “”I want a review gate on a sensitive subtree””Goal: HR / legal / financial categories must be human-approved every time.
- Set
RequiresReview=1on the parent of the sensitive subtree. There is no recursive flag — each sensitive node sets its own. (This is a deliberate design choice: review is a per-node policy, not an inherited one.) - Every classifier hit on those tags writes a
MJ:Tag Suggestionsrow withReason='RequiresReview'and leavesContentItemTag.TagIDnull. - Reviewer approves →
PromoteSuggestionre-points the existingContentItemTagrows whereTag = ProposedNameto the resolvedTagIDand creates theTaggedItem.
”I want to add synonyms from a curated list”
Section titled “”I want to add synonyms from a curated list””Goal: bulk-import a synonym mapping from a CSV or spreadsheet.
-
Use the metadata-files approach. Create
metadata/tag-synonyms/.tag-synonyms.jsonwith one record per synonym:[{"fields": {"TagID": "@lookup:MJ: Tags.Name=AI","Synonym": "Artificial Intelligence","Source": "Imported"}},{"fields": {"TagID": "@lookup:MJ: Tags.Name=AI","Synonym": "Machine Intelligence","Source": "Imported"}}] -
Add a
.mj-sync.jsonfor the directory pointing atMJ: Tag Synonyms. -
npx mj sync push --dir=metadata --include="tag-synonyms". -
Restart MJAPI so
TagEngineBase.Config()reloads the synonym map.
Seeding the taxonomy
Section titled “Seeding the taxonomy”Three patterns, in order of preference for production:
1. Bootstrap from a curated CSV (recommended)
Section titled “1. Bootstrap from a curated CSV (recommended)”Author one JSON record per tag under metadata/tags/.tags.json, using @lookup: for ParentID:
[ { "fields": { "Name": "Industry", "DisplayName": "Industry", "IsFrozen": true, "Description": "Top-level industry classifier" } }, { "fields": { "Name": "Technology", "DisplayName": "Technology", "ParentID": "@lookup:MJ: Tags.Name=Industry", "Description": "Tech industry subnodes" } }]mj sync push --dir=metadata --include="tags". CodeGen and the Save() hook handle embeddings + scope invariants on insert.
2. Bootstrap from an existing corpus
Section titled “2. Bootstrap from an existing corpus”When you have a body of content but no taxonomy:
- Configure a
ContentSourcewithTagTaxonomyMode='free-flow'and run the autotagger on a representative sample. - Review the resulting taxonomy in the admin UI. Use
MergeTags,RenameTag, andDeprecateTagto clean up. - Promote good clusters to a stable structure with
MoveTag. - Switch the source to
'auto-grow'(or'constrained') and freeze the curated nodes.
3. Bootstrap from an LLM
Section titled “3. Bootstrap from an LLM”For greenfield taxonomies, prompt an LLM to draft the structure (one-shot with examples), human-review, and import via path 1. Skip if the corpus is small enough to do path 2.
Always commit metadata files to git — the database is regenerable, the taxonomy schema is source of truth.
Operational guidance
Section titled “Operational guidance”Budgets and pause/resume
Section titled “Budgets and pause/resume”RunBudget (Phase 1d.4) tracks new tags, tokens, and cost across a run. When any budget is hit:
ContentProcessRun.Status→'Paused'ContentProcessRun.CancellationRequested→1ContentProcessRunDetail.ErrorMessage→ reason (which budget tripped)
The run resumes from LastProcessedOffset on next start. Do not lower budgets mid-run — the run will pause but the next run will pause at the same offset and you’ll go nowhere.
Monitoring
Section titled “Monitoring”Watch these signals in the MJ Explorer dashboards or directly:
- Suggestion queue depth by
Reason. A growingBelowThresholdqueue suggestsTagMatchThresholdis too high. A growingConstrainedModequeue suggests your taxonomy is missing categories. - Tag creation rate per run vs.
MaxNewTagsPerRun. Hitting the cap regularly means either you’re infree-flowand shouldn’t be, or your governance is too loose. - Embedding cache-hit ratio in
TagEnginestartup logs. Low ratio after a model change is expected; persistent low ratio meansSave()hooks aren’t firing. - Co-occurrence merge candidates. A spike usually means a new content source introduced near-duplicate tags.
What to do when the suggestion queue grows
Section titled “What to do when the suggestion queue grows”- Triage by
Reason.MergeCandidateandBelowThresholdare usually mass-approvable in bulk.ConstrainedModeandRequiresReviewneed per-row attention. - Use bulk actions. “Approve all
MergeCandidatewithBestMatchScore >= 0.95” usually clears 80% of the noise. - Tune the inputs. If
BelowThresholdkeeps refilling, lowerTagMatchThresholdfor that source by 0.02–0.05. IfMergeCandidatekeeps refilling, raiseminNameSimilarityin the TagHealthJob config.
Plugin points & extension
Section titled “Plugin points & extension”| Concern | File | Hook | Use case |
|---|---|---|---|
| Custom bridging from raw classifier output to taxonomy | AutotagBaseEngine.ts:766 | OnContentItemTagSaved callback | Replace the default bridge with custom logic |
| Notify on new tag creation | TagEngine.ts | onTagCreated callback (passed into ResolveTag via the autotag engine for budget tracking) | Hook into auto-create events for telemetry, slack alerts, etc. |
| Custom Tag Health rules | TagHealthJob.ts | Compose your own emitter alongside the three built-ins | Add domain-specific quality checks |
| Embedding refresh logic | MJTagEntityServer.server.ts | Save() override | Change what fields drive the embedding text, or short-circuit refresh |
| Scope inference from a content source | ScopeContextResolver.deriveScopeContext | Plain function | Override the default “scope follows TagRootID ancestry” with explicit per-source scope |
| Governance enforcement | TagGovernanceEngine | ValidateAutoGrow, PromoteSuggestion, RejectSuggestion | Add custom policy checks before promotion |
| LLM taxonomy context | AutotagBaseEngine.ts:333 | TaxonomyContext field | Customize how the taxonomy is serialized for the prompt |
FAQ / gotchas
Section titled “FAQ / gotchas”1. “Tags from one tenant are leaking into another’s facet.”
Section titled “1. “Tags from one tenant are leaking into another’s facet.””You forgot the scope context. The Knowledge Hub tag facet defaults to all active tags; you must pass a TagScopeContext (or use GetVisibleTags(ctx)) to scope it. Check the RunView in the analytics resource component for an ExtraFilter that respects scope. Also: clear the GraphQL data provider cache when switching backends; cached metadata can carry stale scope decisions.
2. “The autotagger created 200 new tags in one run.”
Section titled “2. “The autotagger created 200 new tags in one run.””You’re in free-flow (or auto-grow with no budget). Set MaxNewTagsPerRun=20 and MaxNewTagsPerItem=5 per source. Add MinWeight=0.6 on the open branches. Decide whether you want auto-grow (under a single root) or hybrid (review-gated).
3. “Embedding scores look wrong / inconsistent after a server restart.”
Section titled “3. “Embedding scores look wrong / inconsistent after a server restart.””You changed the global embedding model and the cached vectors are now from the old model. Run TagEngine.Instance.RebuildTagEmbeddings(contextUser) once. Going forward, gate model changes through a runbook that includes the rebuild.
4. “I created a TagScope row but the tag still shows up everywhere.”
Section titled “4. “I created a TagScope row but the tag still shows up everywhere.””Either the tag is IsGlobal=1 (the Save() invariant should have rejected the scope row — check LatestResult.CompleteMessage) or the consumer isn’t passing TagScopeContext to its lookup. TagScope rows are necessary but not sufficient — every consumer must opt into the visibility filter.
5. “Suggestions of Reason='ConstrainedMode' keep piling up — why isn’t the steward seeing them?”
Section titled “5. “Suggestions of Reason='ConstrainedMode' keep piling up — why isn’t the steward seeing them?””Check that the Suggestion Inbox UI is filtered to Status='Pending' and that SourceContentSourceID filter isn’t accidentally narrowing them out. Also verify the steward has read permission on MJ:Tag Suggestions — the entity is access-controlled like any other.
6. “Renaming a tag broke matching for a week.”
Section titled “6. “Renaming a tag broke matching for a week.””Two possibilities. (1) The Save() hook didn’t re-embed — check the server logs for TagGovernanceEngine: Failed to re-embed tag .... (2) The merge didn’t write a synonym — MergeTags does this automatically; manual rename via RenameTag does not (rename is a name change, not a merge). When you intend “users will type the old name,” rename + manually insert a TagSynonym row.
7. “The LLM keeps returning a UUID as a tag name.”
Section titled “7. “The LLM keeps returning a UUID as a tag name.””Known LLM tic when you inject the taxonomy with IDs. The autotag engine strips IDs from the tree before injection (AutotagEntity.ts:128). If you’ve replaced the bridge or the prompt, make sure your serialization does the same.
8. “TagEngine.ResolveTag is slow under heavy concurrency.”
Section titled “8. “TagEngine.ResolveTag is slow under heavy concurrency.””It’s serialized by an internal mutex on purpose — without it, parallel batches create duplicate tags. The mutex is a feature, not a bug. If you need throughput, batch items upstream so each ResolveTag call processes more text at once (the LLM call is the bottleneck, not the resolver).
9. “Co-occurrence numbers look stale.”
Section titled “9. “Co-occurrence numbers look stale.””TagCoOccurrenceEngine.RecomputeCoOccurrence runs after each autotag run via the post-run hook. If you never run the autotagger (e.g. tags applied only manually), schedule a job to invoke it directly. Co-occurrences are not maintained incrementally — it’s a recompute.
10. “I want to delete a tag, but DeleteTag only soft-deletes.”
Section titled “10. “I want to delete a tag, but DeleteTag only soft-deletes.””That is correct. The audit log and historical TaggedItem / ContentItemTag references would be orphaned by a hard delete. If you genuinely need a hard delete (e.g. PII compliance), write a maintenance migration that nulls out references first; do not bypass TagGovernanceEngine.
Related guides
Section titled “Related guides”- Content Autotagging Guide — the upstream pipeline that feeds tags
- Caching & PubSub Guide — relevant for the BaseEntity event-driven cache invalidation that keeps
TagEngineBasefresh across providers - UUID Comparison Guide — every tag lookup hinges on
UUIDsEqual/NormalizeUUID - Dashboard Best Practices — relevant if you’re building Tag Health or Suggestion Inbox dashboards