<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Kaltofen</title>
    <description>The latest articles on DEV Community by Kaltofen (@coldoven).</description>
    <link>https://dev.to/coldoven</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3869877%2F34aac0c8-6ee5-4425-892e-96077ce2d0b7.jpg</url>
      <title>DEV Community: Kaltofen</title>
      <link>https://dev.to/coldoven</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/coldoven"/>
    <language>en</language>
    <item>
      <title>What does electricity have to do with knowledge graphs?</title>
      <dc:creator>Kaltofen</dc:creator>
      <pubDate>Tue, 07 Jul 2026 09:12:59 +0000</pubDate>
      <link>https://dev.to/coldoven/what-does-electricity-have-to-do-with-knowledge-graphs-3kgd</link>
      <guid>https://dev.to/coldoven/what-does-electricity-have-to-do-with-knowledge-graphs-3kgd</guid>
      <description>&lt;p&gt;Christopher Nolan? Crime? Not really... But this is what my first tries for a knowledge graph did. &lt;/p&gt;

&lt;p&gt;It wasn't hallucinating. There was a real &lt;code&gt;directed_by&lt;/code&gt; edge leaving a node called "Crime," and a plain traversal did exactly what traversals do: it followed the edge and handed back a confident, wrong answer. Every node existed. Every edge existed. The graph was structurally perfect and semantically nonsense.&lt;/p&gt;

&lt;p&gt;Here's the thing that fixed it, and the surprise that came with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ontology some rare teams have
&lt;/h2&gt;

&lt;p&gt;You probably have an ontology somewhere. It's usually a picture: a boxes-and-arrowsdiagram, a wiki page, a PDF nobody opens. Mine is a YAML file the code actually loads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;movie&lt;/span&gt;

&lt;span class="na"&gt;relationships&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;directed_by&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Movie&lt;/span&gt;
    &lt;span class="na"&gt;range&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Person&lt;/span&gt;
  &lt;span class="na"&gt;has_genre&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Movie&lt;/span&gt;
    &lt;span class="na"&gt;range&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Genre&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;domain&lt;/code&gt; = the type an edge is allowed to leave. &lt;code&gt;range&lt;/code&gt; = the type it's allowed to reach. &lt;code&gt;directed_by&lt;/code&gt; goes &lt;code&gt;Movie -&amp;gt; Person&lt;/code&gt;. That's the whole declaration. Everything below runs off it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Job one: lint the walk, not the graph
&lt;/h2&gt;

&lt;p&gt;The Nolan bug is a domain violation: &lt;code&gt;directed_by&lt;/code&gt; left a &lt;code&gt;Genre&lt;/code&gt; node instead of a &lt;code&gt;Movie&lt;/code&gt;. You can't catch it by looking at the edge, because the edge is genuinely there. So you check each hop against the declaration &lt;em&gt;before&lt;/em&gt; you follow it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;open_kgo.feature_groups.kg.ontology.registry&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OntologyRegistry&lt;/span&gt;

&lt;span class="n"&gt;OntologyRegistry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;metaqa_ontology.yaml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;traverse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;relationship&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;entity_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;OntologyRegistry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_valid_edge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entity_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;relationship&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Ontology violation: &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;relationship&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; is not valid from &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;entity type &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;entity_type&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; in namespace &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;namespace&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;expected_range&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;OntologyRegistry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_range_type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;relationship&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;out_edges&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;relation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;relationship&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;target_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;expected_range&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;target_type&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;expected_range&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Range violation: &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;relationship&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; expects range &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;expected_range&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; but reached &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; of type &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;target_type&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the bad hop fails loudly instead of returning &lt;code&gt;[]&lt;/code&gt; in silence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Ontology violation: 'directed_by' is not valid from entity type 'Genre' in namespace 'movie'.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two failure shapes get caught. That one is a &lt;strong&gt;domain&lt;/strong&gt; violation (left the wrong type). The other is a &lt;strong&gt;range&lt;/strong&gt; violation (landed on the wrong type), which name collisions love to cause: "Romance" is both a film and a genre, so a &lt;code&gt;has_genre&lt;/code&gt; edge can end up pointing at a node typed &lt;code&gt;Movie&lt;/code&gt;. The range check stops it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Range violation: 'has_genre' expects range 'Genre' but reached 'Romance' of type 'Movie'.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;This is &lt;strong&gt;not&lt;/strong&gt; SHACL or a SPARQL constraint. Those scan the whole graph, once, after &amp;gt; the fact. This validates the &lt;em&gt;walk&lt;/em&gt;, at the hop, as it runs. By the time your agent is &amp;gt; three hops deep, "the query came back empty, something broke" is useless. "Hop 2 is &amp;gt; invalid, &lt;code&gt;Genre&lt;/code&gt; has no outgoing &lt;code&gt;directed_by&lt;/code&gt;" is a bug you fix before lunch.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The repo ships an eval to prove the split. It builds a batch of traversals that are invalid by construction (every &lt;code&gt;Genre&lt;/code&gt; node attempting &lt;code&gt;directed_by&lt;/code&gt;, plus &lt;code&gt;Person&lt;/code&gt; nodes attempting &lt;code&gt;starred_actors&lt;/code&gt;) - 55 cases on the committed sample graph. Plain traversal: silent empty list on &lt;strong&gt;100%&lt;/strong&gt; of them. Ontology-guided: named error on &lt;strong&gt;100%&lt;/strong&gt; of them. It's a notebook you can re-run offline in a couple of minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Job two: the same YAML ranks results
&lt;/h2&gt;

&lt;p&gt;Here's the surprise. Ask a graph "which Sci-Fi films did Nolan direct" and you normally get a flat yes/no list. No ranking, and a wrong match can sneak in. I wanted a continuous, explainable score, and it turned out the ontology already had what I needed.&lt;/p&gt;

&lt;p&gt;Model the query as a &lt;strong&gt;DC circuit&lt;/strong&gt;. Clip a battery's + terminal to "Nolan," the - terminal to "Sci-Fi," and let current flow. Relationships are wires; the ontology weights are how well each wire conducts. A movie's relevance is the current through it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;open_kgo.feature_groups.kg.ontology.semantic_field&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SemanticField&lt;/span&gt;

&lt;span class="n"&gt;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SemanticField&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compute_and&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;movie&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                     &lt;span class="c1"&gt;# (source_id, relation, target_id) triples
&lt;/span&gt;    &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Nolan&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;     &lt;span class="c1"&gt;# + terminal
&lt;/span&gt;    &lt;span class="n"&gt;sink&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Sci-Fi&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;      &lt;span class="c1"&gt;# ground
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# {"Interstellar": 0.394, "Inception": 0.312, "The Dark Knight": 0.0, ...}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On MetaQA (43k nodes):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Film&lt;/th&gt;
&lt;th&gt;Score&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Interstellar&lt;/td&gt;
&lt;td&gt;0.394&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inception&lt;/td&gt;
&lt;td&gt;0.312&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The Dark Knight&lt;/td&gt;
&lt;td&gt;0.0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Dark Knight scoring a clean &lt;strong&gt;0.0&lt;/strong&gt; is my favorite part, and nobody wrote a filter for it. It's an Action film, so from "Nolan" it's a dead end: current reaches it via &lt;code&gt;directed_by&lt;/code&gt;, but there's no onward path to "Sci-Fi." With nowhere to flow, that branch floats to the source voltage, the potential difference across it is zero, so the current is zero. The "Nolan AND Sci-Fi" is enforced by the &lt;em&gt;wiring&lt;/em&gt;, not by a line of code. Interstellar connects to both terminals, sits at V=0.5625, and carries &lt;code&gt;0.9 * (1.0 - 0.5625) = 0.394&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Bonus: partial matches survive. A film that's only loosely Sci-Fi carries a little current and gets a small score, instead of being guillotined by a hard cutoff. You get a ranking, not a yes/no.&lt;/p&gt;

&lt;h3&gt;
  
  
  Being honest about the math
&lt;/h3&gt;

&lt;p&gt;The graph crowd will ask, so: &lt;strong&gt;this math is not new.&lt;/strong&gt; Fixing two anchor voltages and solving for the rest is the harmonic / Dirichlet problem on a graph (Doyle &amp;amp; Snell's &lt;em&gt;Random Walks and Electric Networks&lt;/em&gt;; it's also label propagation, Zhu, Ghahramani &amp;amp; Lafferty 2003). Scoring a node by the current it carries between two terminals is the single-query case of current-flow betweenness (Newman; Brandes &amp;amp; Fleischer). None of that is my invention. What's new here is the &lt;em&gt;packaging&lt;/em&gt;: ontology weights as&lt;br&gt;
conductances, a query as a source/sink pair, and a deterministic, training-free score that drops dead ends for free. The classical foundations are exactly why it behaves predictably.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why one declaration does both
&lt;/h2&gt;

&lt;p&gt;I didn't plan the symmetry. The linter needs to know what each relationship &lt;em&gt;connects&lt;/em&gt; (domain/range). The circuit needs to know how strongly each relationship &lt;em&gt;conducts&lt;/em&gt; (the weight). Both live on the same relationship in the same YAML. Once the ontology is a runtime object instead of documentation, "is this edge legal?" and "how much does this edge carry?" are just two questions you ask the same file. And because it's one source of truth, validity and relevance can't drift apart.&lt;/p&gt;

&lt;p&gt;That's the pitch. An ontology-as-PDF answers neither at runtime. An ontology-as-code answers both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Everything above runs offline against committed fixtures, no Docker, no API keys:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Linter demo: &lt;a href="https://github.com/mloda-ai/open-kgo/blob/main/demo/demo_kg_ontology.py" rel="noopener noreferrer"&gt;&lt;code&gt;demo/demo_kg_ontology.py&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Circuit demo: &lt;a href="https://github.com/mloda-ai/open-kgo/blob/main/demo/demo_semantic_field.py" rel="noopener noreferrer"&gt;&lt;code&gt;demo/demo_semantic_field.py&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://github.com/mloda-ai/open-kgo" rel="noopener noreferrer"&gt;open-kgo&lt;/a&gt; puts one API over nine families of knowledge-graph backend (RDF/SPARQL, property graphs, in-memory, agent memory, and more), so the ontology travels with the connector and switching backend is a config change. It's Apache-2.0 and early, and I'd genuinely like to be told where the model breaks.&lt;/p&gt;

&lt;p&gt;If your ontology is a PDF today, this is what it looks like as code.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>data</category>
      <category>database</category>
      <category>dataengineering</category>
    </item>
    <item>
      <title>Why I debug my RAG pipeline stage by stage, not end to end</title>
      <dc:creator>Kaltofen</dc:creator>
      <pubDate>Thu, 09 Apr 2026 13:24:29 +0000</pubDate>
      <link>https://dev.to/coldoven/why-i-debug-my-rag-pipeline-stage-by-stage-not-end-to-end-1faf</link>
      <guid>https://dev.to/coldoven/why-i-debug-my-rag-pipeline-stage-by-stage-not-end-to-end-1faf</guid>
      <description>&lt;h2&gt;
  
  
  The problem with end-to-end RAG eval
&lt;/h2&gt;

&lt;p&gt;I had a working document retrieval pipeline. Fixed-size chunking, TF-IDF embeddings, FAISS index. Recall@10 was 0.82 on SciFact. Good enough.&lt;/p&gt;

&lt;p&gt;Then I made one change: I swapped fixed-size chunking for sentence-based chunking. Recall dropped to 0.68.&lt;/p&gt;

&lt;p&gt;My first instinct was to roll back. But I wanted to understand &lt;em&gt;why&lt;/em&gt;. End-to-end eval only told me "retrieval is worse." It couldn't tell me which stage was responsible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The debugging approach
&lt;/h2&gt;

&lt;p&gt;I restructured the pipeline so each stage can be evaluated independently. The pipeline is expressed as a string feature chain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mloda.user&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;mlodaAPI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PluginCollector&lt;/span&gt;

&lt;span class="c1"&gt;# The full pipeline: each __ is a stage boundary
&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mlodaAPI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;features&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;docs__pii_redacted__chunked__deduped__embedded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stop at chunking? &lt;code&gt;"docs__pii_redacted__chunked"&lt;/code&gt;. &lt;br&gt;
Skip dedup? &lt;code&gt;"docs__pii_redacted__chunked__embedded"&lt;/code&gt;. &lt;br&gt;
Add evaluation? &lt;code&gt;"docs__pii_redacted__chunked__deduped__embedded__evaluation"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Each stage is a self-contained plugin. Here's what debugging looked like:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1:&lt;/strong&gt; Inspect chunking output directly. Sentence chunks averaged 45 tokens vs. 512 for fixed-size. Looked reasonable. Not the problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2:&lt;/strong&gt; Check dedup. Shorter chunks meant more near-duplicates. Exact hash dedup only catches identical chunks, so near-duplicates passed through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3:&lt;/strong&gt; Swap dedup method.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;rag_integration.feature_groups.rag_pipeline&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;NgramDeduplicator&lt;/span&gt;

&lt;span class="n"&gt;providers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;...,&lt;/span&gt;
    &lt;span class="n"&gt;NgramDeduplicator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# was ExactHashDeduplicator
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mlodaAPI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;features&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;docs__pii_redacted__chunked__deduped__embedded__evaluation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;compute_frameworks&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;PythonDictFramework&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;plugin_collector&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;PluginCollector&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enabled_feature_groups&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;providers&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# Recall went back to 0.81
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The root cause was never the chunker. The chunker's output exposed a weakness in the downstream dedup stage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;RAG pipelines are a chain of dependent stages. Changing one stage can break a different stage for reasons that are invisible in end-to-end metrics.&lt;/p&gt;

&lt;p&gt;Stage-by-stage eval turns debugging from "something is wrong somewhere" into "this specific stage degrades here."&lt;/p&gt;

&lt;h2&gt;
  
  
  What the pipeline supports
&lt;/h2&gt;

&lt;p&gt;The pipeline makes every stage swappable:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Options&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PII redaction&lt;/td&gt;
&lt;td&gt;regex, presidio, custom patterns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chunking&lt;/td&gt;
&lt;td&gt;fixed-size, sentence, paragraph, semantic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deduplication&lt;/td&gt;
&lt;td&gt;exact hash, normalized, n-gram&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Embedding&lt;/td&gt;
&lt;td&gt;TF-IDF, sentence-transformers, hash&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vector index&lt;/td&gt;
&lt;td&gt;FAISS flat, IVF, HNSW&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Built-in eval metrics: Recall@K, Precision, NDCG, MAP against BEIR benchmarks.&lt;/p&gt;

&lt;p&gt;There's also an image pipeline with the same structure: PII redaction (blur/pixelate/fill), perceptual hash dedup, and CLIP embeddings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Not everything presented here is working yet, but most of it is. We are figuring out if this is interesting or rather not worth reading/talking about.&lt;/p&gt;

&lt;p&gt;Open source under Apache 2.0: &lt;a href="https://github.com/mloda-ai/rag_integration" rel="noopener noreferrer"&gt;https://github.com/mloda-ai/rag_integration&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you've hit the "swap one component, break something else" problem in your own pipelines, I'd be curious to hear how you approached debugging it.&lt;/p&gt;

</description>
      <category>nlp</category>
      <category>python</category>
      <category>rag</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
