Ontology vs semantic model: the difference is easier to explain with a running watch than with a textbook. Two simple questions are enough.

“How fast did I run on average?” sounds like a question with exactly one answer. Say I run 5 km at a pace of 5:00 min/km on Tuesday and 20 km at 6:00 min/km on Sunday. The average of the two paces is 5:30. For all 25 km together, though, I needed 145 minutes, which is 5:48 per kilometer. Both numbers are mathematically valid, but they answer different questions: the average pace per activity, or the pace across the whole distance. For my pace across all 25 km, 5:48 per kilometer is the correct answer.

The second question is: “How many half marathons have I run?” If you count every run of 21.1 km or more, you also catch long training runs, such as my 21.6 km on September 9, 2024. If you search for “half marathon” in the activity title, you miss my race in 2024, which Strava simply calls “Lauf am Morgen”. What I actually mean is an official race over 21.0975 km.

People make these mistakes, and an LLM that writes SQL from a question makes them too. The two questions stand for two different problems. With pace, the question has to be pinned down once, and then the right calculation follows. Writing down that calculation so that it is used the same way every time is the job of a semantic model. With the half marathon, it is not even clear what the word means. That is the job of an ontology.

This article explains both terms. After that, I build an example from my running data in DuckDB with Apache Ossie files and test whether it actually helps an LLM. A short look at knowledge graphs closes the article. All code runs in a notebook.

Ontology: what a word means

An ontology describes the terms of a domain and how they relate to each other, including the rules that always apply. Tables and columns do not appear in it – it’s tool neutral.

An ontology starts with terms: run, training, race, half marathon. Between the terms there are relationships. The most important one is “is a”: a half marathon is a race, and a race is a run. Domain relationships such as “a run has a distance” come on top. Rules complete the picture, for example that a long run is a training run of at least 18 km.

Two related terms are often mixed up with it. A glossary explains each term on its own, and a taxonomy arranges terms in a tree of “is a” relationships. An ontology allows any relationship and any rule, and writes them down formally enough for a machine to draw conclusions.

An example shows what drawing conclusions means. Every half marathon is a race, and my run in Ulm in 2025 was a half marathon. So it was also a race, even though that fact is stored nowhere. An AI agent that knows the ontology counts the half marathon when asked “How many races?” and counts no long run when asked “How many half marathons?”

What an ontology does not tell you: which table holds the runs and how to calculate a pace from them. That is the job of the semantic model.

Semantic model: how to calculate correctly

A semantic model translates business terms into SQL and defines every metric exactly once. In Ossie it has four building blocks:

  • Datasets are the tables the model talks about, each with a primary key. In my case, these include the tables runs and run_types.
  • Fields describe what you can group or filter by, such as year or run type.
  • Relationships define how the tables belong together, in other words the joins. Every run has exactly one run type.
  • Metrics are aggregated measures. Pace is the sum of all times divided by the sum of all distances, not the average of the individual paces.

Whoever asks a question only names a metric, a dimension and a filter, for example “pace by year, half marathons only”. The model turns that into SQL with the right joins and the right grouping. An AI agent no longer has to guess the pace formula, it picks from a list of defined metrics.

The idea is not new. SAP BusinessObjects called it a universe, Power BI calls it a semantic model, Looker keeps it in LookML and dbt in its Semantic Layer. Every vendor has its own format. This is where Apache Ossie comes in: an open YAML format that every tool should be able to read and write. Snowflake announced the initiative together with other vendors in September 2025 as Open Semantic Interchange (OSI). Since July 2026 it has been part of the Apache Incubator as Apache Ossie. Many vendors like Databricks or Microsoft joined.

Ontology vs semantic model at a glance

The ontology defines what a word means. The semantic model defines how to calculate it from tables.

This is a split of responsibilities, not of two separate worlds. In this article, I use ontology for domain concepts and their relationships, and semantic model for the analytical definitions bound to data. In practice the two overlap: my semantic model also lists run types and synonyms, and my ontology contains a classification rule that compares the run type with “Half marathon”.

  Ontology Semantic model
Answers What does a term mean? How do I calculate a metric?
Refers to the domain, independent of data specific tables and columns
Building blocks terms, “is a”, relationships, rules datasets, fields, joins, metrics
Example A half marathon is a race over 21.0975 km. Pace = SUM(time_sec) / (SUM(distance_m) / 1000.0)
Delivers shared vocabulary and conclusions correct numbers via SQL
Classic formats OWL, RDF, SKOS dbt Semantic Layer, Power BI
In Apache Ossie ontology specification core specification
Changes when domain definitions change when analytical definitions or source mappings change

Both work together, and the link between them is called a mapping. It says, for example: the term half marathon corresponds to the runs whose type in the table run_types is “Half marathon”. Ossie has its own document format for this. Next to the ontology and the semantic model, a project therefore has a third file for the mapping.

Hands-on, part 1: data, ontology and mapping

The example consists of a DuckDB database and three YAML files in Apache Ossie format. All three files pass the official validator of the Ossie project. Every snippet below is also in the Colab notebook, where you can run it step by step.

The data

The data are my real runs from Strava for the years 2021 to 2025, 883 in total. From the export I kept only date, GPS distance and moving time. Strava has no field for the type of run, so whether a run was a race exists only in my head and, sometimes, in its name. The same is true with many data points in the enterprise …

The data script therefore assigns the type. The five half marathons in Ulm are known by their date and get the official net time instead of the watch time. A run of 18 km or more counts as a long run, everything else as an easy run:

-- My runs 2021 to 2025 from Strava: date, GPS distance and moving time per run
CREATE OR REPLACE TABLE run_types (
    run_type_id INTEGER PRIMARY KEY,
    run_type    VARCHAR NOT NULL,
    category    VARCHAR NOT NULL        -- Training or Race
);
INSERT INTO run_types VALUES
    (1, 'Easy run',      'Training'),
    (2, 'Long run',      'Training'),
    (3, 'Half marathon', 'Race');

CREATE OR REPLACE TABLE runs (
    run_id      INTEGER PRIMARY KEY,
    run_date    DATE    NOT NULL,
    run_type_id INTEGER NOT NULL REFERENCES run_types,
    distance_m  INTEGER NOT NULL,       -- measured by GPS
    time_sec    INTEGER NOT NULL        -- moving time, official net time for races
);

-- Strava has no field for the type of run. Races are known by date, with their official net time.
INSERT INTO runs
WITH half_marathons (run_date, official_sec) AS (
    VALUES (DATE '2021-10-03', 6576), (DATE '2022-09-25', 6554), (DATE '2023-10-01', 6322),
           (DATE '2024-09-29', 6458), (DATE '2025-09-28', 6700)
)
SELECT s.run_id, s.run_date,
       CASE WHEN h.run_date IS NOT NULL THEN 3
            WHEN s.distance_m >= 18000  THEN 2
            ELSE 1 END,
       s.distance_m,
       coalesce(h.official_sec, s.time_sec)
FROM read_csv('runs.csv') s
LEFT JOIN half_marathons h ON h.run_date = s.run_date AND s.distance_m > 20000   -- the race, not a warm-up
ORDER BY s.run_id;

The classification is therefore supplied by me. An LLM does not have to discover which activities were races, it has to translate a question into the categories stored in the database. That is the part the test at the end checks.

The ontology

The ontology knows nothing about tables. It describes what a run is and which kinds of runs exist, together with their rules. Here is the most important part:

version: 0.2.0.dev0
name: Running
prefixes:
  run: https://buckenhofer.com/ontology/running#
ontology:
  - concept: Kilometers
    type: ValueType
    extends: [Decimal]
    requires: ["Kilometers > 0"]

  - concept: Run
    type: EntityType
    iri: run:Run
    identify_by: [nr]
    relationships:
      - name: nr
        roles: [concept: Integer]
        multiplicity: OneToOne
        verbalizes: ["{Run} has number {Integer}"]
      - name: distance
        roles: [concept: Kilometers]
        multiplicity: ManyToOne
        verbalizes: ["{Run} is {Kilometers} long"]
      - name: type
        roles: [concept: String]
        multiplicity: ManyToOne
        verbalizes: ["{Run} is of type {String}"]
      # date and duration work the same way

  - concept: Training
    type: EntityType
    extends: [Run]

  - concept: Race
    type: EntityType
    extends: [Run]
    description: Official race with bib number and timing

  - concept: LongRun
    type: EntityType
    extends: [Training]
    derived_by: ["Run.type == 'Long run'"]
    requires: ["Run.distance >= 18"]

  - concept: HalfMarathon
    type: EntityType
    extends: [Race]
    description: Race over the official distance of 21.0975 km.
      The GPS watch usually measures a bit more.
    derived_by: ["Run.type == 'Half marathon'"]
    iri: run:HalfMarathon
  # EasyRun works like LongRun

Six keywords carry the whole file:

  • concept with type creates a term. An EntityType is a thing with its own identity, such as a run. A ValueType is a value with meaning: kilometers are a decimal number greater than zero.
  • extends is the “is a” relationship. A half marathon is a race and therefore also a run.
  • relationships describe domain relationships. Their verbalizes entry adds the sentence in plain language that a human or a language model can read.
  • derived_by is a rule for who belongs to a term. A run of type “Long run” is a long run.
  • requires is a rule that must always hold. A long run has at least 18 km.
  • iri gives the term a globally unique name. This is how the ontology connects to RDF, more on that below.

[embed: node/1d6353f3-3e4c]

 

Ontology example

As a picture, the ontology is a small tree. Everything that holds for “Run” also holds for every term below it.

One trap I ran into: I first called the date relationship “on”. YAML reads an unquoted on as the boolean true, and the validator rejected the file. Now it is called date.

The mapping

The mapping connects the terms with the fields of the semantic model from part 2:

version: 0.2.0.dev0
name: running_mapping
ontology_ref: {name: Running, iri: ./running.ontology.yaml}
semantic_model_ref: {name: running, iri: ./running.semantic_model.yaml}
concept_mappings:
  - concept: Run
    object_mappings:
      - expression: runs.run_id
    link_mappings:
      - object_mapping: {expression: runs.run_id}
        children:
          - relationship: date
            object_mapping: {concept: Date, expression: runs.run_date}
          - relationship: distance
            object_mapping: {concept: Kilometers, expression: runs.distance_m / 1000.0}
          - relationship: duration
            object_mapping: {concept: Minutes, expression: runs.time_sec / 60.0}
          - relationship: type
            object_mapping: {concept: String, expression: run_types.run_type}

Two lines show what a mapping is good for. The ontology talks about kilometers, the database stores meters, so the mapping divides distance_m by 1000. And the type of a run is not even in the table runs but in run_types. The ontology does not care, for it the type is simply a property of the run.

I checked all three files with the validator from the Ossie repository. Among other things, it verifies that every term and every field the mapping points to actually exists:

git clone --depth 1 https://github.com/apache/ossie.git
pip install -U jsonschema pyyaml "sqlglot>=30" referencing
python ossie/validation/validate.py --schema ossie/ontology/mapping.json running.mapping.yaml
# Validation PASSED: running.mapping.yaml

For comparison: the same terms in Turtle and SKOS

The classic home of ontologies is RDF, a W3C standard. For simple concept schemes it offers SKOS with a handful of building blocks, for example prefLabel for the name, altLabel for synonyms and broader for the parent term. In Turtle notation the half marathon looks like this:

@prefix skos:  .
@prefix run:   .

run:Race a skos:Concept ;
    skos:prefLabel "Race"@en ;
    skos:broader run:Run .

run:HalfMarathon a skos:Concept ;
    skos:prefLabel "Half marathon"@en ;
    skos:altLabel "HM"@en, "half"@en ;
    skos:definition "Race over the official distance of 21.0975 km"@en ;
    skos:broader run:Race .

The IRIs are the same as in the iri field of the Ossie ontology, which links both worlds.

Hands-on, part 2: the semantic model with Ossie and DuckDB

The semantic model is the file that does the work at query time. The DuckDB extension ossie reads it, generates SQL from it and lets DuckDB run that SQL. Here are the most important parts:

version: 0.2.0.dev0
name: running
ai_context:
  instructions: >
    Always filter half marathons and other races via run_types,
    never by distance. A long training run can be longer than 21 km.

datasets:
  - name: runs
    source: running.main.runs
    primary_key: [run_id]
    fields:
      - name: year
        datatype: Integer
        dimension: {is_time: true}
        expression: {dialects: [{dialect: OSSIE_SQL_2026, expression: YEAR(run_date)}]}
      - name: distance_m
        description: Distance measured by GPS in meters
        expression: {dialects: [{dialect: OSSIE_SQL_2026, expression: distance_m}]}
      # run_id, run_date, run_type_id, time_sec work the same way

  - name: run_types
    source: running.main.run_types
    primary_key: [run_type_id]
    fields:
      - name: run_type
        description: Easy run, Long run or Half marathon
        ai_context: {synonyms: [type of run, HM, half]}
        expression: {dialects: [{dialect: OSSIE_SQL_2026, expression: run_type}]}
      # run_type_id, category work the same way

relationships:
  - name: run_has_type
    from: runs
    to: run_types
    from_columns: [run_type_id]
    to_columns: [run_type_id]

metrics:
  - name: pace_sec_per_km
    description: Seconds per kilometer, total time divided by total distance
    ai_context: {synonyms: [pace, average pace]}
    expression:
      dialects:
        - dialect: OSSIE_SQL_2026
          expression: SUM(runs.time_sec) / (SUM(runs.distance_m) / 1000.0)
  # run_count, distance_km, best_time_sec work the same way

OSSIE_SQL_2026 is Ossie’s expression language, a portable SQL core that should return the same result on every engine. The field year does not exist as a column. The model calculates it with YEAR(run_date) and marks it as a time dimension, so whoever asks for runs by year does not need to know that.

Load and ask

The extension is called ossie and comes from the DuckDB community repository. It is an independent implementation, not an official product of the Apache Software Foundation. The database must be called running, because the model addresses its tables as running.main.runs:

-- duckdb running.duckdb
INSTALL ossie FROM community;
LOAD ossie;
CALL ossie_load('running.semantic_model.yaml');

SELECT name, description FROM ossie_metrics();

Queries go through the table function ossie_query. It takes three lists: metrics, dimensions and filters. The model takes care of the rest.

Question 1: How fast did I run on average?

SELECT to_seconds(round(pace_sec_per_km)) AS pace
FROM ossie_query(['pace_sec_per_km']);
-- 00:05:56

-- for comparison, the naive average over all runs
SELECT to_seconds(round(avg(time_sec / (distance_m / 1000.0)))) AS pace_wrong
FROM runs;
-- 00:05:57

On my real runs, the naive average is only one second per kilometer too slow, because my runs are similar in length and pace. With a mix of short fast runs and long slow ones, as in the example from the introduction, the gap grows. In both cases the model decides the formula, not the person asking.

Question 2: How many half marathons have I run?

SELECT count(*) FROM runs WHERE distance_m >= 21097.5;
-- 6

SELECT * FROM ossie_query(['run_count'], [], ['run_types.run_type = ''Half marathon''']);
-- 5

The sixth run is my long training run of 21.6 km on September 9, 2024. To be honest: Ossie does not know what a half marathon is either. Whoever asks picks the right filter. An AI agent can find the stored value in the field description. The optional ai_context adds synonyms and an instruction not to classify races by distance. The extension does not derive this filter from the ontology.

Question 3: How did my half marathons in Ulm go?

SELECT "runs.year" AS year, to_seconds(best_time_sec) AS finish_time, to_seconds(round(pace_sec_per_km)) AS pace FROM ossie_query(['best_time_sec', 'pace_sec_per_km'], ['runs.year'], ['run_types.run_type = ''Half marathon''']);
┌───────┬─────────────┬──────────┐
│ year  │ finish_time │   pace   │
├───────┼─────────────┼──────────┤
│  2021 │ 01:49:36    │ 00:05:07 │
│  2022 │ 01:49:14    │ 00:05:00 │
│  2023 │ 01:45:22    │ 00:04:55 │
│  2024 │ 01:47:38    │ 00:04:59 │
│  2025 │ 01:51:40    │ 00:05:15 │
└───────┴─────────────┴──────────┘

The finish times are the official net times. The pace is a bit faster than the official distance would give, because it is based on the GPS distance. The model added the join to run_types and the grouping by year on its own.

What the model makes of it

The function ossie_compile shows the generated SQL without running it. That helps with reviews and with systems where DuckDB is not the engine that executes the query:

SELECT ossie_compile(['pace_sec_per_km'], ['run_types.category'], []);
SELECT run_types.category AS "run_types.category",
       (sum(runs.time_sec) / (sum(runs.distance_m) / 1000.0)) AS pace_sec_per_km
FROM (running.main.runs AS runs
      INNER JOIN running.main.run_types AS run_types
      ON ((runs.run_type_id = run_types.run_type_id)))
GROUP BY run_types.category

I tested all queries using the notebook I published on my GitHub repository.

Does it really help? A test with two LLMs

Whether ontology and semantic model help with that can be measured. The short answer: without documented values, both models I tested failed on my words. The ontology fixed the terms but not the pace calculation, and the metric API fixed the calculation as long as the model could use it.

The setup

The last part of the Colab notebook asks eight questions, including both questions from the introduction in two wordings each. Questions, expected values and the check for each question live in a SQL table. Every question runs under four conditions:

  • A, schema only, free SQL: the LLM sees the table schema and writes any SQL.
  • B, semantic model, metric API: the LLM sees the semantic model without its ai_context blocks and may read data only through ossie_query. Descriptions, metrics and relationships stay in.
  • C, semantic model with AI instructions, metric API: like B, plus the ai_context blocks with the synonyms and the instruction about races. They repeat selected domain knowledge by hand. The ontology itself is not part of this context.
  • D, schema plus ontology and mapping, free SQL: the LLM sees schema, ontology and mapping and writes any SQL.

The LLM sits in a single function. A switch at the top of the test selects Colab’s built-in Gemini, which needs no key, the Gemini API or any model on OpenRouter. For OpenRouter, the function looks like this:

LLM = 'openrouter'                  # 'colab' | 'gemini_api' | 'openrouter'
MODEL = 'z-ai/glm-5.1'              # model id at the chosen service

def ask_llm(prompt: str) -> str:
    r = requests.post("https://openrouter.ai/api/v1/chat/completions", timeout=180,
                      headers={"Authorization": f"Bearer {OPENROUTER_KEY}"},
                      json={"model": MODEL, "messages": [{"role": "user", "content": prompt}]})
    r.raise_for_status()
    return r.json()["choices"][0]["message"]["content"] or ""

For the numbers below, I ran the notebook in Colab with two open models via OpenRouter: GLM 5.1 from Z.ai, a large current model, and Llama 3.1 8B from Meta, a small and older one. Five runs each, 320 answers in total. Every attempt, including the failed ones, is in llm_attempts.csv in the repository, so each number below can be checked.

The results

Condition GLM 5.1: correct / wrong / failed Llama 3.1 8B: correct / wrong / failed
A: schema only, free SQL 17 / 23 / 0 4 / 35 / 1
B: semantic model, metric API 40 / 0 / 0 3 / 3 / 34
C: semantic model with AI instructions, metric API 40 / 0 / 0 2 / 6 / 32
D: schema plus ontology and mapping, free SQL 37 / 3 / 0 24 / 16 / 0

Answers after at most one retry, 8 questions times 5 runs per condition. “Failed” means that Ossie refused the query or the SQL did not run, even after the retry. GLM 5.1 got all its correct answers on the first try, except one each in B and C.

A closer look explains what happened.

The pace trap snapped wherever the formula was left to the model. Llama 3.1 8B averaged the individual paces in 19 of its 20 free-SQL answers to the pace questions. GLM 5.1 did so once with the schema only and three times with ontology and mapping. In three more answers with the schema only, GLM divided correctly but returned seconds per meter, a number no runner would read as a pace. Through the metric API, the pace was right every time a model managed to call it. On my data, the wrong formula costs only 1.3 seconds, but the mechanism is the one from the introduction.

The term trap snapped whenever the values were unknown. With the schema only, GLM 5.1 guessed “Half Marathon”, “HM” or “Long Run” and found nothing for half marathons, the abbreviation and the finish times in every run, and for long runs in four of five. Llama 3.1 8B made similar guesses. With the ontology, both models found the stored values, because its derived_by rules name them: GLM got every term right, Llama most of them. GLM was also perfect with the semantic model, even in condition B without the synonym “HM”, since the description of the field run_type lists the possible values. The test does not isolate any further benefit of the ontology’s hierarchy or rules.

The ontology fixed the terms, not the calculation. All three of GLM’s wrong answers with ontology and mapping were averaged paces. They reuse the mapping’s expressions per run, time_sec / 60.0 and distance_m / 1000.0, inside AVG. My hypothesis is that the mapping invites a calculation per run. Five runs cannot prove that, but it fits the split from the beginning of the article: the ontology says what a term means, not how to aggregate it.

The metric API only helps models that can use it. Llama 3.1 8B failed in 34 and 32 of 40 answers in B and C. It put text values in double quotes, which SQL reads as column names, wrote subqueries into filters, which Ossie refuses, or tried to read tables directly. Three and six answers were wrong; 34 and 32 failed visibly. Only three and two answers, respectively, were correct. Part of the problem is my prompt: its example shows a numeric filter, not a text filter in single quotes. The AI instructions in C did not help either model.

Assessment

The test is small, and that limits what it shows. The schema has two tables with telling names. In a real data warehouse with hundreds of tables and codes like RT_CD = ‘H’, I would expect a bigger benefit but with lots of finetuning efforts. The LLMs could not see the data. An agent with tools might have looked up which values the column run_type contains. Five runs per condition with two models are few, a difference of one or two answers can be chance.

A critical look

The example works, but Ossie is a young project. If you want to use it at work, you should know five things.

The specification is a draft. Ossie is still in the Apache Incubator and has no release yet. The current version is a draft. A file that validates today may need changes after the next release.

The DuckDB extension does not execute the ontology. It only reads the semantic model. You can validate the ontology and the mapping, but I found no tool that executes them, and the specification itself says that parsers and converters for mapping documents are still missing. Selected domain knowledge is therefore repeated in the semantic model, in ordinary field descriptions and in optional ai_context blocks such as the synonym “HM”. In this test, the descriptions alone were sufficient. Maintaining knowledge in two places is the price for now.

The extension is strict, but not complete. It emits all joins as inner joins, because Ossie does not define the join type. A run without a run type would disappear as soon as you group by run type. Metrics across several grains are refused rather than calculated wrongly. I saw that in a first version of this example with a table of yearly goals, which I later dropped. According to the project, metrics with window functions are refused as well. I think refusing instead of guessing is the right decision.

Ossie YAML or RDF? The Ossie ontology is written for data teams. Rules look like SQL, and the mapping points directly at datasets and fields. RDF with SKOS and OWL, on the other hand, has twenty years of tooling behind it, up to graph databases with built-in reasoning. If you already use RDF vocabularies, the iri field connects both worlds. If you start from scratch, Ossie YAML gets you to a result faster.

Alternatives. If you need a semantic model in production today, the dbt Semantic Layer and Cube are proven options. Ossie is therefore mainly an exchange format between these tools. The DuckDB extension is a light way to try it locally.

Outlook: from model to knowledge graph

An ontology describes concepts and their relationships. Add the actual runs and connect them to those concepts, and the example becomes a small knowledge graph. The mapping describes how database rows connect to the domain model.

Knowledge Graph

The ontology sits on top, the data below. Run 850 is linked to half marathon, which is a race, which is a run. The question “Which runs are races?” can now be answered by following that path, without storing “race” for every single run.

The notebook builds this graph in DuckDB with the community extension DuckPGQ, which implements SQL/PGQ, the graph part of the SQL standard. Two small tables hold the concepts and their “is a” relationships, a third connects each run to its concept. A property graph exposes these connections, and the query follows them:

FROM GRAPH_TABLE (running_graph
    MATCH (r:runs)-[e:run_is]->(c:concepts)
          -[i:is_a]->{1,3}(s:concepts WHERE s.concept = 'Race')
    COLUMNS (r.run_date, c.concept)
)
ORDER BY run_date;
-- five rows: the half marathons from 2021 to 2025

Add marathon as another kind of race, link a marathon run to it, and the same query includes that run without changes. This is where the relationships between concepts pay off: when you ask questions about actual data.

This remains an experiment. I fill the graph tables explicitly, the Ossie ontology and mapping are not executed to generate them. DuckPGQ is a research project. RDF and SPARQL would be another route, but that is beyond this example.

Conclusion

The ontology clarifies what a half marathon is. The semantic model clarifies how to calculate the pace. Both questions come up in every data project, only there the terms are called customer or contract.

Apache Ossie brings both levels into one open YAML format. The semantic model can already be queried with DuckDB today. The ontology is, for now, readable documentation that a validator can check. My suggestion in the spirit of data.KISS: start with a semantic model for the most important metrics and let the ontology grow as soon as the first terms cause discussions.

The test with real data shows the split from the beginning of this article. Without documented values, both models failed on my words. The ontology fixed the terms but not the calculation, and the semantic model fixed the formula, provided the model could use its interface. This test does not show that an ontology is required. It shows that a schema alone does not tell an LLM which values encode the concepts in a question.

All code runs in one Colab notebook: the data script, the three YAML files, the Turtle file, the graph and the LLM test. Open the notebook in Colab or find it in the GitHub repository.