ontology-guided-retrieval

v2026.09.24

Ontology-aware RAG. Leveraging domain ontologies (SNOMED CT, FIBO, schema.org, Gene Ontology, custom OWL) to expand queries with synonyms, hyponyms, and broader concepts; boost retrieval for ontology-matched entities; hybrid SPARQL + vector pipelines; RDFLib + embeddings. Examples with medical and financial ontologies. USE WHEN: user mentions "ontology", "SNOMED", "FIBO", "schema.org", "OWL", "SKOS", "RDF", "SPARQL RAG", "taxonomy-guided", "concept expansion", "hyponym retrieval" DO NOT USE FOR: building a KG from text - use `knowledge-graph-construction`; deduping entities - use `entity-resolution`; generic graph RAG - use `graph-rag`

GitHub
安装命令
npx skhub add claude-dev-suite/ontology-guided-retrieval
Markdown
SKILL.md

Ontology-Guided Retrieval

Vector RAG treats every chunk as an island of text. Ontologies bring structure: ibuprofen is-a NSAID is-a analgesic; USD has-currency-code 840; Software Engineer broader Engineer. Folding these relations into retrieval unlocks precision gains (exact concept matching) and recall gains (synonym and hyponym expansion) that vector-only systems can't match.

When to Use

  • Regulated domains with authoritative ontologies: healthcare (SNOMED CT, ICD-10, LOINC, RxNorm), finance (FIBO, GLEIF), biology (GO, ChEBI, UniProt), legal (LKIF, EuroVoc).
  • You already have a taxonomy (product categories, industry codes NAICS/SIC) you want to respect.
  • Queries use jargon that must map to canonical concepts for compliance or downstream systems.

Not worth it for: general-purpose consumer chat, corpora with no natural taxonomy.

Ontology Formats

FormatPurposeTools
OWL 2Formal ontology with classes, properties, axiomsProtégé, owlready2
RDFSLightweight: classes + subClassOf + labelsrdflib
SKOSTaxonomies: broader, narrower, related, prefLabel, altLabelrdflib, skosmos
schema.orgStructured markup vocabularyjson-ld, rdflib
JSON-LDJSON serialization of RDFpyld, rdflib

Most useful SKOS relations:

  • prefLabel — canonical name.
  • altLabel — synonyms.
  • broader / narrower — IS-A-ish hierarchy.
  • related — lateral association.

Loading an Ontology with rdflib

from rdflib import Graph, Namespace, URIRef
from rdflib.namespace import SKOS, RDF, RDFS

g = Graph()
g.parse("snomed-subset.ttl", format="turtle")      # or .owl, .rdf, .nt

# All narrower concepts of "Infectious disease"
q = """
SELECT ?c ?label WHERE {
  ?c skos:broader* <http://snomed.info/id/40733004> ;
     skos:prefLabel ?label .
}
"""
for row in g.query(q, initNs={"skos": SKOS}):
    print(row.c, row.label)

Owlready2 for OWL Reasoning

# pip install owlready2
from owlready2 import get_ontology, sync_reasoner_pellet

onto = get_ontology("fibo.owl").load()
sync_reasoner_pellet(infer_property_values=True)

for cls in onto.classes():
    print(cls.iri, list(cls.label), list(cls.ancestors()))

Reasoning materializes inferred subClassOf and property chains — use it once at ingest time, then query the materialized graph (don't reason at query time in RAG's hot path).

Query Expansion via Ontology

Given a user query containing ontology-matched terms, expand with synonyms, hyponyms, and broader parents.

def expand_terms(term: str, g: Graph) -> dict:
    q = """
    SELECT ?syn ?narrower ?broader WHERE {
      ?c skos:prefLabel|skos:altLabel ?t .
      FILTER (LCASE(STR(?t)) = LCASE(?input))
      OPTIONAL { ?c skos:altLabel ?syn }
      OPTIONAL { ?n skos:broader ?c ; skos:prefLabel ?narrower }
      OPTIONAL { ?c skos:broader ?b . ?b skos:prefLabel ?broader }
    }
    """
    syn, narrower, broader = set(), set(), set()
    for r in g.query(q, initNs={"skos": SKOS}, initBindings={"input": term}):
        if r.syn:       syn.add(str(r.syn))
        if r.narrower:  narrower.add(str(r.narrower))
        if r.broader:   broader.add(str(r.broader))
    return {"synonyms": list(syn), "hyponyms": list(narrower), "hypernyms": list(broader)}

Generate an expanded query for retrieval:

def build_expanded(query: str, matched_terms: list[str]) -> list[str]:
    variants = [query]
    for term in matched_terms:
        x = expand_terms(term, g)
        for syn in x["synonyms"]:
            variants.append(query.replace(term, syn))
        for hyp in x["hyponyms"]:              # narrower terms retrieve more specific docs
            variants.append(query.replace(term, hyp))
    return variants

Then dense-retrieve each variant and union/dedup results.

Concept Matching in Queries

Identifying which ontology concepts appear in a query:

  • Lexicon + lookup (fast): build a radix tree of all prefLabel + altLabel; longest-match tokenization.
  • SciSpaCy / MedCAT (medical): pretrained entity linkers to UMLS/SNOMED.
  • LLM + few-shot: for rare terms, ask an LLM to output ontology IDs given candidate concepts.
# MedCAT example for SNOMED
from medcat.cat import CAT
cat = CAT.load_model_pack("/path/to/medcat-snomed-modelpack.zip")
doc = cat.get_entities("Patient presents with NSTEMI and raised troponin.")
for ent in doc["entities"].values():
    print(ent["source_value"], ent["cui"], ent["pretty_name"])

Ontology-Boosted Retrieval

After detecting ontology concepts, boost chunks that mention the same concept IDs.

def retrieve_with_boost(query: str, concept_ids: list[str], top_k=20):
    vec_hits = vectorstore.similarity_search_with_score(query, k=top_k*3)
    boosted = []
    for doc, score in vec_hits:
        overlap = len(set(doc.metadata.get("concept_ids", [])) & set(concept_ids))
        boosted.append((doc, score - 0.05 * overlap))   # lower score = better in many libs
    boosted.sort(key=lambda x: x[1])
    return boosted[:top_k]

At ingest time, tag each chunk with the concept IDs detected in it so this filter is a metadata hit, not a runtime extraction.

SPARQL + Vector Hybrid

For queries with structured parts (Drugs that interact with warfarin AND treat atrial fibrillation), run SPARQL over the KG/ontology and use the result as a filter on the vector search.

def hybrid_retrieve(nl_query: str):
    sparql = """
    SELECT ?drug WHERE {
      ?drug a :Drug ;
            :interactsWith :Warfarin ;
            :treats :AtrialFibrillation .
    }
    """
    drug_ids = [str(r.drug) for r in kg.query(sparql)]
    if drug_ids:
        # Filter vector search to chunks mentioning any of these drugs
        return vectorstore.similarity_search(
            nl_query, k=10,
            filter={"concept_ids": {"$in": drug_ids}},
        )
    return vectorstore.similarity_search(nl_query, k=10)

LLM-generated SPARQL (like LLM-generated Cypher) needs: read-only endpoint, query validator, and timeout. Restrict the endpoint to a small SPARQL profile for safety.

RDFLib + Embedding Hybrid Store

Store entity embeddings in the same RDF graph using a literal property:

from rdflib import Literal, XSD
import base64, numpy as np

def attach_embedding(g: Graph, iri: URIRef, vec: np.ndarray):
    blob = base64.b64encode(vec.astype("float32").tobytes()).decode()
    g.add((iri, URIRef("urn:embed"), Literal(blob, datatype=XSD.string)))

Retrieval:

def top_k_entities(q_vec: np.ndarray, k=10):
    entities = []
    for s, _, o in g.triples((None, URIRef("urn:embed"), None)):
        v = np.frombuffer(base64.b64decode(str(o)), dtype="float32")
        sim = float(np.dot(q_vec, v) / (np.linalg.norm(q_vec) * np.linalg.norm(v) + 1e-9))
        entities.append((s, sim))
    return sorted(entities, key=lambda x: -x[1])[:k]

This is fine for <100k entities. Beyond that, put embeddings in a vector DB and keep only IRI+label in RDF.

Example: Medical RAG with SNOMED

# Query: "What treats angina in diabetics?"
concepts = medcat_extract(query)       # angina (194828000), diabetes (73211009)
# Expand via SNOMED: angina -> stable angina (59546005), unstable angina (4557003)
expanded_ids = expand_with_children(concepts, depth=2)

# SPARQL: find treatments
sparql = f"""
SELECT ?treatment WHERE {{
  ?treatment :treats ?cond .
  FILTER (?cond IN ({','.join(f'<{c}>' for c in expanded_ids)}))
}}
"""
candidate_ids = run_sparql(sparql)

# Retrieve clinical notes mentioning these IDs + the query vector
docs = vectorstore.similarity_search(
    query, k=10,
    filter={"snomed_ids": {"$in": candidate_ids + concepts}},
)

Example: Financial RAG with FIBO

# Query: "How do municipal bonds compare to treasury bonds for tax efficiency?"
# FIBO class hierarchy: MunicipalBond < DebtInstrument, TreasuryBond < GovernmentBond < DebtInstrument
# Expand query with FIBO labels + altLabels; boost chunks tagged with these IRIs.

GLEIF (LEI) adds precise issuer identification — tag each chunk with LEI when known so queries about "JPMorgan" vs "JP Morgan" vs "JPM" all resolve to the same entity.

Anti-Patterns

Anti-PatternFix
Expanding with every synonym/hyponym (explodes)Limit depth (1–2) and cap variants at ~5
SPARQL at query time on a giant live graphPre-materialize the subgraph or cache answers
Letting an LLM write SPARQL to a writable endpointRead-only endpoint + validator + timeout
Ignoring ontology versioningPin ontology version in manifest; re-index on upgrade
Free-text concept matching (no IRI)Always resolve to the canonical IRI/code
Reasoning at query time (owlready2 in hot path)Reason once at ingest; query materialized graph
Tagging chunks with only surface formTag with concept IDs for language-agnostic filtering

Production Checklist

  • Ontology version pinned and stored in manifest
  • Reasoning / expansion materialized at ingest time
  • Chunks tagged with concept IDs (not just surface text)
  • Query-time concept matcher (lexicon / MedCAT / LLM) benchmarked for precision
  • Query expansion capped (depth ≤ 2, variants ≤ 5)
  • SPARQL endpoint read-only, timeout-bounded, query-shape validated
  • Hybrid retrieval (concept filter + vector) benchmarked vs vector-only
  • Ontology update pipeline (re-tag chunks, bump manifest version)
  • Gold eval set covering rare concepts + hypernym/hyponym expansion
发现
标签

此技能尚未发布标签。

版本
最新版本元数据

版本

v2026.09.24

发布时间

2026年9月24日

分类

未分类

许可证

MIT

源路径

skills/rag/ontology-guided-retrieval

默认分支

main

最新提交

9496306

Tree SHA

fe4e2f1