Ontology Knowledge Fusion: Why I Stopped Trusting Pure RAG for Job Fit

I spent about three weeks last spring convinced our Job Fit feature on ITJobOpportunities was basically done. Vector search working, embeddings stored, cosine similarity humming along nicely in PostgreSQL with pgvector.

I spent about three weeks convinced our Job Fit feature on ITJobOpportunities was basically done. Vector search working, embeddings stored, cosine similarity humming along nicely in PostgreSQL with pgvector. Then a candidate uploaded a resume listing "JavaScript" as a primary skill, and the system matched him at 78% against a senior Java backend role. Wrong on almost every level. That's the day I started taking what I now call OKF , ontology knowledge fusion, a lot more seriously instead of treating pure RAG as the whole answer. I want to walk through what I mean by OKF, why plain RAG falls short for anything with structured domain knowledge (skills, job titles, seniority levels, whatever your taxonomy is), and how I ended up building a hybrid retrieval layer for ITJobOpportunities that mixes semantic search with a lightweight ontology. Not a knowledge graph database, nothing that fancy. Just enough structure to stop dumb mistakes like the JavaScript/Java thing. What RAG Gets You, and Where It Quietly Fails Retrieval Augmented Generation is simple in theory. You embed your documents, store the vectors, and at query time you pull the nearest neighbors and feed them to an LLM as context. For unstructured text (support tickets, documentation, contracts), this works well. I use it for resume improvement on ITJobOpportunities, and it's genuinely good at pulling relevant phrasing and formatting patterns from a corpus of well-written resumes. But RAG has a blind spot: it treats similarity as meaning. Cosine similarity between two embeddings tells you the vectors point in roughly the same direction in a high-dimensional space. It does not tell you whether "Java" and "JavaScript" are the same skill, related skills, or completely unrelated skills that happen to share seven letters. Embedding models pick up on surface patterns, and "Java" and "JavaScript" look almost identical to a tokenizer trained on general text. The same problem shows up with seniority. "5 years of experience" and "5+ years required" embed close together, fine. But "Junior Developer" and "Senior Developer" also end up closer than you'd expect, because the surrounding context (both are job titles, both mention "developer," both appear in similar sentence structures) pulls them together semantically even though they mean opposite things for hiring purposes. This is where a pure RAG pipeline, no matter how good your embedding model is (I've tried text-embedding-3-large, several DeepSeek variants through OpenRouter, and a couple of open-source sentence transformers), starts producing results that are technically similar but practically wrong. Defining OKF the Way I Use It I don't think OKF is some industry-standard term you'll find in a textbook. I use it internally to describe a pattern: take a structured taxonomy, an ontology, even a simple one, and fuse its relationships into the retrieval scoring instead of relying purely on vector distance. For ITJobOpportunities, that taxonomy is a skills graph. Nothing exotic. A PostgreSQL table with skill nodes and edges representing relationships like: Java is a parent category containing Spring Boot , Hibernate , Maven JavaScript is a sibling category, unrelated to Java despite the name overlap React and Angular are both children of Frontend Frameworks , so they're related but not equivalent AWS Lambda and Azure Functions are cross-linked as "equivalent concept, different vendor," which matters a lot when a recruiter is scanning for cloud experience and doesn't care which vendor That's the ontology part. The fusion part is where I combine this graph with the embedding similarity score, instead of picking one or the other. The Architecture I Landed On Here's roughly how the scoring pipeline works for Job Fit today. When a candidate uploads a resume against a job posting, I run two parallel retrieval paths and then blend them. public record SkillMatchScore( String skillName, double embeddingSimilarity, double ontologyWeight, double finalScore ) {} @Service public class HybridSkillMatcher { private static final double EMBEDDING_WEIGHT = 0.55; private static final double ONTOLOGY_WEIGHT = 0.45; public SkillMatchScore score(String candidateSkill, String jobSkill, double cosineSim, SkillOntology ontology) { double ontologyScore = ontology.relatedness(candidateSkill, jobSkill); double blended = (cosineSim * EMBEDDING_WEIGHT) + (ontologyScore * ONTOLOGY_WEIGHT); return new SkillMatchScore(jobSkill, cosineSim, ontologyScore, blended); } } The SkillOntology.relatedness call is the interesting part. It doesn't do a fuzzy string match. It walks a small graph of nodes and edges I maintain in Postgres and returns something like: 1.0 for an exact match ("Spring Boot" to "Spring Boot") 0.7 for a parent/child relationship ("Spring Boot" to "Java") 0.4 for a sibling relationship in the same category ("React" to "Angular") 0.0 for unrelated, even if the strings look similar ("Java" to "JavaScript") SELECT s2.skill_name, se.relationship_type, se.weight FROM skill_edges se JOIN skills s1 ON se.skill_id_a = s1.id JOIN skills s2 ON se.skill_id_b = s2.id WHERE s1.skill_name = 'Spring Boot' ORDER BY se.weight DESC; That query is nothing special, a handful of joins against maybe 400 rows right now. But it fixes the JavaScript/Java problem completely, because the ontology explicitly says relatedness is zero, and no amount of embedding similarity can push the blended score above the noise floor. I'll be honest, building the taxonomy itself took longer than I expected. I started with a spreadsheet, yes, a plain Google Sheet with skill names and parent categories, before migrating it into Postgres tables once the structure stabilized. About two weeks of manual curation, plus a batch job through DeepSeek to suggest additional relationships I hadn't thought of. The LLM-suggested edges needed review, some were wrong, but it sped up the initial pass a lot. Why Not Just Use a Real Knowledge Graph? I get asked this. Neo4j, or even RDF triples with a proper reasoner, would give you more expressive relationships than a weighted edge table in Postgres. For some domains, that's the right call. For ITJobOpportunities, I made a deliberate tradeoff: keep the ontology cheap to query, because the single backend, jobs-posting , running Java 25 and Spring Boot 4.1.0, already talks to PostgreSQL for everything else. Adding a graph database would mean another moving piece, another connection pool, another thing to monitor, for a relationship structure that isn't actually that deep. Skills taxonomies are mostly two or three levels deep. You don't need graph traversal algorithms for that. A recursive CTE handles it fine. WITH RECURSIVE skill_tree AS ( SELECT id, skill_name, parent_id, 0 AS depth FROM skills WHERE skill_name = 'Spring Boot' UNION ALL SELECT s.id, s.skill_name, s.parent_id, st.depth + 1 FROM skills s JOIN skill_tree st ON s.parent_id = st.id ) SELECT * FROM skill_tree WHERE depth <= 2; If I were building something with genuinely deep, cross-cutting relationships, I'd reach for a real graph database without hesitation. But most business domains I've touched, including a disclosure platform program and a lending microservices program I worked on years back, had shallow enough hierarchies that a relational model with weighted edges did the job. Where OKF Changes the RAG Prompt Itself Fusion doesn't stop at scoring. It also changes what context you hand the LLM when generating the final summary. On the Job Fit feature, once I have the blended scores, I don't just dump the top-k matched skills into the prompt. I structure it so the LLM understands the relationship type, not just the raw list. def build_context(matched_skills): lines = [] for skill in matched_skills: if skill.relationship == "exact": lines.append(f"- {s...