Your Salesforce Org Metadata Is a Knowledge Graph: Schema-Aware AI
Every Salesforce org contains a knowledge graph hiding in plain sight. Objects are nodes. Lookups are edges. Descriptions, validation rules, and flows are attributes. When you give AI access to this graph, it stops hallucinating about your business rules.
The Hallucination Problem Is a Context Problem
Ask an LLM to write a SOQL query for your org and it will happily generate one. The field names will be wrong. The relationships will be invented. The validation rules it assumes exist will be fictional.
This is not an intelligence failure. The model is doing exactly what it was trained to do: generate plausible Salesforce code based on the patterns it has seen across millions of orgs. The problem is that your org is not the average of millions of orgs. Your custom fields, your naming conventions, your specific relationship graph, your validation rules are unique to you.
The fix is not a better model. The fix is better context. And the richest source of context for Salesforce AI is the org metadata itself, treated as a structured knowledge graph.
Phase 1: Metadata Extraction
The first step is getting the metadata out of Salesforce and into a format suitable for graph construction. The Tooling API and REST Describe endpoints give you everything you need.
Here is the Apex extraction class that pulls the full schema graph:
public class SchemaExtractor {
public class ObjectNode {
public String apiName;
public String label;
public String description;
public Boolean isCustom;
public String keyPrefix;
public List<FieldNode> fields;
public List<ValidationRuleNode> validationRules;
public List<RelationshipEdge> relationships;
}
public class FieldNode {
public String apiName;
public String label;
public String description;
public String fieldType;
public Boolean isRequired;
public Boolean isUnique;
public Boolean isExternalId;
public List<String> picklistValues;
public String inlineHelpText;
public String formula;
public String defaultValue;
}
public class RelationshipEdge {
public String fromObject;
public String toObject;
public String fieldName;
public String relationshipName;
public String relationshipType; // Lookup, MasterDetail
public Boolean isPolymorphic;
public List<String> referenceTo;
}
public class ValidationRuleNode {
public String name;
public String errorConditionFormula;
public String errorMessage;
public Boolean isActive;
}
public static ObjectNode extractObject(String objectName) {
Schema.DescribeSObjectResult describe =
Schema.getGlobalDescribe().get(objectName).getDescribe();
ObjectNode node = new ObjectNode();
node.apiName = describe.getName();
node.label = describe.getLabel();
node.isCustom = describe.isCustom();
node.keyPrefix = describe.getKeyPrefix();
node.fields = new List<FieldNode>();
node.relationships = new List<RelationshipEdge>();
Map<String, Schema.SObjectField> fieldMap =
describe.fields.getMap();
for (String fieldKey : fieldMap.keySet()) {
Schema.DescribeFieldResult fr =
fieldMap.get(fieldKey).getDescribe();
FieldNode fn = new FieldNode();
fn.apiName = fr.getName();
fn.label = fr.getLabel();
fn.fieldType = String.valueOf(fr.getType());
fn.isRequired = !fr.isNillable() && fr.isCreateable();
fn.isUnique = fr.isUnique();
fn.isExternalId = fr.isExternalId();
fn.inlineHelpText = fr.getInlineHelpText();
fn.defaultValue = fr.getDefaultValue() != null
? String.valueOf(fr.getDefaultValue()) : null;
// Extract picklist values
if (fr.getType() == Schema.DisplayType.PICKLIST
|| fr.getType() ==
Schema.DisplayType.MULTIPICKLIST) {
fn.picklistValues = new List<String>();
for (Schema.PicklistEntry pe :
fr.getPicklistValues()) {
if (pe.isActive()) {
fn.picklistValues.add(pe.getValue());
}
}
}
node.fields.add(fn);
// Extract relationship edges
if (fr.getType() == Schema.DisplayType.REFERENCE) {
RelationshipEdge edge = new RelationshipEdge();
edge.fromObject = objectName;
edge.fieldName = fr.getName();
edge.relationshipName =
fr.getRelationshipName();
edge.referenceTo = new List<String>();
for (Schema.SObjectType ref :
fr.getReferenceTo()) {
edge.referenceTo.add(
ref.getDescribe().getName());
}
edge.toObject = edge.referenceTo.isEmpty()
? null : edge.referenceTo[0];
edge.isPolymorphic =
edge.referenceTo.size() > 1;
edge.relationshipType =
fr.getRelationshipOrder() != null
? 'MasterDetail' : 'Lookup';
node.relationships.add(edge);
}
}
return node;
}
// Extract all queryable objects into a graph
public static List<ObjectNode> extractFullGraph() {
List<ObjectNode> graph = new List<ObjectNode>();
Map<String, Schema.SObjectType> globalDescribe =
Schema.getGlobalDescribe();
for (String objName : globalDescribe.keySet()) {
Schema.DescribeSObjectResult desc =
globalDescribe.get(objName).getDescribe();
if (desc.isQueryable() && desc.isAccessible()) {
try {
graph.add(extractObject(objName));
} catch (Exception e) {
System.debug(LoggingLevel.WARN,
'Failed to extract ' + objName
+ ': ' + e.getMessage());
}
}
}
return graph;
}
}
For validation rules and flows, you need the Tooling API because Schema.getGlobalDescribe does not include them:
// Tooling API queries for metadata extraction
// Run via sf-fabric's {{sf:tooling:...}} variables
// or via the Tooling API REST endpoint
// Validation rules with formulas
SELECT Id, EntityDefinition.QualifiedApiName,
ValidationName, Active,
ErrorConditionFormula, ErrorMessage
FROM ValidationRule
WHERE Active = true
// Flow definitions with process types
SELECT Id, MasterLabel, ProcessType,
Status, Description, TriggerType
FROM FlowDefinition
WHERE Status = 'Active'
// Custom metadata for business rules
SELECT Id, DeveloperName, NamespacePrefix,
QualifiedApiName
FROM EntityDefinition
WHERE QualifiedApiName LIKE '%__mdt'
Phase 2: Graph Construction
Once extracted, the metadata maps naturally to a graph model. I build this in Rust using an adjacency model with natural-language constraint annotations. This is the core of what BridgeQL's RelationshipGraph does:
// Rust: Building the schema knowledge graph
// From bridgeql-salesforce/src/graph.rs
pub struct RelationshipGraph {
/// Objects (nodes) in the graph
objects: HashMap<String, ObjectNode>,
/// Relationships (edges) in the graph
relationships: Vec<RelationshipEdge>,
/// Index: object name -> outgoing relationship indices
outgoing: HashMap<String, Vec<usize>>,
/// Index: object name -> incoming relationship indices
incoming: HashMap<String, Vec<usize>>,
}
pub struct ObjectNode {
pub name: String,
pub label: String,
pub is_custom: bool,
pub key_prefix: Option<String>,
pub queryable: bool,
}
pub struct RelationshipEdge {
pub from_object: String,
pub to_object: String,
pub field_name: String,
pub relationship_name: Option<String>,
pub relationship_type: RelationshipType,
pub direction: EdgeDirection,
pub is_polymorphic: bool,
}
pub enum EdgeDirection {
ParentLookup, // Contact -> Account (via AccountId)
ChildLookup, // Account -> Contacts (child subquery)
}
pub enum RelationshipType {
Lookup,
MasterDetail,
ExternalLookup,
IndirectLookup,
Hierarchical,
}
The graph construction produces something like this for a typical Sales Cloud org:
Graph Statistics:
Nodes: 847 objects (312 custom, 535 standard)
Edges: 2,341 relationships
- 1,892 Lookup
- 312 MasterDetail
- 89 Hierarchical
- 48 Polymorphic
Key Hubs (most connections):
Account: 142 incoming, 23 outgoing
Contact: 87 incoming, 15 outgoing
User: 234 incoming, 3 outgoing
Opportunity: 45 incoming, 12 outgoing
Longest Path: Order__c -> Account -> Parent Account
-> Ultimate_Parent__c (4 hops)
The graph reveals things that are invisible when looking at objects individually. You can see that Account is a massive hub with 142 incoming lookups. You can trace the complete relationship chain from any object to any other. You can find orphaned custom objects with zero relationships. You can detect circular references. You can measure the "distance" between any two objects in your data model.
But the real power comes from annotating the graph with natural-language constraints.
Constraint Annotations
Every validation rule, every picklist restriction, every required field, every sharing rule is a constraint on the graph. I convert these into natural-language annotations because that is what the LLM can consume:
// For each object node, generate NL constraint summary
fn generate_constraint_annotation(obj: &ObjectNode) -> String {
let mut annotations = Vec::new();
// Required fields
let required: Vec<&str> = obj.fields.iter()
.filter(|f| f.is_required)
.map(|f| f.api_name.as_str())
.collect();
if !required.is_empty() {
annotations.push(format!(
"Required fields on {}: {}",
obj.api_name, required.join(", ")
));
}
// Picklist constraints
for field in &obj.fields {
if let Some(ref values) = field.picklist_values {
if !values.is_empty() {
annotations.push(format!(
"{}.{} must be one of: {}",
obj.api_name, field.api_name,
values.join(", ")
));
}
}
}
// Validation rules
for vr in &obj.validation_rules {
if vr.is_active {
annotations.push(format!(
"Validation on {}: {} (Error: {})",
obj.api_name,
vr.error_condition_formula,
vr.error_message
));
}
}
annotations.join("\n")
}
The constraint annotation for an Account object might look like:
Required fields on Account: Name, OwnerId, Industry
Account.Industry must be one of: Technology, Healthcare,
Finance, Manufacturing, Retail, Other
Account.Type must be one of: Prospect, Customer, Partner,
Competitor, Other
Validation on Account: AND(ISBLANK(BillingStreet),
NOT(ISBLANK(ShippingStreet)))
(Error: "Billing address required when shipping set")
Validation on Account: Revenue__c > 0
(Error: "Revenue must be positive")
Account -> Contact (Contacts): 1-to-many via AccountId
Account -> Opportunity (Opportunities): 1-to-many via AccountId
Account -> Case (Cases): 1-to-many via AccountId
Account -> Account (Parent): self-referential via ParentId
This is dense, structured context that an LLM can actually use. When you embed this into the prompt, the model knows that Industry must be "Technology" or "Healthcare", not some invented value. It knows Revenue__c must be positive. It knows the exact relationship names for SOQL traversals.
Phase 3: Vector Embedding
A large org has 800+ objects. You cannot stuff all of them into a prompt. You need selective retrieval: given a user's question, find the relevant subset of schema context. This is where vector embeddings come in.
The key insight: embed the schema descriptions, not the records. Schema is compact, stable, and rich in structural information. Records are large, volatile, and often contain sensitive data.
// Schema embedding pipeline
// Each object gets a text representation that captures
// its role in the graph
fn embed_object(obj: &ObjectNode, graph: &RelationshipGraph)
-> String
{
let mut text = String::new();
// Object identity
text += &format!(
"Object: {} ({})\n",
obj.api_name, obj.label
);
if let Some(ref desc) = obj.description {
text += &format!("Purpose: {}\n", desc);
}
// Key fields (not all fields, just the important ones)
let key_fields: Vec<String> = obj.fields.iter()
.filter(|f| {
f.is_required || f.is_unique
|| f.is_external_id
|| f.field_type == "reference"
})
.map(|f| format!(
" {} ({}, {}{})",
f.api_name, f.field_type, f.label,
if f.is_required { ", required" } else { "" }
))
.collect();
if !key_fields.is_empty() {
text += "Key fields:\n";
text += &key_fields.join("\n");
text += "\n";
}
// Relationships
let outgoing = graph.outgoing_relationships(&obj.name);
for edge in outgoing {
text += &format!(
"Related to {} via {} ({})\n",
edge.to_object,
edge.field_name,
edge.relationship_type_name()
);
}
// Constraints
text += &generate_constraint_annotation(obj);
text
}
// Generate embeddings using Anthropic or OpenAI
async fn embed_schema(
objects: &[ObjectNode],
graph: &RelationshipGraph,
) -> Vec<SchemaEmbedding> {
let mut embeddings = Vec::new();
for obj in objects {
let text = embed_object(obj, graph);
let vector = call_embedding_api(&text).await;
embeddings.push(SchemaEmbedding {
object_name: obj.api_name.clone(),
text,
vector,
});
}
embeddings
}
The embedding is on the object description text, not on the object itself. This means the vector captures semantic meaning: "Account stores customer and prospect company information, related to Contacts, Opportunities, and Cases" embeds near queries about "customer data" or "company records" even if the user never mentions "Account."
Phase 4: Schema-Grounded AI
Now you have the full pipeline. User asks a question. You embed the question. You retrieve the most relevant schema context. You inject that context into the prompt. The LLM generates output grounded in your actual org schema.
// Schema-grounded prompt assembly
// This is what sf-fabric does with {{sf:metadata:...}} variables
async fn schema_grounded_prompt(
user_query: &str,
schema_store: &SchemaStore,
top_k: usize,
) -> String {
// 1. Embed the user's query
let query_vector = call_embedding_api(user_query).await;
// 2. Retrieve top-k most relevant objects
let relevant = schema_store
.nearest_neighbors(&query_vector, top_k);
// 3. Build the schema context block
let mut schema_context = String::from(
"## Org Schema Context\n\n"
);
schema_context += "The following objects, fields, and \
constraints are from the actual Salesforce org. \
Use ONLY these field names and relationships. \
Do NOT invent fields that are not listed.\n\n";
for embedding in &relevant {
schema_context += &embedding.text;
schema_context += "\n---\n";
}
// 4. Assemble the full prompt
format!(
"{schema_context}\n\n\
## User Request\n\n{user_query}\n\n\
## Instructions\n\n\
Use ONLY the field names, relationships, and \
picklist values from the schema context above. \
If a field or relationship is not listed, say so \
explicitly rather than guessing."
)
}
The instruction "Use ONLY the field names, relationships, and picklist values from the schema context above" is critical. Without it, the model will happily mix real field names with hallucinated ones. With it, the model is grounded.
Before and After: The Difference Is Stark
Here is a real comparison. Same question, same model, with and without schema grounding.
Without schema context:
User: Write a SOQL query to find all high-value
opportunities closing this quarter with their
account details.
AI Response (ungrounded):
SELECT Id, Name, Amount, CloseDate, StageName,
Account.Name, Account.Industry, Account.Revenue
FROM Opportunity
WHERE Amount > 100000
AND CloseDate = THIS_QUARTER
AND StageName NOT IN ('Closed Won', 'Closed Lost')
ORDER BY Amount DESC
Looks reasonable. But Account.Revenue does not exist. The field is AnnualRevenue. And this org uses a custom field Revenue_Band__c, not a numeric revenue field. The stage names might be wrong too, since this org uses custom stages.
With schema context:
Schema Context:
Object: Opportunity (Opportunity)
Key fields:
Amount (currency, Amount, required)
CloseDate (date, Close Date, required)
Deal_Stage__c (picklist, Deal Stage, required)
Values: Qualification, Discovery, Proposal,
Negotiation, Verbal Commit, Closed Won,
Closed Lost, On Hold
Revenue_Tier__c (picklist, Revenue Tier)
Values: Enterprise, Mid-Market, SMB
Related to Account via AccountId (LOOKUP)
Object: Account (Account)
Key fields:
Name (string, Account Name, required)
Industry (picklist, Industry)
Values: Technology, Healthcare, Finance,
Manufacturing, Retail, Other
AnnualRevenue (currency, Annual Revenue)
Revenue_Band__c (formula, Revenue Band)
---
AI Response (grounded):
SELECT Id, Name, Amount, CloseDate, Deal_Stage__c,
Revenue_Tier__c,
Account.Name, Account.Industry,
Account.AnnualRevenue, Account.Revenue_Band__c
FROM Opportunity
WHERE Amount > 100000
AND CloseDate = THIS_QUARTER
AND Deal_Stage__c NOT IN ('Closed Won', 'Closed Lost',
'On Hold')
ORDER BY Amount DESC
Every field name is real. The stage picklist values come from the actual org. The revenue fields are correct. The relationship traversal uses the right path. This is not better prompting. This is the model operating with the right information.
Why This Eliminates Hallucinations About Business Rules
Hallucinations happen when the model fills gaps in its knowledge with plausible-sounding inventions. Schema grounding eliminates the gaps. The model does not need to guess what your fields are named because you told it. It does not need to invent validation rules because you provided the real ones.
There are three categories of hallucination that schema grounding eliminates:
- Field name hallucination. The model invents fields like "Revenue" when the real field is "AnnualRevenue" or "Annual_Revenue__c". Schema context provides the exact API names.
- Relationship hallucination. The model assumes Account.Contacts is a valid traversal when the actual relationship name is Account.Contacts__r or uses a custom junction object. The graph edges provide the exact traversal paths.
- Constraint hallucination. The model generates code that violates validation rules it does not know about. Constraint annotations surface these rules so the model can respect them.
What schema grounding does NOT eliminate: logic errors, performance issues, governor limit violations, and incorrect business intent. You still need patterns like governor_aware and security_first (from sf-fabric's strategy system) for those. Schema grounding handles the "what exists" question. Strategy stitching handles the "how to use it correctly" question.
Performance and Token Economics
A full org schema for 800+ objects would consume 400,000+ tokens. That is impractical. The vector retrieval step is essential because it brings this down to 2,000-5,000 tokens for the 3-5 most relevant objects.
Token budget breakdown (typical query):
Schema context (5 objects): ~3,000 tokens
Constraint annotations: ~1,200 tokens
Relationship graph excerpt: ~500 tokens
User query: ~200 tokens
System instructions: ~300 tokens
----------------------------------------
Total input: ~5,200 tokens
Response budget: ~2,000 tokens
----------------------------------------
Total per request: ~7,200 tokens
At Claude Sonnet pricing ($3/M input, $15/M output):
Input cost: $0.0156 per request
Output cost: $0.030 per request
Total: $0.046 per request
1,000 requests/day = $46/day = $1,380/month
$1,380/month for AI that uses your actual field names and respects your actual validation rules. Compare that to the cost of debugging AI-generated code that references nonexistent fields, which I have seen consume 20+ developer hours per month at multiple clients.
Refresh Strategy
Schema changes. Fields get added. Validation rules get updated. Picklist values change. Your embedding store needs a refresh strategy.
I use a two-tier approach. Nightly: re-extract and re-embed any objects modified in the last 24 hours (check LastModifiedDate on EntityDefinition via Tooling API). Weekly: full re-extraction and re-embedding of all objects. The nightly sync catches 95% of changes. The weekly sync catches schema drift from metadata deploys that do not update LastModifiedDate.
The Tooling API query for change detection:
SELECT QualifiedApiName, LastModifiedDate,
LastModifiedBy.Name
FROM EntityDefinition
WHERE LastModifiedDate >= YESTERDAY
ORDER BY LastModifiedDate DESC
This is lightweight. A single API call that returns only changed objects. You re-extract and re-embed only what changed. For a 800-object org with 5 daily schema changes, the nightly sync takes under 30 seconds.
Building This Today
You do not need a custom vector database to get started. The simplest implementation:
- Run the SchemaExtractor Apex class in your org. Save the JSON output.
- Build the constraint annotations offline (a Python script works fine for this).
- For each AI request, include the relevant object schemas directly in the prompt. No embedding needed if you know which objects are relevant.
- Graduate to vector retrieval when you need to handle arbitrary queries across 100+ objects.
Step 3 works surprisingly well for focused use cases. If your AI is always working with Cases, Accounts, and Contacts, you can hardcode those three schema contexts into every prompt. It is only when users can ask questions about any object that you need the full retrieval pipeline.
The graph is already in your org. The metadata APIs expose it. The constraint information is in your validation rules and picklist definitions. The only thing you need to build is the bridge between that structured knowledge and the LLM's context window.
That bridge changes everything. The AI stops inventing your schema and starts using it. And that is the difference between an AI demo and an AI system you can trust in production.