Skip to main content

Command Palette

Search for a command to run...

Ontology-Based Context Engineering: When RAG Is Not Enough

Leveraging Large Language Models in Large Legacy Systems

Updated
36 min readView as Markdown
Ontology-Based Context Engineering: When RAG Is Not Enough
E
Directeur Craft / Devops chez Sopra Steria Next

Note: Originally published on Medium

In my previous articles, I discussed Context Engineering and the BMAD method. Today, I want to take it one step further and introduce Ontology-based Context Engineering.

I will explain what it is, how it compares to RAG and other techniques, which standards exist (RDF, OWL, SKOS, SHACL and friends), how to choose between them, and finally how I applied all of this to a real legacy system:

  • More than 2 million lines of C and C++ code

  • Extensive PHP MVC code and PL/SQL

  • Hundreds of unstructured documents across multiple versions and releases

1. The problem: The meaning is never in one place

If you work with a modern, well-documented application, maybe you do not have this problem. I did not have this luck.

In a large legacy system, the meaning of any concept is spread across many locations, split and scattered:

  • The C/C++ code knows how things are computed, but not why

  • The PL/SQL packages contain critical business rules, hidden inside thousands of procedures

  • The PHP Smarty templates know how users interact with the data

  • The documents (specifications, release notes, migration guides) explain the why, but they exist in many versions, for many releases, and they often contradict each other

  • And a big part of the knowledge is only in the heads of people who are now enjoying a peaceful retirement 😀

When you ask an AI assistant a question like "What happens to an invoice when the client changes his contract in the middle of the month?", the answer is not in one file. It is distributed across a C module, two PL/SQL packages, a database table, a specification from 2017 and a correction note from 2021. No single chunk of text contains the answer.

However, that answer exists, but only as a path between all those artifacts. And for this reason, it has to be modeled properly first.

2. What is ontology-based context engineering?

Context engineering is the discipline of preparing and selecting the right information to put into the context of an LLM. The model depends on the context you give it.

An ontology is a formal, explicit model of a domain: the types of things that exist (module, package, table, document, release, business rule), the relationships between them (calls, writes to, is described by, applies to release, supersedes), and the constraints and logic that connect them.

Ontology-based context engineering means: instead of throwing raw text at the model, you first build a machine-readable map of your domain (a knowledge graph structured by an ontology), and you use this map to select, connect, and validate the context before passing it to the LLM.

What about embeddings?

The key idea is simple: embeddings capture similarity, ontologies capture meaning.

A vector database can tell you that two paragraphs "look alike". An ontology can tell you that PKG_INVOICE writes to table T_INVOICE, is called by the billing engine, is described by specification v3, and is deprecated since release 2024.1. These are facts, not similarities.

The two techniques can be used together, but they play different roles.

Facts decide what the model is allowed to look at: which artifacts, which documents, which release. Similarity then decides which paragraph inside that selection actually answers.

Both techniques are used; only the order is fixed. And that order is the whole design.

3. The landscape: RAG, GraphRAG and ontologies

Before choosing the ontology road, I tried almost everything else. Here is the comparison, based on my experience. And, of course, why classic RAG starts to fail in the situation I described earlier.

Six retrieval techniques compared on mechanism, strengths, weaknesses and best use. Long context: zero setup, noisy, lost at 2M+ lines. Fine-tuning: speaks your domain, costly, stale each release. Vector RAG: quick, but isolated chunks, mixed versions. GraphRAG: better multi-hop, but graph extracted not designed. Code graph: precise on code, blind to documents. Highlighted last row, ontology plus knowledge graph: precise, explainable, version-aware and traceable, at real upfront cost.

But be careful: Vector RAG still works very well for simple, single-hop questions, and it is much cheaper to build. The point is not that RAG is bad. The point is that vector-only RAG does not explicitly represent relationships and versions. In a legacy system, relationships and versions are exactly where the meaning lives.

4. The standards: RDF, OWL and friends

When you decide to build an ontology, you enter the world of W3C Semantic Web standards. The names can be scary, but the ideas are simple. I experimented with some of these technologies and studied the others to understand where they fit. Here is my map.

  • RDF (Resource Description Framework): The foundation. Everything is expressed as triples: subject → predicate → object (:BillingEngine :calls :PKG_INVOICE). It is the fundamental data model.

  • RDFS (RDF Schema): A lightweight schema layer on top of RDF: classes, subclasses, domains and ranges of properties. Often enough for lightweight schemas and simple ontologies.

  • OWL (Web Ontology Language): The more expressive option. Based on description logics, it lets a reasoner infer new facts: disjoint classes, cardinalities, transitive properties, equivalences, and much more.

  • OWL 2 DL: It is the decidable description-logic fragment of OWL 2 and the main target of description-logic reasoners. It is more expressive than the OWL 2 profiles, but more restrictive than OWL 2 Full. HermiT is a common reasoner for OWL 2 DL. Powerful, but the learning curve is real.

  • OWL 2 EL: An OWL 2 profile designed for large ontologies and scalable reasoning. It gives up some expressiveness in exchange for more scalable reasoning. ELK is a reasoner designed for this profile.

  • OWL 2 QL: An OWL 2 profile designed for efficient query answering over large amounts of relational data. Particularly interesting when the underlying data lives in existing databases.

  • OWL 2 RL: An OWL 2 profile designed for rule-based reasoning. Useful when reasoning can be expressed efficiently through rules and database technologies.

  • SKOS (Simple Knowledge Organization System): For vocabularies, thesauri and taxonomies: broader/narrower terms, preferred and alternative labels. Perfect for business glossaries.

  • SHACL (Shapes Constraint Language): Validation. It checks that your graph respects the rules you define ("every PL/SQL package must be linked to at least one specification"). Think of it as schema validation for your knowledge graph.

  • SPARQL: The query language, the SQL of graphs.

  • R2RML: A W3C standard for mapping relational databases to RDF. It lets you define how tables and columns map to your RDF vocabulary.

  • OBDA (Ontology-Based Data Access) / Ontop: An approach and tooling for querying existing data through an ontology. With a virtual knowledge graph, the data can stay in the relational database while SPARQL queries are translated into SQL. For a PL/SQL-heavy system, this one is gold.

  • Labeled Property Graphs (Neo4j): Not part of the W3C RDF/OWL stack, but a pragmatic alternative: great developer experience and fast to start, with a different graph model and without the RDF/OWL semantic layer.

Eight semantic standards compared on role, power, reasoning, validation and learning curve. RDF: the triple model, the foundation. RDFS: light schema, basic inference. OWL: formal semantics and inference, steep curve. SKOS: business terms and taxonomies. SHACL: constraints and validation, not inference. SPARQL: the query language. R2RML and OBDA: map a relational database, no duplication. LPG: speed over RDF/OWL semantics. Caption: layers, not competitors.

So, which is better?

Wrong question, and this is one of the most important lessons I learned. Although they solve different problems, these standards can be combined as layers. The realistic stack for a legacy project is:

RDF for the facts and relationships, RDFS where basic schema and hierarchy are useful, SKOS for the business vocabulary, SHACL for validation, SPARQL for querying, R2RML to plug in the relational database, and OWL only where inference is really worthwhile (impact analysis, derived relationships, deprecation rules).

Using OWL everywhere can make ontology projects difficult to maintain. A pragmatic layered stack is how they survive.

How to choose: My Decision Criteria

  1. Do you need automatic inference? In a legacy system, the answer may be different depending on the domain. Some parts may only need RDF to represent facts and relationships; others may need RDFS for basic schema and hierarchy; and only the parts where inference really pays off need OWL.

  2. Do you need to guarantee quality? In a legacy project with many contributors, yes → SHACL from day one. Define the constraints that your graph must respect and validate it continuously. SHACL is specifically designed for describing and validating RDF graphs against defined conditions.

  3. Where does the data live? Usually in more than one place, and each answer leads somewhere different:

    • Truth in a relational database (my case: Oracle + PL/SQL) → R2RML / OBDA exposes it through the ontology as a Virtual Knowledge Graph.

    • Truth in documents (specifications, release notes) → nothing to virtualize; everything is loaded into a graph: RDF (optionally with an OWL 2 ontology) or a property graph.

  4. Do you need a business glossary? Legacy systems often have three names for the same concept → SKOS is a good fit for business terms, synonyms and taxonomies.

  5. One stack or both? If the graph must live for years and be shared across teams and tools → the W3C stack is a strong choice. If it is an internal tool where developer productivity and graph traversal matter more than semantic-web interoperability → a property graph can be a fair choice. And it is not always either/or: a valid pattern is to keep the W3C stack as the source of truth and add a property-graph projection for the parts of the domain that need graph algorithms (Neo4j Graph Data Science, GDS), at the cost of a sync pipeline, since LPG does not provide OWL’s standard semantics.

Decision tree, five numbered questions for choosing a semantic stack. 1: which layers, RDF for facts, plus RDFS for schema and hierarchy, plus OWL 2 only if inference is needed. 2: need quality guarantees, then SHACL. 3: where the data lives, a relational database leads to R2RML/OBDA with Ontop as a virtual graph, documents to loading into a graph. 4: business glossary leads to SKOS. 5: one stack or both, W3C for long-lived, Neo4j for internal tools, both with W3C as source of truth.

Where the standards live

The question everybody asks right after the decision, and the one almost no article answers: where do these files sit, and what keeps them accurate?

Here is the good news: the artifacts are text-based. An OWL ontology, a SKOS glossary, a SHACL shapes graph and an R2RML mapping can all be represented using Turtle files. So, the answer is very simple: put them in a Git repository, review changes, test them in CI, and treat the ontology artifacts as code.

In my case, I did not need a GDS, so I skipped the property-graph stack. My repository looks like this:

Legacy ontology repository structure and reasoning workflow

Files in ontology/ declare what exists: the classes, the relations, the hierarchy, and some metadata. The OWL layer is limited to declarations and a few rdfs:range.

Be careful with rdfs:range, I got caught the first time: a range is not a constraint. If a collector emits :writesTable towards something that is not a table, the engine does not complain, unless you declared that class disjoint from :DbTable, which I had not. It infers that the target is a :DbTable. Checking that the target really is a table is the job of SHACL, not of the ontology.

Moreover, the axioms that make a reasoner derive new facts live next door, because a profile belongs to the engine that consumes the ontology, not to the ontology itself.

5. My Experience: A Massive Legacy Codebase

Now the real story. The project is a system with more than 2 million lines of C and C++, a large PHP layer, an enormous quantity of PL/SQL (a very big part of the business logic lives in the database), and hundreds of unstructured documents (specifications, release notes, migration guides) in many different versions, for many different releases. Some documents describe behavior that no longer exists. Some describe behavior that exists only from release X to release Y. Nothing tells you which one is true today.

What I tried first (and why it was not enough)

I did not start with ontologies. Instead, I started with the simple techniques, and I spent a lot of time on them:

Vector RAG: I chunked the code and the documents, embedded everything, and retrieved by similarity. For simple questions ("what does this function do?") it was fine. For real questions, it failed in a predictable way: it returned five chunks that looked similar to the question, taken from different releases of the same specification, and the model mixed them into one answer that looked right and was not. The retrieval had no notion of version or "this document supersedes that one".

Better chunking, metadata filters, rerankers: This brought some improvements, but the fundamental problem remained. People always suggest filtering by release, and they are right that it would fix the version mixing. The trouble is that I had nothing to filter on: the documents did not carry a reliable release tag, and producing that tag turned out to be most of the work I describe later. Similarity search does not explicitly model or reliably follow the path between those artifacts.

GraphRAG: I let an LLM extract entities and relations from the corpus. On clean English text this works reasonably well. On a mix of C code, PL/SQL, French/English documents and twenty years of naming conventions, the extracted graph was noisy: duplicated entities, invented relations, and no predefined schema to control any of it. Interesting for exploration but unusable as a source of truth in my case.

One nuance I only understood later: I did not throw that step away. Letting an LLM read documents and propose entities and links is exactly what my document pipeline still does. The difference is not the extractor, it is where its output lands. In the GraphRAG pipeline I tested, the extracted information becomes part of the graph. In mine, it lands in a staging area, behind a schema decided in advance, a validation gate and a human review queue.

I still use it to explore a subsystem I do not know: the entities it proposes are a decent first draft of the classes. But not as my source of truth. The pipeline I tested did not keep the release semantics, so it blended v2 and v3 like vector RAG did.

Each attempt was time consuming, and in terms of accuracy, none of them fit well enough. That is when I accepted that the problem was not only retrieval. The deeper problem was representation.

The ontology approach

So I inverted the logic. Instead of asking "how do I retrieve better chunks?", I asked "what are the things in this system, and how are they connected?". The core ontology was surprisingly small, about a dozen classes:

CModule, CppModule, SmartyTemplate, PhpController, PlsqlPackage, PlsqlProcedure, DbTable, SpecificationDocument, ReleaseNote, Release, BusinessRule, BusinessTerm.

And the relations that carry the meaning: calls, readsTable, writesTable, hasProcedure, implementsRule, describedBy, appliesToRelease, supersedes, deprecatedInRelease, replacedBy.

Those classnames describe my system, not the method. A Java and PostgreSQL shop would write SpringService and PgFunction where I wrote CModule and PlsqlPackage. The collectors and parsers would change, of course, but the ontology engineering approach would remain the same. Many of the relations remain useful across implementations, although their exact semantics and extraction rules depend on the system: calls, writesTable, describedBy, appliesToRelease are about how a system is assembled and how its truth changes over time, not about what it is written in.

The twelve classes: what they are, and how I found them

Because this article involves both RDF and C++/PHP, you need to pay attention to class definitions. A class here is an RDF class, not an object-oriented one. CModule is not a class declared somewhere in the code, it is the category a node belongs to, and the triple :BillingEngine a :CModule says nothing more than "this node is a C module". There is no constructor and no method. RDFS does let one class be the subclass of another, and I used that, but a subclass here is not code reuse.

That distinction has a consequence people miss on the first read: the model never describes what an artifact does internally. There is no save, no edit, no validate anywhere in the ontology, and no relation says how a value is computed. I deliberately keep detailed behavior in the code, where it is already represented, rather than trying to reproduce it exhaustively in the ontology. What the graph carries is what each artifact is and what it touches: calls, writesTable, describedBy, appliesToRelease. That is a deliberate restriction, and it is the one that makes the model small, because the behavior of two million lines is unbounded while the ways those lines connect to each other are not. It also happens to be exactly what a path-shaped question needs: to follow a screen to a package to a table to a specification, I need the links, not the semantics of each function.

So the twelve classes are the vocabulary of a description of the system, and the nodes typed with them are generated from the code and the documents rather than written by hand.

That split decides what the repository actually contains, and it is worth stating plainly. Every name in this article that reads like a real thing (:BillingEngine, :PKG_INVOICE, :T_INVOICE, :ProRataRule) lives in no Turtle file of the repository. The files hold the language: twelve class names, ten relation names, the few axioms that say what a relation entails when it chains, and the SHACL shapes that say what a valid graph must look like.

The store holds the description of my system written in that language, and that description is where the meaning of two million lines actually sits. The repository is deliberately silent even about which kinds connect to which: :calls is declared with no domain and no range, because a call here crosses PHP, C and PL/SQL. The Java and PostgreSQL shop from earlier could take that repository and most of it would survive the trip. The store would be worth nothing to them, because it describes my system and only mine.

None of this is in tension with the repository being the source of truth, and a section further down returns to it: the facts in the store are derived, so the code is the source of the facts and the repository is the source of how to read them.

Below is an extract from the ontology/ folder:

@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix owl:  <http://www.w3.org/2002/07/owl#> .
@prefix :     <https://example.com/legacy#> .

<https://example.com/legacy> a owl:Ontology ;
    owl:versionInfo "1.4.0" .

:CModule        a owl:Class ; rdfs:label "C module" .
:PlsqlPackage   a owl:Class ; rdfs:label "PL/SQL package" .
:BusinessRule   a owl:Class ; rdfs:label "Business rule" .
:DbTable        a owl:Class ; rdfs:label "Database table" .

:calls          a owl:ObjectProperty .   # no range on purpose: a call crosses PHP, C and PL/SQL
:writesTable    a owl:ObjectProperty ; rdfs:range :DbTable .
:implementsRule a owl:ObjectProperty ; rdfs:range :BusinessRule .

That file names the terms and says what a relation points at, and every engine reads it the same way.

The axioms that depend on which engine reads them live apart, and the extract below comes from the reasoning/rl/ folder:

@prefix owl:  <http://www.w3.org/2002/07/owl#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix :     <https://example.com/legacy#> .

# a module that calls a package implementing a rule reaches that rule
:reachesRule    a owl:ObjectProperty ;
                owl:propertyChainAxiom ( :calls :implementsRule ) .
:implementsRule rdfs:subPropertyOf     :reachesRule .

# a specification that supersedes a superseded one supersedes it as well
:supersedes     a owl:ObjectProperty , owl:TransitiveProperty .

Finally, we can find a small extract from the graph itself, written entirely in those terms. Nobody typed those triples, and unlike the two snippets above they live in no folder of the repository: the repository holds the terms and the axioms, while the facts expressed in them are produced by the collectors and loaded into the graph at build time.

@prefix :    <https://example.com/legacy#> .
@prefix g:   <https://example.com/legacy/graph/> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .

# structural facts, as collected from the sources of release 2022.2
g:R2022_2 {
    :BillingEngine a :CModule ;
        rdfs:label "Billing engine (C)" ;
        :calls :PKG_INVOICE ;
        :readsTable :T_CONTRACT .

    :PKG_INVOICE a :PlsqlPackage ;
        rdfs:label "PKG_INVOICE" ;
        :hasProcedure :CALC_PRORATA ;
        :writesTable :T_INVOICE ;
        :implementsRule :ProRataRule ;
        :describedBy :SPEC_INV_V3 .

    :CALC_PRORATA a :PlsqlProcedure ;
        rdfs:label "CALC_PRORATA" ;
        :implementsRule :ProRataRule .
}

# facts that span releases: documents, business rules, lifecycle
g:shared {
    :PKG_INVOICE
        :deprecatedInRelease :R2024_1 ;
        :replacedBy :PKG_INVOICE_V2 .

    :SPEC_INV_V3 a :SpecificationDocument ;
        :appliesToRelease :R2021_1, :R2022_2 ;
        :supersedes :SPEC_INV_V2 .

    :ProRataRule a :BusinessRule ;
        rdfs:label "Mid-month contract change is invoiced pro rata" .
}

:implementsRule appears twice on purpose, once on the package and once on the procedure inside it. The package-level edge is the one a question about the system reaches first. The procedure-level edge is what lets an answer say which of forty procedures carries the rule.

This extract is in TriG and not in Turtle, because I need to show where the triples are stored. This is the mechanism behind the version handling I promised earlier.

A triple has no place for a release: :BillingEngine :calls :PKG_INVOICE is true in 2021.1 and maybe false in 2024.1, and its three positions leave no room to say it. An RDF dataset adds a fourth one, the name of the graph a triple belongs to, and TriG is the syntax that writes it. So I opted for one named graph per release (g:R2022_2, g:R2024_1, etc.).

The collectors load the structural facts of a release into its graph, and a question runs against the graph of the release the user works on. What spans several releases goes into a shared graph: a specification covers several releases, so it carries :appliesToRelease; a business rule does not depend on a release; and :deprecatedInRelease or :replacedBy describe the life of a package across releases, not its state in one of them. RDF-star is the other option to attach a validity to a triple, but named graphs were enough for me.

So far the graph can answer things that no similarity search alone can answer reliably:

  • Which specification applies to release 2022.2?

  • What depends on PKG_INVOICE?

  • Which C modules implement the pro-rata rule, directly or indirectly?

The first two are plain SPARQL over the facts above. The third one taught me something about the reasoner. The property chain gives :BillingEngine :reachesRule :ProRataRule for the modules that call the package directly. But a SPARQL 1.1 property path (?m :calls+/:implementsRule ?rule) answers the direct and the indirect case, at any depth, with no reasoner at all. So the chain is comfortable, not necessary. Where the reasoner really pays is on the facts that do not depend on a release. :supersedes lives in the shared graph, so declaring it transitive costs one closure, computed once and never leaking across releases, and every query that asks which specification is authoritative, and every shape that checks the version chain, reads plain edges instead of walking it by hand. And, where an explicit rule or query is defined, the deprecation of a package can be propagated to its procedures.

A reasoner does not know which graph a triple belongs to. If it is run over the entire store, it can combine facts from different releases and derive conclusions that were never true in any single release. For example, it might combine a :calls triple collected in 2022.2 with an :implementsRule triple collected in 2024.1 and derive a :reachesRule triple that belongs to neither release.

There is also a storage problem. A reasoner typically writes its inferred triples to the default graph. As a result, even valid inferences may not be visible to a query scoped to a particular release graph, such as g:R2022_2.

To avoid both problems, the build runs the reasoner separately for each release. For each run, it uses the release graph together with the shared graph, then loads the inferred triples back into that release graph. This prevents facts from different releases from being mixed and ensures that release-scoped queries can see the inferred triples.

The shared graph is reasoned over separately because its facts are independent of any release. For example, a transitive closure such as :supersedes can be computed once and reused across releases, making it much cheaper than repeating the same reasoning for every release.

The diagram below is a wider extract: it adds the screen and the PHP controller that lead into BillingEngine, and leaves the successor package out, because what matters here is the overall shape.

Graph extract around one PL/SQL package, in three zones. Code: contract_change.tpl calls ContractChangeCtrl.php, which calls the BillingEngine C module, which calls PKG_INVOICE. Knowledge: PKG_INVOICE implements the pro-rata rule, is described by SPEC_INV_V3 (superseding V2), is valid from 2021.1 until 2022.2 and deprecated in 2024.1. Database: BillingEngine reads T_CONTRACT, PKG_INVOICE writes T_INVOICE. Caption: the meaning is in the edges, each relation a fact, not a similarity score.

What the repository builds

Three pieces are built from the repository layout I showed earlier:

The legacy-ontology git repo and the three runtime artifacts built from it: ontology (RDFS and OWL classes), reasoning axioms, one folder per OWL 2 profile (rl for transitivity and chains, ql for what Ontop rewrites), vocab (SKOS), shapes (SHACL), mappings (R2RML) and queries (SPARQL). CI loads the triple store from rl; the mappings configure an Ontop endpoint over Oracle from ql; a context service assembles the context. Caption: the profile belongs to the engine, not the ontology.

A triple store is the database of the graph: where a relational database stores rows, it stores triples, subject then predicate then object (:BillingEngine :calls :PKG_INVOICE), and it answers SPARQL instead of SQL. CI fills it from the Turtle files plus everything the collectors extracted from the code and the documents, and it is the engine that loads reasoning/rl/.

Next to it, the Ontop endpoint serves the half of the graph that is never copied: mappings/*.r2rml.ttl says how Oracle tables become triples, reasoning/ql/ is the only reasoning folder it loads on top of the declarations in ontology/, and SPARQL is translated into SQL against the live database, but the mappings and the ontology still need maintenance. That's why the mappings arrow in the diagram leaves the mappings/ folder and not the repository as a whole. That half has a release too, and it gets it from the database itself: each Oracle schema is a release apart, so the mappings cover every release still maintained, one set per schema, and each set writes its triples into the named graph of its release (rr:graph), the same graph name the collected facts of that release carry in the triple store, so the context service merges the two halves by release without any bookkeeping of its own. A release that is no longer maintained has no schema left, so no virtual half, only its collected facts.

In front of both sits the context service, the only component the LLM ever talks to: it resolves the question to entities, runs the queries from queries/ filtered by the release the user works on, and returns one small package instead of a dump. The same service exposes those queries as MCP tools.

The important mental switch: the triple store is a build artifact. The source systems remain authoritative for facts; the ontology repository is authoritative for their representation, validation and querying. If the store burns, you rebuild it from the repo and the fact store, the database where everything the collectors accepted accumulates, release after release.

That fact store is the one component a rebuild cannot regenerate. It contains all the decisions a domain expert made on a quarantine entry and all the facts from different sources. The facts could be collected again from the tagged sources. The decisions could not, and they are the reason it is the one thing to back up. The triple store is what comes out when the repository and the fact store are loaded together and the reasoner has run.

How the graph gets initialized

The repository layout says what the artifacts are, not where their content came from. That first load deserves a paragraph of its own, because readers may mistakenly think that someone would have to write Turtle for two million lines of code, entity by entity. Nobody can do that.

Only the top layer is designed rather than collected, and it is deliberately tiny. A few hundred lines of Turtle, and the only part of the repository where I read every line before it lands. Getting that first version right took me almost 4 days.

Everything else is generated by a first full collector run, and that run is deterministic from end to end. It walks the sources file by file: the call graph and the table accesses come out of static analysis of the C and C++, the PL/SQL half comes out of the Oracle data dictionary (ALL_DEPENDENCIES for the package-to-object dependencies, PL/Scope with ALL_STATEMENTS and ALL_IDENTIFIERS to know if it is a read or a write, because ALL_DEPENDENCIES only tells you that a package touches a table), the screen-to-controller chain out of PHP route analysis. Each collector emits Turtle expressed in the classes and relations ontology/ already declares. No LLM produces a single fact in that baseline. A parser knows that function A calls function B, and asking an LLM to guess it would be slower, more expensive and less certain.

I should be precise about what deterministic means here, because I used the word for the method and not for the result. A parser gives the same output every time it reads the same file, and no model is involved. That does not make every fact certain: an EXEC SQL block tells me which table it touches, a statement built at runtime does not, and I never resolved calls made through function pointers. So the collectors attach a confidence to what they emit, and facts from ambiguous constructs are considered less reliable and remain marked as such.

What the collectors produce does not enter the graph on arrival. It lands in a staging area, the SHACL shapes run over it, and whatever violates a shape is quarantined with its violation report attached instead of being loaded. The gate itself is deterministic: a shape holds or it does not, and the report names the node, the shape and the constraint that failed.

The early runs generated a lot of quarantine entries, and that is the useful part, because every entry is one of three things and each one has a different fix:

  1. A fact that is real but mangled by the extraction is a collector bug.

  2. A fact that is real and legitimate but rejected by a rule that was too strict is a correction to the shape or to the ontology, and since shapes live in the repository, that correction travels through exactly the review process described in the next section.

  3. A fact that is simply not true in the sources stays out and requires human analysis.

The loop did not succeed on the first attempt. It took ten passes. Collect, read the quarantine, fix the collector or the shape, collect again, ten times over before what came out was worth trusting, with the first three runs carrying most of the volume. This is also the one place where I used an LLM in the baseline, and only as a sorter: grouping the violations into families and suggesting which families looked like a rule to adjust rather than a collector to fix. It decided nothing. Every verdict was mine, and that loop took 20 to 25 days, collectors not counted: they were already written.

The documents are the exception, and the one place where an LLM proposes content instead of sorting reports: classifying a specification, detecting which releases it applies to, proposing that a section describes a given package. Those proposals do not enter the graph directly either, they go to a human review queue.

So the shape of the baseline is simple: the code side is parsed, the document side is proposed then reviewed, and both cross the same validation gate. Once the baseline is reproducible from an empty store, everything after it is a delta, as described in the next section.

How the graph gets updated

The delta goes through a governance loop that takes a change from proposal to deployed graph without breaking the consumers.

Updating follows the same logic as any code change:

  • Change proposal. A developer or a domain expert needs a new class, relation or rule (e.g. "we need to model batch jobs"). It becomes an issue, then a pull request on the Turtle files.

  • Ontology validation. Automatically, on the pull request: SHACL validation on sample data, a reasoner consistency check (no logical contradictions), a profile check that ontology/ together with reasoning/ql/ is still valid OWL 2 QL (the check that catches a class declared with rdfs:Class where OWL 2 expects owl:Class), and SPARQL regression tests, which check that existing queries still return the expected results. A broken ontology never reaches the main branch.

  • Human review. The domain person is not optional: the ontology is a contract about meaning, not just syntax.

  • Merge and version. Tag the release and record the version in the ontology itself (owl:versionInfo, owl:versionIRI). One rule saves you from breaking every consumer: deprecate, never delete. Mark old terms with owl:deprecated true, just as you would deprecate an API.

  • Deploy and repopulate. Re-run the collectors over what changed, merge the result into the fact store, then rebuild the store; if the mappings have changed, update the R2RML/OBDA mappings. The new graph is served. The same step also runs on its own at every release of the sources (a commit, a tag or the nightly run), with no proposal and no review, because the ontology already covers what changed.

  • Feedback. When the LLM gives a wrong or incomplete answer, or a release brings an artifact the ontology does not describe yet, that becomes the next change proposal. The loop closes.

Governance loop for an ontology change. A proposal from a developer or domain expert becomes a pull request on the Turtle files. Ontology validation: SHACL, consistency check, OWL 2 profile check and SPARQL regression tests; a failure returns to the proposal. Review by a developer and a domain expert. Merge and version tag, deprecating not deleting. Deploy and repopulate, on the merge and at every release of the sources (commit, tag or nightly), then serve release-filtered context.

If this looks exactly like the lifecycle of a shared library (semantic versioning, CI, code review, deprecation policy), that is the point. Your team already knows how to do this. The only new skill is managing Turtle files.

How the LLM uses it

At question time, the pipeline became: entity linking → graph traversal → context assembly. The question is mapped to entities in the graph (using SKOS labels and synonyms), SPARQL queries collect the relevant subgraph filtered by the release the user works on (the named graph of that release, plus the shared graph, whose documents are filtered on :appliesToRelease while its rules and lifecycle facts come as they are), and only then are the connected facts, plus the exact document sections they point to, given to the LLM as context. Vector search did not disappear: it still finds the right paragraphs inside a document. But the graph decides which documents and which code artifacts are on the table, and the versions no longer get mixed.

The difference was not subtle. Here is the kind of question that changed: which specification governs the pro-rata computation for a client on release 2022.2. The vector setup returned passages from SPEC_INV_V2 and SPEC_INV_V3 side by side, without any indication of which one was valid, and the model picked one. The graph selects SPEC_INV_V3, because it applies to 2022.2 and supersedes v2 under the project’s policy for choosing the latest applicable specification, and it names PKG_INVOICE as the package implementing the rule. And, very important for adoption, every answer came with a traceable path: this module → this package → this table → this document, this release.

Developers can verify the path but not a pile of similar chunks!

The full pipeline, step by step

Let me put the complete flow in one place, as an overview:

The six-stage pipeline as a loop, with the ontology, SKOS vocabulary and SHACL shapes as the contract every stage reads. Four build the graph: Analyzing picks a standard per artifact family, Collecting runs one collector per source family, Transforming merges with SKOS and puts LLM proposals behind a SHACL gate, Versioning appends a delta per release. Two use it: Retrieving does entity linking then release-filtered SPARQL, Serving returns context or MCP tools with the answer and its path.
  • Analyzing. Before writing a single collector, walk through your artifact families and decide which standard represents each one: RDF triples for the structural facts in the code, R2RML/OBDA for relational data that stays in Oracle, SKOS for the bilingual vocabulary, SHACL for the quality rules. The output is a small core ontology plus a decision table that becomes the contract for every collector you write next.

  • Collecting. Scan all the sources: static analysis of the C/C++ (call graphs, table access), the Oracle data dictionary (ALL_DEPENDENCIES and PL/Scope) for PL/SQL, PHP routing, and every document repository. Each fact is collected with its origin, its date and, when detectable, its release.

  • Transforming. Map the raw facts to the ontology terms, merge duplicated entities (legacy systems always have three names for the same concept, using SKOS for synonyms and labels), and let the LLM propose classifications and links that a human validates. Then, SHACL shapes act as a quality gate: any fact that fails the defined constraints is quarantined instead of entering the graph. What passes is loaded into the triple store, or, when the truth already lives in the relational database, exposed virtually through an OBDA/VKG layer.

  • Versioning. The collectors run again at every release. New facts land in the named graph of that release, and the facts that disappeared are not deleted: they stay in the named graphs of the releases where they were true, and the fact store records the release in which each one was last seen (validUntil). :deprecatedInRelease is a special case: it comes either from a release note or from the comparison of two collector runs, and the fact store keeps track of which one. A package that disappears from the code is not always deprecated, sometimes it was just renamed.

  • Retrieving. At question time: entity linking maps the question to graph nodes, SPARQL queries the relevant subgraph filtered by the user's release (its named graph, plus the shared one), and the connected facts plus the exact document sections are assembled into the context.

  • Serving. The assembled context goes to the LLM, which writes the answer with its traceable path attached. The same graph is also exposed as tools an agent can call, and this is where it becomes really interesting: before proposing a fix, the assistant asks the graph for the impact (who calls this procedure? which business rules does it implement? which screens touch this table?), so the generated change respects what the graph knows. Once that change is merged, the cycle restarts at analyzing: most of the time the contract already covers what changed and that pass costs nothing, but when the change brings in an artifact family or a concept the ontology does not describe yet, this is where it gets added, and the collectors run again.

Notice that the list splits in two. Stages 1 to 4 build and maintain the graph, which is classic data engineering: stage 1 is a decision, stages 2 to 4 are the CI job that reruns at every release. Stages 5 and 6 use it at question time, which is the context-engineering part. The ontology is the contract between them.

What it cost me

  • The upfront modeling took real time, but far less than the accumulated time I lost tuning RAG pipelines that could not work.

  • The value is in the pipelines that keep the graph up to date, not in the ontology file. An ontology that is not maintained and a graph that is not updated with each release are just more outdated documentation.

  • Start small. My first working ontology covered only one subsystem. It proved the value in weeks, and it can grow from there.

6. Conclusion

  • RAG is not dead. It is simply not sufficient when the meaning of your system is split across code, database, documents and releases.

  • Ontologies are not academic. RDF + RDFS + SKOS + SHACL is a pragmatic and effective stack. Keep OWL for the places where inference pays.

  • The graph is the semantic layer for context engineering. The LLM stays the language expert; the ontology decides what the LLM is allowed to see, for which release, with a traceable path.

  • On a 2M-line legacy system, this was the first approach that fit the constraints.

This article stops at the map. The territory (how to extract a reliable graph from C/C++, PL/SQL and twenty years of documents, and how to serve it to the model without losing your sanity) is where most of the real work sits, and I intend to give it the detail it deserves in dedicated articles.


Let's keep in touch

Before I wrap up, I’d love to hear from you.

Have you tried RAG on a large legacy system? Did you run into the same challenges with scattered meaning and mixed versions? Are you using ontologies, property graphs, or something else?

Share your experience in the comments. I read and respond to every one.

If you enjoyed this article, follow me on X and connect with me on LinkedIn. Your feedback also helps me choose what to explore next.

S

"The meaning is never in one place" and "the documents contradict each other across versions" is the exact problem we hit with a house instead of a codebase. ML Systems builds an ontology for residential deconstruction, and the same building is described by a town assessor record, a homeowner's memory, satellite and street imagery, and seven AI agents that each write their own claims. They disagree constantly, and the disagreements are usually about the same node.

Two choices we made that might transfer back to your legacy-system case:

  1. The ontology types claims, not facts. Your "PKG_INVOICE is described by specification v3" is true for one release and false for another; our "roof re-shingled in 2016" is STATED by the homeowner, absent from the assessor's RECORD, and MODELED by vision. Instead of resolving that at ingestion, every edge carries its source and an evidence grade (MEASURED > STATED > RECORD > MODELED), authority is scoped to a domain (the assessor is authoritative on legal and valuation facts, vision on the visible envelope, the homeowner on intent and recent work), and where two credible sources still collide the node resolves to a conflict state and stays there. The LLM is allowed to see the conflict. For a 20-year document trail I suspect the contradictions are the most valuable edges in the graph, not noise to clean out.

  2. Your "the model never describes what an artifact does internally" rule matches ours exactly. The Collective Ontology stores what a member is and what it touches (a rafter sits on the plate, ties to the ridge, is fastened through by the sheathing) and nothing about how; the disassembly sequence is derived from the edges as a DAG, not stored. The ontology grows by extending codes, not storage, which is your PR-on-Turtle-files loop with the reasoner swapped for a scheduler.

The territory article is the one I'd like to read. Extracting a reliable graph from contradictory sources is where our work sits too.

github.com/MLSystemsRI/ml-systems-public

E

Thank you so much, Salman, for your thoughtful and detailed feedback! I really appreciate that you read the article so closely and drew these parallels with your own work. I know how challenging and complex this kind of work can be.

My approach was shaped by the specific problems I encountered, and I don’t see it as a universal solution to every context-engineering problem. I haven’t faced the exact challenge you’re describing, so your perspective is particularly interesting to me.

Your approach of distinguishing claims from facts sounds particularly insightful. I also really like the idea of treating the ontology not as a cleaned-up representation of reality, but as what we currently have evidence to believe about reality. I’ll definitely take a closer look at your work!

Best regards,

Elie

E

I would like to add that one important difference I see between our cases is that, for a given version, the code ultimately has deterministic behavior, which is not necessarily the case when describing a house from different sources. A function cannot really do “yes and no” at the same time.

The rule may change from one version to another, and understanding why it changed is extremely valuable context. But at the end of the day, what matters most is being able to determine what the function actually does in that specific version.

So I think contradictions between documentation, specifications, historical sources, etc. are important to preserve as context and evidence, but they don't necessarily need to remain unresolved when it comes to the actual behavior of the code.

S

That is the right distinction, and it is where the two cases meet rather than split. The ledger keeps the contradiction in the row as claims, but the record still has to behave deterministically for a purpose: a takeoff needs one roof height. So a seat stamps a value. The Custodian's stamp, two keys bound to a content hash, is the equivalent of your "what the function does in this version." Change the underlying content and the stamp lapses, which is a new version. Until a seat stamps, a conflict row is quarantined and nothing downstream may consume it. Evidence preserved, behavior resolved, and the resolution is signed by someone who can be asked why.