Ontology-Based Context Engineering: When RAG Is Not Enough
Leveraging Large Language Models in Large Legacy Systems
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.
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.
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
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.
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.
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.
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.
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.
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:
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.
What the repository builds
Three pieces are built from the repository layout I showed earlier:
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:
A fact that is real but mangled by the extraction is a collector bug.
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.
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 withreasoning/ql/is still valid OWL 2 QL (the check that catches a class declared withrdfs:Classwhere OWL 2 expectsowl: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 withowl: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.
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:
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_DEPENDENCIESand 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).:deprecatedInReleaseis 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.


