<?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: Tae Kim</title>
    <description>The latest articles on DEV Community by Tae Kim (@hannune).</description>
    <link>https://dev.to/hannune</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%2F3678969%2F0d047df5-2d0b-45b9-bbde-b4dbad88e550.png</url>
      <title>DEV Community: Tae Kim</title>
      <link>https://dev.to/hannune</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hannune"/>
    <language>en</language>
    <item>
      <title>The decisions in my entity resolution pipeline that I still make myself</title>
      <dc:creator>Tae Kim</dc:creator>
      <pubDate>Thu, 01 Oct 2026 02:49:03 +0000</pubDate>
      <link>https://dev.to/hannune/the-decisions-in-my-entity-resolution-pipeline-that-i-still-make-myself-2cl3</link>
      <guid>https://dev.to/hannune/the-decisions-in-my-entity-resolution-pipeline-that-i-still-make-myself-2cl3</guid>
      <description>&lt;p&gt;A few months ago I spent a couple of days debugging blocking mismatches that I couldn't explain. I'd added a Japanese securities data source and a bunch of subsidiary nodes weren't matching their Korean counterparts. Asked for help checking the blocking key function. Got back a version that looked right. Ran it. Still had mismatches. Tried again.&lt;/p&gt;

&lt;p&gt;Eventually I just pulled up the raw input data and looked at it. The source had a lot of entries formatted as &lt;code&gt;合同会社 XYZ&lt;/code&gt;. That's a godo kaisha — basically a Japanese LLC. My blocking key function stripped &lt;code&gt;株式会社&lt;/code&gt; and a few other common forms, but I'd never added &lt;code&gt;合同会社&lt;/code&gt;. So those records were producing blocking keys that didn't match their Korean counterparts, and those pairs never entered the candidate pool.&lt;/p&gt;

&lt;p&gt;The fix was one regex line. I'd lost two days on it.&lt;/p&gt;

&lt;p&gt;What I kept thinking about after was: the AI couldn't have caught this. It was generating correct general blocking functions. But whether &lt;code&gt;合同会社&lt;/code&gt; needed to be in the strip list depended entirely on what was in my specific dataset. You can't answer that from the code. You have to look at the data.&lt;/p&gt;

&lt;p&gt;Since then I've started paying attention to when a question I'm asking is really a "what's in my data" question in disguise. Confidence threshold questions often are. I'll ask what threshold I should use and get an answer that's been reasoned out from general principles. It'll sound right. But the right threshold for me depends on what the Korean-English pairs in my training set actually look like, and what it costs me when the resolver merges two nodes that shouldn't be merged versus when it misses a merge it should have made. That's not in any prompt I can write.&lt;/p&gt;

&lt;p&gt;I haven't fully figured out how to screen for these. Sometimes I catch it before I go down a path, sometimes I don't until I've already spent time on the wrong thing. The &lt;code&gt;合同会社&lt;/code&gt; case was the one where I lost the most time to it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://er-api.hannune.ai" rel="noopener noreferrer"&gt;er-api&lt;/a&gt;, a multilingual entity resolution service for Korean, Japanese, Chinese, and English corporate data. More at &lt;a href="https://hannune.ai" rel="noopener noreferrer"&gt;hannune.ai&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>entityresolution</category>
      <category>dataengineering</category>
      <category>python</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Graph RAG: where it actually breaks</title>
      <dc:creator>Tae Kim</dc:creator>
      <pubDate>Wed, 30 Sep 2026 02:37:58 +0000</pubDate>
      <link>https://dev.to/hannune/graph-rag-where-it-actually-breaks-59ok</link>
      <guid>https://dev.to/hannune/graph-rag-where-it-actually-breaks-59ok</guid>
      <description>&lt;p&gt;Neo4j with a working schema: two days. Cypher traversal for the relationships I needed: another day or two, once I knew what I was querying. The graph structure, once committed, stayed mostly stable.&lt;/p&gt;

&lt;p&gt;That's not where things kept breaking.&lt;/p&gt;

&lt;h2&gt;
  
  
  The routing problem
&lt;/h2&gt;

&lt;p&gt;When someone asks "what companies are exposed to semiconductor export restrictions?" the system needs to figure out whether to do graph traversal, vector search, or some combination. Getting this wrong doesn't produce an obvious error. It returns something that cites real sources and sounds confident. It just misses context that was sitting in the graph the whole time.&lt;/p&gt;

&lt;p&gt;I had one case that stayed with me. A query about a Korean chipmaker's supplier network kept returning news results. The answer was plausible — recent articles about the company, relevant details. It took longer than I'd like to admit to notice that the supplier relationships were already in the graph. The system just wasn't routing that query there. Once I found the gap, the fix was two lines of code. Finding it was the work.&lt;/p&gt;

&lt;p&gt;The routing logic I ended up with uses the query's dependency structure as a signal:&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;route_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;graph_searcher&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vector_searcher&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;relational_signals&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;supplier&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;subsidiary&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;controlled by&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;exposed to&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;connected to&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;supply chain&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="n"&gt;needs_graph&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&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;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;relational_signals&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;needs_graph&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;entity_ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;graph_searcher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_related&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;vector_searcher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search_scoped&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entity_ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;vector_searcher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The signal list is hand-tuned and the scoping logic has edge cases. But it reduced the miss rate more than anything else I tried.&lt;/p&gt;

&lt;h2&gt;
  
  
  Entity quality doesn't stay clean
&lt;/h2&gt;

&lt;p&gt;The second place things break is entity quality, and this one is more insidious because it's gradual.&lt;/p&gt;

&lt;p&gt;The initial entity resolution pass establishes a set of merged entities. After that, the source data keeps changing. A company I had cleanly resolved was acquired six months into the project. Post-acquisition news started using a new parent company name alongside the old subsidiary name. The graph started accumulating both as separate nodes for the same underlying entity.&lt;/p&gt;

&lt;p&gt;Graph traversal from the old node gave one picture; traversal from the new node gave a different one. Neither looked wrong in isolation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;e:&lt;/span&gt;&lt;span class="n"&gt;Entity&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;name:&lt;/span&gt; &lt;span class="s2"&gt;"Old Name"&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:SUPPLIES&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;s:&lt;/span&gt;&lt;span class="n"&gt;Entity&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;s.name&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s.country&lt;/span&gt;

&lt;span class="o"&gt;--&lt;/span&gt; &lt;span class="n"&gt;If&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;same&lt;/span&gt; &lt;span class="n"&gt;entity&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="n"&gt;also&lt;/span&gt; &lt;span class="ow"&gt;exists&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="s2"&gt;"New Name"&lt;/span&gt; &lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;acquisition&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt;
&lt;span class="o"&gt;--&lt;/span&gt; &lt;span class="n"&gt;those&lt;/span&gt; &lt;span class="n"&gt;relationships&lt;/span&gt; &lt;span class="n"&gt;are&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;different&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="n"&gt;entirely&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I treat entity quality as something that degrades continuously now, not a state you establish once. Running a periodic audit of merged pairs helps — checking whether the alias-to-entity mapping still makes sense for recently active entities.&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;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;stale_merges&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;days_threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;cutoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;today&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;days_threshold&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;stale&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;entity_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;meta&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;entities&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;last_verified&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;meta&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;last_verified&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;last_verified&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;verified_date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromisoformat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;last_verified&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;verified_date&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;cutoff&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;meta&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;alias_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;stale&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;entity_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;stale&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The honest version: figuring out which merged pairs have actually gone stale is still mostly manual review. The detection is automatable. What to do with a flagged pair isn't something the code decides.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I build &lt;a href="https://er-api.hannune.ai" rel="noopener noreferrer"&gt;er-api&lt;/a&gt;, a multilingual entity resolution service for Korean, Japanese, Chinese, and English corporate data. More at &lt;a href="https://hannune.ai" rel="noopener noreferrer"&gt;hannune.ai&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>graphrag</category>
      <category>neo4j</category>
      <category>dataengineering</category>
      <category>entityresolution</category>
    </item>
    <item>
      <title>Why I can't run Splink in an API call</title>
      <dc:creator>Tae Kim</dc:creator>
      <pubDate>Mon, 28 Sep 2026 03:15:15 +0000</pubDate>
      <link>https://dev.to/hannune/why-i-cant-run-splink-in-an-api-call-4gm8</link>
      <guid>https://dev.to/hannune/why-i-cant-run-splink-in-an-api-call-4gm8</guid>
      <description>&lt;p&gt;I got confused about this for longer than I expected.&lt;/p&gt;

&lt;p&gt;The 2asy.ai news pipeline uses Splink. Company mentions come in with each article batch, Splink runs pairwise comparison against everything already in the registry, clusters form, the registry updates. Usually takes about 90 seconds. Sometimes longer. Nobody is waiting for it.&lt;/p&gt;

&lt;p&gt;When I started building the ER API, I thought the API would just be a lighter version of that. Call Splink, get a match, return it. The response latency problem hit me within the first day of prototyping. Splink's actual comparison work involves blocking across the full corpus, scoring candidate pairs, then an EM step to calibrate probabilities. On a corpus with a few hundred thousand records, you're looking at seconds of compute, not milliseconds. You can't put that in the path of an HTTP request that someone's polling from their application.&lt;/p&gt;

&lt;p&gt;So the API compares an incoming name against a pre-computed candidate index and returns the top result above a threshold. That's fast enough. What I didn't think through carefully at the start was what you lose by doing it that way. Splink's EM step sees the full distribution of records together. Some pairs that look ambiguous in isolation are only resolvable in context because the EM algorithm essentially gets to vote across the whole population. The API doesn't have that. Each query is independent.&lt;/p&gt;

&lt;p&gt;I worked around the latency problem reasonably well. The threshold question has been messier. When a match comes back at 0.71, what should the caller do with that? In the news pipeline I can defer uncertain pairs to a review queue and come back to them. In the API you have to give the caller something. My first attempt was to just return the score and let callers use their own threshold. What I didn't anticipate was that most callers treat any result above their threshold as a hard match, regardless of how close to the threshold it is. The docs now include threshold guidance for different use cases, but I'm still getting questions about this, so I probably haven't described it clearly enough yet.&lt;/p&gt;

&lt;p&gt;Model retraining is the one I thought about least and arguably got most wrong. I collect labeled pairs continuously and retrain the Splink model for the pipeline every few weeks. Doing the same for the API turned out to be a problem. Even a small retrain shifts match probabilities slightly. If the API returns 0.84 for a query this week and 0.79 for the same query after a retrain, anyone who built automation against the API and didn't read the changelog will notice. I now pin the API to a specific model version and do explicit releases instead. This is more conservative than I'd like, but I don't have a cleaner answer.&lt;/p&gt;

&lt;p&gt;The 2asy.ai pipeline still uses full Splink batch resolution. The API handles point queries where a caller needs an answer immediately and doesn't have the corpus context to run it themselves. I knew from the start that these were different use cases. What I underestimated was how different the constraints would be.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://er-api.hannune.ai" rel="noopener noreferrer"&gt;er-api&lt;/a&gt;, a multilingual entity resolution service for Korean, Japanese, Chinese, and English corporate data. More at &lt;a href="https://hannune.ai" rel="noopener noreferrer"&gt;hannune.ai&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>entityresolution</category>
      <category>dataengineering</category>
      <category>api</category>
      <category>python</category>
    </item>
    <item>
      <title>The normalization step that breaks before your ER model does</title>
      <dc:creator>Tae Kim</dc:creator>
      <pubDate>Fri, 25 Sep 2026 03:10:47 +0000</pubDate>
      <link>https://dev.to/hannune/the-normalization-step-that-breaks-before-your-er-model-does-38ia</link>
      <guid>https://dev.to/hannune/the-normalization-step-that-breaks-before-your-er-model-does-38ia</guid>
      <description>&lt;p&gt;I spent three weeks tuning near-threshold match scores before realizing the pairs I was most worried about weren't getting bad scores. They weren't generating candidate pairs at all.&lt;/p&gt;

&lt;p&gt;The thing I thought would fix it didn't. My first guess was blocking key design. I'd read enough about the invisibility of blocking errors that I figured the issue had to be there. I checked the blocking key function, checked the bucket sizes, didn't see anything obviously wrong. The pairs I was looking for were just absent.&lt;/p&gt;

&lt;p&gt;Then I added a simple audit: take 30 pairs that should match, print the blocking keys side by side. First run was Japanese companies from a securities exchange I'd added a few weeks before. Out of 30 pairs, 11 weren't generating matching keys.&lt;/p&gt;

&lt;p&gt;The exchange uses full-width ASCII characters in company names. &lt;code&gt;ＡＢＣ株式会社&lt;/code&gt; uses full-width Latin letters (U+FF21 and so on), not regular ASCII. I hadn't known this was a thing. &lt;code&gt;Ａ&lt;/code&gt; and &lt;code&gt;A&lt;/code&gt; are different code points, so the normalized name hashes to a different key.&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;import&lt;/span&gt; &lt;span class="n"&gt;unicodedata&lt;/span&gt;
&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;unicodedata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NFKC&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;NFKC handles this. I ran the audit again, got 30 of 30. Then I ran the same audit on Korean data from a corporate registry and found a different problem.&lt;/p&gt;

&lt;p&gt;Korean company names have the entity type in multiple forms: &lt;code&gt;주식회사 ABC&lt;/code&gt;, &lt;code&gt;ABC(주)&lt;/code&gt;, sometimes just &lt;code&gt;ABC㈜&lt;/code&gt; where &lt;code&gt;㈜&lt;/code&gt; is a single precomposed Unicode character at U+3378 in the CJK Compatibility block. My stripping code handled &lt;code&gt;주식회사&lt;/code&gt; and &lt;code&gt;(주)&lt;/code&gt;. It didn't handle &lt;code&gt;㈜&lt;/code&gt; because that's not a sequence of three characters; it's one codepoint that looks like a parenthetical when rendered.&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="n"&gt;KOREAN_LEGAL_FORMS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sa"&gt;r&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="sa"&gt;r&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="sa"&gt;r&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="sa"&gt;r&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="c1"&gt;# U+3378, precomposed, won't match the spelled-out pattern
&lt;/span&gt;    &lt;span class="sa"&gt;r&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="sa"&gt;r&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="c1"&gt;# U+3354
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Japanese has &lt;code&gt;㈱&lt;/code&gt; (U+3231) and &lt;code&gt;㈲&lt;/code&gt; (U+3232) the same way. I found those in the same audit session.&lt;/p&gt;

&lt;p&gt;The ordering thing tripped me up a bit. My thought was to run NFKC first and that would expand the precomposed symbols so the stripping patterns would match them. It doesn't work that way. NFKC doesn't decompose those particular codepoints; they pass through intact. So the precomposed forms have to be in the stripping list explicitly. Running NFKC before stripping is still right because it handles the full-width ASCII, but it doesn't help with the precomposed legal symbols.&lt;/p&gt;

&lt;p&gt;There's still a category I don't have a clean answer for. Some Japanese company names use older traditional character forms that predate postwar simplification. &lt;code&gt;國&lt;/code&gt; and &lt;code&gt;国&lt;/code&gt; refer to the same character. NFKC handles some variant pairs but not all. I've been adding them manually to a lookup table when they turn up in audits. That's obviously not comprehensive. At some point I should probably run a dedicated audit against a list of the known variant pairs, but I haven't done it, partly because I'm not sure how to get that list and partly because the other normalization issues got fixed first and this one fell off the queue.&lt;/p&gt;

&lt;p&gt;The reason these are hard to catch is that they don't show up in any metric unless you build the blocking recall audit yourself.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://er-api.hannune.ai" rel="noopener noreferrer"&gt;er-api&lt;/a&gt;, a multilingual entity resolution service for Korean, Japanese, Chinese, and English corporate data. More at &lt;a href="https://hannune.ai" rel="noopener noreferrer"&gt;hannune.ai&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>entityresolution</category>
      <category>dataengineering</category>
      <category>python</category>
      <category>nlp</category>
    </item>
    <item>
      <title>Where I got labeled training data for Korean-Japanese-Chinese entity resolution</title>
      <dc:creator>Tae Kim</dc:creator>
      <pubDate>Wed, 23 Sep 2026 03:11:35 +0000</pubDate>
      <link>https://dev.to/hannune/where-i-got-labeled-training-data-for-korean-japanese-chinese-entity-resolution-1j5d</link>
      <guid>https://dev.to/hannune/where-i-got-labeled-training-data-for-korean-japanese-chinese-entity-resolution-1j5d</guid>
      <description>&lt;p&gt;Around pair 400 of the manual annotation batch, I got to a Korean holding company and its Japanese parent. The Japanese database had the company listed as a joint venture, the Korean filing described it as a wholly-owned subsidiary. Same entity, but the ownership description was genuinely different between sources. I marked it as a match since the names clearly referred to the same company, but I wrote a note that I still don't know what to do with.&lt;/p&gt;

&lt;p&gt;That was about a month in. I ended up reviewing around 2,000 pairs by hand before I had enough coverage to feel reasonably confident in the KR-JP and KR-ZH models.&lt;/p&gt;

&lt;p&gt;The reason it got to manual annotation: I couldn't find a public labeled dataset for Korean-Japanese-Chinese cross-lingual corporate name matching. For English-only ER, there are open sources. For this specific domain, I didn't find anything usable.&lt;/p&gt;

&lt;p&gt;The Korean-English piece came together faster than I expected. Korean financial disclosure filings (DART) require companies to report both the Korean legal name and the English name. That turned out to be a clean source of verified KR-EN pairs. The company certified both names in a regulatory document, so I didn't have to guess whether they referred to the same entity.&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch_dart_name_pairs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;company_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://opendart.fss.or.kr/api/company.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;params&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;crtfc_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;DART_API_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;corp_code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;company_code&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&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="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;params&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="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ko&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&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;corp_name&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;en&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&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;corp_name_eng&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scraping recent DART filings got me a few thousand KR-EN positive pairs. Combined with negatives generated by sampling companies from different industry sectors, the KR-EN Splink model got decent coverage early.&lt;/p&gt;

&lt;p&gt;The KR-JP and KR-ZH pairs didn't have an equivalent source. I went looking for companies with cross-listed securities that would appear in databases across multiple languages. Found maybe 800-900 verified triplets that way. Not many, but enough to get an initial model to a usable state for the core coverage.&lt;/p&gt;

&lt;p&gt;The 2,000 manual annotations were the gap-filler. Mostly companies in the near-threshold band of early model runs, which sounds principled but in practice meant sitting with a lot of cases where I wasn't sure. Japanese holding company naming conventions have edge cases I'd never thought about before. A company could be listed under a regional holding name in Japanese filings but appear under the global brand in Korean corporate registries. Not wrong, just a different layer of the corporate structure.&lt;/p&gt;

&lt;p&gt;The KR-EN model is the one I trust most. The KR-JP and KR-ZH models have held up for the core coverage but I've seen them struggle on cases that weren't well represented in the training set. I add pairs when I find errors in production, but it's slow.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://er-api.hannune.ai" rel="noopener noreferrer"&gt;er-api&lt;/a&gt;, a multilingual entity resolution service for Korean, Japanese, Chinese, and English corporate data. More at &lt;a href="https://hannune.ai" rel="noopener noreferrer"&gt;hannune.ai&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>entityresolution</category>
      <category>dataengineering</category>
      <category>python</category>
      <category>nlp</category>
    </item>
    <item>
      <title>What Splink actually runs on DuckDB when it scores entity pairs</title>
      <dc:creator>Tae Kim</dc:creator>
      <pubDate>Tue, 22 Sep 2026 02:36:36 +0000</pubDate>
      <link>https://dev.to/hannune/what-splink-actually-runs-on-duckdb-when-it-scores-entity-pairs-1e3o</link>
      <guid>https://dev.to/hannune/what-splink-actually-runs-on-duckdb-when-it-scores-entity-pairs-1e3o</guid>
      <description>&lt;p&gt;When I added Splink to the ER API, the registry was at 44,798 entities. Before touching any configuration knobs, I looked at the SQL it was generating. I expected opaque query strings. What came out was readable enough that I could follow the logic directly.&lt;/p&gt;

&lt;p&gt;The blocking step generates something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity_id&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;entity_id_l&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity_id&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;entity_id_r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name_normalized&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;name_l&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name_normalized&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;name_r&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;entities&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;
&lt;span class="k"&gt;INNER&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;entities&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;LEFT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name_normalized&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;LEFT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name_normalized&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity_id&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity_id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;LEFT(name_normalized, 4)&lt;/code&gt; is the blocking key. Entities that don't share those first four characters don't get compared at all. At 44,798 entities this brought candidate pairs from roughly 2 billion down to about 400,000.&lt;/p&gt;

&lt;p&gt;The cross-join on the full entity table was what was slow. I caught this by looking at the DuckDB query plan while the blocking step ran — it was sitting there doing a lot more work than I wanted. Adding an index on the blocking key column fixed it immediately. Not complicated once I knew where to look, but I wouldn't have looked there without reading what was actually running.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the EM pass is doing
&lt;/h2&gt;

&lt;p&gt;For each candidate pair, Splink builds comparison features before the EM loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;entity_id_l&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entity_id_r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;name_l&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;name_r&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;exact_match&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;jaro_winkler_similarity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name_l&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name_r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;jaro_winkler_sim&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;country_l&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;country_r&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;same_country&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;candidate_pairs&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The EM loop ran fast once I had the candidate table. DuckDB handles this in-process, so there's no serialization overhead between iterations. On my machine, 400,000 pairs, the whole convergence took a few seconds. That part of the pipeline wasn't a problem.&lt;/p&gt;

&lt;p&gt;What I spent a disproportionate amount of time on was preprocessing. Name normalization for Korean, Japanese, and Chinese inputs has a lot of edge cases, and the blocking key construction amplifies whatever noise gets through. Japanese company name suffixes alone took an afternoon to handle consistently — 株式会社 shows up at the front in some sources and the back in others, abbreviated in a few more. Once I had a normalization that wasn't silently introducing variance into the blocking key, I'd spent more hours there than on everything inside Splink's own configuration combined.&lt;/p&gt;

&lt;p&gt;None of that is a Splink problem. The library doesn't know what language your inputs are in. But if you're running entity resolution over multilingual corporate names, the normalization you build before Splink touches the data will probably be where you spend most of your time. The EM loop will be fine.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://er-api.hannune.ai" rel="noopener noreferrer"&gt;er-api&lt;/a&gt;, a multilingual entity resolution service for Korean, Japanese, Chinese, and English corporate data. More at &lt;a href="https://hannune.ai" rel="noopener noreferrer"&gt;hannune.ai&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>entityresolution</category>
      <category>duckdb</category>
      <category>dataengineering</category>
      <category>python</category>
    </item>
    <item>
      <title>Your entity resolution pipeline works on database records. Then you point it at news text.</title>
      <dc:creator>Tae Kim</dc:creator>
      <pubDate>Thu, 17 Sep 2026 03:06:13 +0000</pubDate>
      <link>https://dev.to/hannune/your-entity-resolution-pipeline-works-on-database-records-then-you-point-it-at-news-text-51cd</link>
      <guid>https://dev.to/hannune/your-entity-resolution-pipeline-works-on-database-records-then-you-point-it-at-news-text-51cd</guid>
      <description>&lt;p&gt;I built the entity resolution pipeline at 2asy.ai against structured registry data. I benchmarked it against structured registry data. The match scores looked good. I moved on.&lt;/p&gt;

&lt;p&gt;About a month later I started ingesting news articles. The first batch turned into two weeks of cleanup.&lt;/p&gt;

&lt;p&gt;What I hadn't tested was what happens when the source text doesn't use formal company names. News doesn't. A Korean financial article calls "삼성전자 주식회사" just "삼성." A piece in English calls it "Samsung" without specifying which Samsung entity, because to a human reading with context, it's obvious which one. The pipeline had no reading context. It had a blocking key built on the first four characters of a normalized name.&lt;/p&gt;

&lt;p&gt;"삼성" hits the same blocking bucket as Samsung Electronics, Samsung SDI, Samsung C&amp;amp;T, and a dozen other entities under the group umbrella. The EM scoring then gets candidate pairs with name similarity 1.0 across all of them and nothing else to work with.&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="n"&gt;ON&lt;/span&gt; &lt;span class="nc"&gt;LEFT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name_normalized&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LEFT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name_normalized&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was the blocking rule. It was designed for formal names where "Samsung Electronics" and "삼성전자" both normalize to something starting with "sams." It's also what causes a short news mention to block with every Samsung subsidiary in the registry.&lt;/p&gt;

&lt;p&gt;What I added was a post-scoring filter using signals from the article itself. The article a mention comes from has context: what other entities appear nearby, what sector terms show up in the paragraph. When the top match by name similarity has no overlap with those signals, I look at the second-best match.&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;extract_context_signals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;article&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mention_span&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;window&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;article&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mention_span&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&lt;span class="n"&gt;mention_span&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mentioned_with&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;extract_entity_mentions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sector_keywords&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;extract_sector_terms&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;article_category&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;article&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;category&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This helped with the Samsung group disambiguation specifically. It doesn't do much for companies that don't have siblings with similar names. And when the article context is thin — short breaking news items especially — the signals aren't reliable enough to use.&lt;/p&gt;

&lt;p&gt;I also raised the confidence floor for what I'm calling informal mentions: short Korean-character forms, English names that don't include the entity's legal suffix. The threshold for a two-character Korean name merging to a registry entry is higher than for a full formal name match.&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="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;mention_is_short_informal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mention_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_language&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;threshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.90&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 0.90 came from watching the false-merge rate and adjusting until it dropped to something workable. It's not principled.&lt;/p&gt;

&lt;p&gt;The benchmark I ran when building this is still the benchmark I'm running. It's all registry-vs-registry pairs. I don't have a labeled test set for news mentions, so I don't actually know what the precision-recall curve looks like for the domain I'm now running against daily. That's the part I'm not comfortable with.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://er-api.hannune.ai" rel="noopener noreferrer"&gt;er-api&lt;/a&gt;, a multilingual entity resolution service for Korean, Japanese, Chinese, and English corporate data, used in &lt;a href="https://2asy.ai" rel="noopener noreferrer"&gt;2asy.ai&lt;/a&gt; for news-to-knowledge-graph ingestion. More at &lt;a href="https://hannune.ai" rel="noopener noreferrer"&gt;hannune.ai&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>entityresolution</category>
      <category>dataengineering</category>
      <category>nlp</category>
      <category>python</category>
    </item>
    <item>
      <title>When I killed the Korean version: what channel mismatch actually looks like</title>
      <dc:creator>Tae Kim</dc:creator>
      <pubDate>Tue, 15 Sep 2026 03:44:39 +0000</pubDate>
      <link>https://dev.to/hannune/when-i-killed-the-korean-version-what-channel-mismatch-actually-looks-like-ll2</link>
      <guid>https://dev.to/hannune/when-i-killed-the-korean-version-what-channel-mismatch-actually-looks-like-ll2</guid>
      <description>&lt;p&gt;About three months into building the Korean version, I showed the product to a financial analyst at a Korean institutional investor. He scrolled through the interface for maybe thirty seconds, then said something like: "we already have Bloomberg for data, what I need is something I can hand off."&lt;/p&gt;

&lt;p&gt;The "hand off" part is what stuck. He needed a PDF or an Excel file, something he could email to a manager. The graph query interface I was showing him was not the answer.&lt;/p&gt;

&lt;p&gt;I kept working on the project for a few more weeks anyway. The pipeline was running. I told myself I was still gathering information.&lt;/p&gt;

&lt;p&gt;Some background: 2asy.ai tracks tariff policy changes and how they propagate to specific companies. The basic idea is that you can crawl policy and trade news, figure out which companies sit in the affected supply chains, and make that queryable. The entity resolution work is the hard part of it, which is mostly what the ER API does. When I decided to do a Korean-language version, it looked tractable because I'd done most of that infrastructure already. Mainly crawl pipeline work and some frontend localization to do on top.&lt;/p&gt;

&lt;p&gt;By the time I showed the analyst that demo, I'd had a few other conversations in the same vein. Different people, similar answer. Korean institutional finance and compliance people I was talking to wanted a deliverable they could hand to someone else, not a tool they'd query themselves. The B2B buyers I'd built the international version for had already decided to integrate vendor risk data programmatically. Most of the Korean buyers I was reaching hadn't made that decision, or weren't the decision-makers who would.&lt;/p&gt;

&lt;p&gt;I paused it. I kept the crawl pipeline and the entity resolution work because those are directly reusable if a reporting-oriented distribution path shows up. But I stopped maintaining it as an active product and turned off the scheduled jobs.&lt;/p&gt;

&lt;p&gt;Looking back, I'm still not entirely sure when I made the decision versus when I just stopped maintaining it. Somewhere around week twelve those converged.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://er-api.hannune.ai" rel="noopener noreferrer"&gt;er-api&lt;/a&gt;, a multilingual entity resolution service, and &lt;a href="https://2asy.ai" rel="noopener noreferrer"&gt;2asy.ai&lt;/a&gt;, a trade intelligence graph. More at &lt;a href="https://hannune.ai" rel="noopener noreferrer"&gt;hannune.ai&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>dataengineering</category>
      <category>startup</category>
      <category>programming</category>
    </item>
    <item>
      <title>What the retrieval layer of a Graph RAG system actually looks like</title>
      <dc:creator>Tae Kim</dc:creator>
      <pubDate>Fri, 11 Sep 2026 03:25:08 +0000</pubDate>
      <link>https://dev.to/hannune/what-the-retrieval-layer-of-a-graph-rag-system-actually-looks-like-19fn</link>
      <guid>https://dev.to/hannune/what-the-retrieval-layer-of-a-graph-rag-system-actually-looks-like-19fn</guid>
      <description>&lt;p&gt;The first version retrieved everything. Every node reachable from the seed entity, up to four hops. The context window hit 8,000 tokens on a basic query about a mid-size Korean manufacturer. The model started confusing entities.&lt;/p&gt;

&lt;p&gt;I pulled it back to 50 nodes, which is where it still is. I don't love that number. It's a hard cutoff that causes real problems for large corporate groups — Samsung Electronics has hundreds of subsidiaries and a query about their supply chain exposure hits the limit before returning a complete picture. But 50 is where the model stops confusing things and starts giving useful answers, and I haven't found a smarter expansion strategy that changes that tradeoff.&lt;/p&gt;

&lt;p&gt;The system is a Graph RAG layer over a 50 million-row supply chain knowledge graph. The entity resolution took about two weeks to get right. The retrieval layer itself took three days. Here's what the retrieval actually does.&lt;/p&gt;

&lt;p&gt;Every retrieval starts from seed entities. For a query like "what is Samsung SDI's exposure to US tariffs on Korean steel," the first step is which nodes in the graph correspond to "Samsung SDI" and "US tariffs." I run this through an entity lookup that checks normalized names and aliases:&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;identify_seed_entities&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query_text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;entities&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extract_entities&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query_text&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;entity_mention&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;entities&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;candidates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
            MATCH (e:Entity)
            WHERE e.name_normalized CONTAINS $mention_normalized
               OR any(alias IN e.aliases WHERE alias CONTAINS $mention_normalized)
            RETURN e.id, e.name, e.canonical_type
            ORDER BY e.mention_count DESC
            LIMIT 3
        &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mention_normalized&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entity_mention&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;data&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;candidates&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;candidates&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;e.id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mention_count&lt;/code&gt; ranks entities by how many source documents they appear in. This is a rough importance proxy. For "Samsung SDI" it works fine. For more ambiguous mentions it sometimes picks the wrong candidate, especially for smaller companies that share name fragments. I haven't fixed that properly.&lt;/p&gt;

&lt;p&gt;From the seed nodes, the subgraph expansion follows supply chain relationship types and cuts off at three hops. Four hops started returning nodes that were technically connected but not relevant to any reasonable interpretation of the query. A steel tariff affecting a Korean parts supplier affecting a construction equipment company is three hops and stays useful. The fourth hop typically reached conglomerates that own everything and added noise.&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="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    MATCH path = (seed:Entity {id: $seed_id})-[r:SUPPLIES_TO|SOURCES_FROM|OWNS|CONTROLS*1..3]-(connected)
    WITH connected, length(path) AS hops
    WHERE hops &amp;lt;= 3
    RETURN connected.id AS entity_id, connected.name AS entity_name, hops
    ORDER BY hops ASC, connected.document_count DESC
    LIMIT 50
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seed_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;seed_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After getting the subgraph, I fetch the source document excerpts that were asserted through those entities. These are the actual facts — the sentences from filings, news articles, procurement documents that describe the relationships:&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="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    MATCH (e:Entity)-[r:ASSERTED_IN]-&amp;gt;(d:Document)
    WHERE e.id IN $entity_ids
    RETURN e.name AS entity_name,
           d.excerpt AS fact_text,
           d.source AS source,
           d.published_at AS published_at,
           r.assertion_confidence AS confidence
    ORDER BY r.assertion_confidence DESC, d.published_at DESC
    LIMIT 100
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entity_ids&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;entity_ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hundred-fact fetch gets trimmed to forty before going to the model. The trim is TF-IDF ranking against the query terms, which is coarse but seems to work. The bigger problem is that facts from entities reached through low-confidence ER merges get the same treatment as facts from directly verified sources. I flag anything below 0.80 confidence with &lt;code&gt;[uncertain]&lt;/code&gt; in the context, but that's a manual addition after discovering the model was treating all facts equally regardless of how confident the underlying merge was.&lt;/p&gt;

&lt;p&gt;Packaging the final context is straightforward: facts sorted by confidence, each labeled with source and date. Uncertain ones get the flag. The model prompt says to treat flagged facts as preliminary.&lt;/p&gt;

&lt;p&gt;What takes the most time isn't any of this. It's the cases where the query involves an entity the graph doesn't have at resolution quality. The graph might have "Samsung SDI" resolved, but a smaller battery cell supplier mentioned in a procurement document might exist as a raw string node that never got merged with anything. The retrieval finds it, returns facts, and the model reasons off it as if it were a resolved entity. I currently have no way to flag "this entity wasn't fully resolved" at retrieval time. That's the next thing I need to add.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://er-api.hannune.ai" rel="noopener noreferrer"&gt;er-api&lt;/a&gt;, a multilingual entity resolution service for Korean, Japanese, Chinese, and English corporate data. More at &lt;a href="https://hannune.ai" rel="noopener noreferrer"&gt;hannune.ai&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>graphrag</category>
      <category>neo4j</category>
      <category>knowledgegraph</category>
      <category>dataengineering</category>
    </item>
    <item>
      <title>Building an investing knowledge graph, part 6: if you're merging data from more than one source, you'll hit this</title>
      <dc:creator>Tae Kim</dc:creator>
      <pubDate>Thu, 10 Sep 2026 02:55:22 +0000</pubDate>
      <link>https://dev.to/hannune/building-an-investing-knowledge-graph-part-6-if-youre-merging-data-from-more-than-one-source-2ecn</link>
      <guid>https://dev.to/hannune/building-an-investing-knowledge-graph-part-6-if-youre-merging-data-from-more-than-one-source-2ecn</guid>
      <description>&lt;h1&gt;
  
  
  Building an investing knowledge graph, part 6: if you're merging data from more than one source, you'll hit this
&lt;/h1&gt;

&lt;p&gt;Since part 5 went up, I've had two conversations worth writing about. One was with someone building a financial news aggregation system who wanted to know if the ER setup here would work for their case. The other was with someone doing KYC screening who asked roughly the same question. Those conversations are what prompted this final part.&lt;/p&gt;

&lt;p&gt;The financial news question had an obvious answer: yes, with some caveats about cross-language blocking. The KYC question took me longer, because the error tolerance is so different that I wasn't sure how much of what I built would actually transfer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The KYC conversation
&lt;/h2&gt;

&lt;p&gt;The person I talked to was working on sanctions screening. Their problem was name matching: entity strings from incoming documents against a registry of flagged persons and organizations. The description of the ER problem was familiar enough that I recognized it immediately.&lt;/p&gt;

&lt;p&gt;What I had to think through more carefully was what the differences meant in practice. In the investing graph, a false negative means a traversal stops short. Two nodes that should be the same entity exist as separate nodes, and a path that should go through them stops at the first one. The graph is less useful, and you can often tell something is wrong because you're getting empty results where you expect some.&lt;/p&gt;

&lt;p&gt;In sanctions screening, a false negative is an entity that should have been flagged clearing a check it shouldn't have. The downstream consequences are not "the graph is less useful." This changes how you'd set the threshold, and it probably changes how you'd approach training data. The model I've been running was tuned for a corpus where some missed merges are acceptable in exchange for keeping false positives low. For compliance tooling, that tradeoff goes the other way. I couldn't tell the KYC person "use the same model and threshold" and be confident they'd get results they could actually rely on.&lt;/p&gt;

&lt;p&gt;So: the core architecture is the same. The pairwise scoring, the registry with a stable and pending boundary, the review layer for uncertain pairs. But the model would need to be retrained on labeled pairs from their actual domain, and the threshold would need to be set differently. These aren't small caveats. Blocking strategy would probably change too, because person name matching across languages is harder than company name matching in English.&lt;/p&gt;

&lt;h2&gt;
  
  
  The financial news question
&lt;/h2&gt;

&lt;p&gt;This one was more tractable. They were ingesting from Bloomberg, a Chinese-language wire service, and some regional outlets. "Tesla, Inc." and "特斯拉" and "the EV maker" arriving in the same week.&lt;/p&gt;

&lt;p&gt;The architecture question I could answer directly: yes, the same setup applies. The harder question was about cross-language blocking. For English company names, string normalization gets you a long way. Strip legal suffixes, lowercase, collapse whitespace, and most aliases cluster cleanly enough that phonetic blocking on the normalized form works. For Chinese-English matching, you need something else.&lt;/p&gt;

&lt;p&gt;I spent some time thinking through what I'd actually try. One approach is alias generation during ingestion: before a string hits the resolver, you run it through a step that produces language variants and stores them as additional candidates. Then blocking happens on normalized alias strings rather than on raw input. The problem with this is that you're trusting the alias generation step, which introduces a new source of noise. I've thought about this but haven't implemented it, and I was honest with the person asking that this was speculation rather than something I'd tested.&lt;/p&gt;

&lt;p&gt;The main takeaway from both conversations is that the registry architecture is more transferable than the model itself. The idea of maintaining a stable set of committed decisions versus a pending set of uncertain pairs under review, and serving only the stable set to external callers, holds regardless of what domain you're matching in. The specific model weights and threshold values don't hold.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell someone starting from scratch
&lt;/h2&gt;

&lt;p&gt;This is the part I'm most uncertain about writing, because there's a gap between "what I learned from this specific project" and "what would generalize to your project."&lt;/p&gt;

&lt;p&gt;The concrete thing I'd say: budget for registry maintenance in a way you probably won't. The ongoing cost of running this system is managing accumulated state, not running inference. Close to 48,000 entities and aliases have accumulated over months of ingestion. Some of those decisions were made when the threshold was still being calibrated. I know this in aggregate because I watched the model improve; I don't know which specific entries are wrong without re-running the full corpus. That re-run is still on the list.&lt;/p&gt;

&lt;p&gt;I'd also say: the blocking step is where I lost the most time to underestimation. It's easy to focus on the model and assume that if you have good coverage you'll catch most matches. The model doesn't see pairs that didn't make it through blocking. If your blocking strategy misses a class of matches systematically, you won't know from looking at the model's output alone.&lt;/p&gt;

&lt;p&gt;The third thing, and I've mentioned this before but it bears repeating at the end of a series: Splink is doing the real probabilistic lifting here. It's a well-maintained open-source library, and I'd recommend starting there before building anything custom.&lt;/p&gt;

&lt;h2&gt;
  
  
  ER API and what comes next
&lt;/h2&gt;

&lt;p&gt;The investing knowledge graph is still running. The entity resolver is live on Railway as ER API, and I'm expanding access based on how early users are actually using it.&lt;/p&gt;

&lt;p&gt;The endpoints are &lt;code&gt;/v1/resolve&lt;/code&gt; for single string resolution and &lt;code&gt;/v1/splink-pairs&lt;/code&gt; for batch candidate scoring. If you're running into the entity resolution problem in your own pipeline and want to try the service, there's a form at &lt;a href="https://hannune.ai" rel="noopener noreferrer"&gt;hannune.ai&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  All six parts
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;a href="https://dev.to/hannune/building-an-investing-knowledge-graph-part-1-from-does-this-news-matter-to-a-graph-i-can-query-11bi"&gt;Part 1&lt;/a&gt;: From "does this news matter" to a graph I can query&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/hannune/building-an-investing-knowledge-graph-part-2-the-query-sql-cant-answer-l9f"&gt;Part 2&lt;/a&gt;: The query SQL can't answer&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/hannune/building-an-investing-knowledge-graph-part-3-same-company-seven-names-b7d"&gt;Part 3&lt;/a&gt;: Same company, seven names&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/hannune/building-an-investing-knowledge-graph-part-4-building-the-resolver-3dn7"&gt;Part 4&lt;/a&gt;: Building the resolver&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/hannune/building-an-investing-knowledge-graph-part-5-what-live-actually-means-18g3"&gt;Part 5&lt;/a&gt;: What LIVE actually means&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;Built on &lt;a href="https://github.com/moj-analytical-services/splink" rel="noopener noreferrer"&gt;Splink&lt;/a&gt; for probabilistic record linkage.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>graphdb</category>
      <category>knowledgegraph</category>
      <category>entityresolution</category>
      <category>dataengineering</category>
    </item>
    <item>
      <title>Building an investing knowledge graph, part 5: what LIVE actually means</title>
      <dc:creator>Tae Kim</dc:creator>
      <pubDate>Wed, 02 Sep 2026 02:37:13 +0000</pubDate>
      <link>https://dev.to/hannune/building-an-investing-knowledge-graph-part-5-what-live-actually-means-18g3</link>
      <guid>https://dev.to/hannune/building-an-investing-knowledge-graph-part-5-what-live-actually-means-18g3</guid>
      <description>&lt;h1&gt;
  
  
  Building an investing knowledge graph, part 5: what LIVE actually means
&lt;/h1&gt;

&lt;p&gt;Part 4 ended with the resolver working locally and me calling it live on Railway. That framing glossed over a gap. Something running locally and something running in production are different in ways that are obvious in retrospect and invisible until they bite you.&lt;/p&gt;

&lt;p&gt;Here's what changed once I had a caller that wasn't me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first thing that breaks when someone else calls your API
&lt;/h2&gt;

&lt;p&gt;The investing knowledge graph pipeline was the only caller for a long time. I knew its call patterns. I'd written both sides. The error budget was implicit — if something failed, I fixed it and kept going.&lt;/p&gt;

&lt;p&gt;The first external caller changed that. When the API started returning errors for them, there was no "I'll fix it and retry." There was a broken integration on their end, and they had no context for diagnosing it.&lt;/p&gt;

&lt;p&gt;The specific failure was unhelpful: a 500 with a generic error message on a request that had worked in my own testing. It took debugging from both ends to isolate the cause. The request was valid. The issue was that it hit the API during a window when the registry backend was mid-write, and the response came back internally inconsistent — some aliases resolved correctly, others returned stale data.&lt;/p&gt;

&lt;p&gt;This wasn't a bug in the resolver logic. It was a backend durability problem I hadn't needed to care about when I was the only caller.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the backend switch actually fixed
&lt;/h2&gt;

&lt;p&gt;Locally, I used a file-based registry backend. The registry serializes to a JSON file, loaded into memory at startup and read during inference. Writes append and periodically flush to disk. Simple, fast, no dependencies.&lt;/p&gt;

&lt;p&gt;The failure mode is obvious once you see it: during a write, if a read hits a partially flushed state, you get inconsistency. For a single-user pipeline running sequentially, this almost never surfaces. You'd have to be unlucky for a read to interleave with a write in a way that causes problems. Once there are concurrent callers, that probability stops being unlucky and becomes predictable.&lt;/p&gt;

&lt;p&gt;The production backend is now PostgreSQL via Supabase. Writes go through transactions. Reads get a consistent snapshot. The alias table and the entity registry update atomically — either both change or neither does. A caller mid-request gets a consistent registry view regardless of what writes are in flight.&lt;/p&gt;

&lt;p&gt;The switch is controlled by an environment variable (&lt;code&gt;ER_REGISTRY_BACKEND&lt;/code&gt;). The application logic doesn't change, just the persistence layer. This separation made the transition straightforward: I could test both backends against the same code without restructuring anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The healthcheck
&lt;/h2&gt;

&lt;p&gt;Railway handles deployments by routing traffic only after a service passes its healthcheck. The &lt;code&gt;/health&lt;/code&gt; endpoint returns 200 when the API is ready to serve requests and the registry backend is reachable.&lt;/p&gt;

&lt;p&gt;This matters in practice because startup isn't instantaneous. The service loads the model, initializes the Splink configuration, and verifies the backend connection before reporting healthy. Without an accurate healthcheck, Railway might route traffic to an instance that's running but not ready, which produces the kind of half-initialized state that generates confusing errors.&lt;/p&gt;

&lt;p&gt;During a deploy, Railway keeps the old version serving traffic until the new one passes its healthcheck. No request reaches the new version before it's ready. This zero-downtime behavior is Railway's default — I didn't configure it specially — but it only works correctly if the healthcheck accurately reflects whether the service is ready.&lt;/p&gt;

&lt;p&gt;The healthcheck has failed in two ways across different deployments: backend unreachable, and model initialization error. Both are things I'd rather catch at startup than discover mid-request. The healthcheck turned each into a failed deploy rather than a silent production problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Registry integrity at 47,853 entities
&lt;/h2&gt;

&lt;p&gt;The registry currently holds 47,853 resolved entities and 47,883 aliases. Most entries are stable. Some are probably wrong.&lt;/p&gt;

&lt;p&gt;Early in the project, when I was still calibrating the Splink threshold, the model was more permissive than it should have been. Some merges happened that were likely incorrect — entity strings that shared enough surface similarity to pass the threshold but were different companies. A few of those early-period decisions are still in the registry.&lt;/p&gt;

&lt;p&gt;I know this in general. I don't know specifically which ones without re-running the full corpus against the current model. That re-run is on the list but hasn't happened yet.&lt;/p&gt;

&lt;p&gt;What I have now is a review layer before new merges commit to the stable registry. Pairs that the model scores as probable matches but below a high-confidence threshold go into a pending state rather than being committed immediately. They get reviewed before joining the stable set. This doesn't fix the early errors already in the registry, but it stops new errors from accumulating at the same rate.&lt;/p&gt;

&lt;p&gt;The production API serves only the stable registry. Pending pairs aren't exposed to external callers. If the model is uncertain, the caller gets a &lt;code&gt;split&lt;/code&gt; decision. They can re-query later once additional evidence has accumulated and the pair has cleared review.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "beta" actually means here
&lt;/h2&gt;

&lt;p&gt;The last few posts in this series have mentioned "beta" without being specific. Here's what it actually implies:&lt;/p&gt;

&lt;p&gt;The API is running. External callers are using it. I'm expanding access carefully rather than opening it wide.&lt;/p&gt;

&lt;p&gt;It's not a prototype — the infrastructure decisions described above are real, the registry has nearly 48k entities built from actual article processing, and the resolver has been running reliably for months. It's also not a mature product with SLAs, a dedicated support tier, or uptime commitments beyond what Railway's infrastructure provides.&lt;/p&gt;

&lt;p&gt;If you have an entity resolution problem — corporate name disambiguation, customer record deduplication, knowledge graph construction from heterogeneous sources — and want to try the service, there's a form at &lt;a href="https://hannune.ai" rel="noopener noreferrer"&gt;hannune.ai&lt;/a&gt;. I'll respond directly.&lt;/p&gt;

&lt;p&gt;Next: the final part of this series wraps up the full arc and looks at where the same five-step pattern — DB limits, knowledge graph, ER bottleneck, direct implementation, cloud deployment — shows up outside investing graphs.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built on &lt;a href="https://github.com/moj-analytical-services/splink" rel="noopener noreferrer"&gt;Splink&lt;/a&gt; for probabilistic record linkage. Part 1 is &lt;a href="https://dev.to/hannune/building-an-investing-knowledge-graph-part-1-from-does-this-news-matter-to-a-graph-i-can-query-11bi"&gt;here&lt;/a&gt;. Part 2 is &lt;a href="https://dev.to/hannune/building-an-investing-knowledge-graph-part-2-the-query-sql-cant-answer-l9f"&gt;here&lt;/a&gt;. Part 3 is &lt;a href="https://dev.to/hannune/building-an-investing-knowledge-graph-part-3-same-company-seven-names-b7d"&gt;here&lt;/a&gt;. Part 4 is &lt;a href="https://dev.to/hannune/building-an-investing-knowledge-graph-part-4-building-the-resolver-3dn7"&gt;here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>graphdb</category>
      <category>knowledgegraph</category>
      <category>dataengineering</category>
      <category>api</category>
    </item>
    <item>
      <title>Building an investing knowledge graph, part 4: building the resolver</title>
      <dc:creator>Tae Kim</dc:creator>
      <pubDate>Tue, 01 Sep 2026 02:37:00 +0000</pubDate>
      <link>https://dev.to/hannune/building-an-investing-knowledge-graph-part-4-building-the-resolver-3dn7</link>
      <guid>https://dev.to/hannune/building-an-investing-knowledge-graph-part-4-building-the-resolver-3dn7</guid>
      <description>&lt;h1&gt;
  
  
  Building an investing knowledge graph, part 4: building the resolver
&lt;/h1&gt;

&lt;p&gt;Part 3 ended with a question I left open: how does the threshold actually get set, and what happens to the registry when new articles keep coming in? That's what this one covers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost of getting it wrong in both directions
&lt;/h2&gt;

&lt;p&gt;Before I touch thresholds, it's worth being specific about what failing in each direction costs.&lt;/p&gt;

&lt;p&gt;A false merge combines two distinct entities into one node. In the Samsung SDI case from part 3, that would mean battery-supply disruptions show up when you traverse from the semiconductor business. The graph gives you an answer. The answer is wrong. You don't necessarily know it's wrong without already knowing the answer, which defeats the point.&lt;/p&gt;

&lt;p&gt;A false split keeps the same entity as two separate nodes. "Samsung Electronics" and "the largest memory chipmaker in the world" stay disconnected. Edges pile up on each independently. A traversal from one doesn't reach the other. The graph gives you a partial answer. You lose coverage.&lt;/p&gt;

&lt;p&gt;These aren't symmetric. A false merge injects noise that's hard to detect. A false split just means a disconnected node — you miss some connections, but you don't get fabricated ones.&lt;/p&gt;

&lt;p&gt;So I set the threshold conservatively. Pairs below a high confidence mark stay split. Some real aliases end up never linking. That's the tradeoff I chose: lower recall, higher precision. For a graph I'm using to make judgments about which news matters, a confidently wrong edge is worse than a missing one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the endpoint does
&lt;/h2&gt;

&lt;p&gt;The resolution service exposes a &lt;code&gt;/v1/splink-pairs&lt;/code&gt; endpoint. It takes a list of candidate entity mention strings and returns a score for each pair, along with whether that pair crosses the registry threshold.&lt;/p&gt;

&lt;p&gt;A minimal request looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;POST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/v&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="err"&gt;/splink-pairs&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"candidates"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"Samsung Electronics Co."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"the largest memory chipmaker in the world"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"Samsung SDI"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"TSMC"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response returns a score matrix for pairs that the model considers worth evaluating — not every combination, just the ones above a blocking threshold that filters out obviously unrelated pairs before the full model runs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"pairs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"left"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Samsung Electronics Co."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"right"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"the largest memory chipmaker in the world"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"match_probability"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.96&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"decision"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"match"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"canonical_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"company:samsung_electronics"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"left"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Samsung Electronics Co."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"right"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Samsung SDI"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"match_probability"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"decision"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"split"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"canonical_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The caller gets a decision (&lt;code&gt;match&lt;/code&gt; or &lt;code&gt;split&lt;/code&gt;) and the canonical entity ID when there's a match. If the match is against a known registry entry, the alias gets recorded. If it's a new pair the model hadn't seen before, that pair gets added to the evidence set for future model updates.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the registry grows
&lt;/h2&gt;

&lt;p&gt;When the investing knowledge graph pipeline processes a new article, it extracts entity mentions and runs them through the resolver. Three things can happen:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Known alias.&lt;/strong&gt; The string matches an existing entry in the alias table. Lookup is fast, no model call needed. The mention gets written to the graph under the existing canonical ID.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unknown mention, matches existing entity.&lt;/strong&gt; The string isn't in the alias table, but the model scores it as a probable match against an existing entity. The alias gets added to the registry. Future articles that use the same string skip the model call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Genuinely new entity.&lt;/strong&gt; The model doesn't find a confident match against anything in the registry. A new canonical entry gets created. It starts small — one mention, no resolved aliases — and accumulates evidence as future articles mention the same company.&lt;/p&gt;

&lt;p&gt;The registry currently has 47,853 resolved entities and 47,883 aliases. A lot of those started as single-mention nodes. Some have since merged as more articles confirmed the connection.&lt;/p&gt;

&lt;p&gt;A few early decisions are probably still wrong. In the first batches I was more aggressive with merging, before I tightened the threshold. There are likely some nodes that should be split. I know this in general; I don't know specifically which ones without re-running the full corpus against the current model, which I haven't done.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Splink is doing under the hood
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/moj-analytical-services/splink" rel="noopener noreferrer"&gt;Splink&lt;/a&gt; is the open source probabilistic record linkage library the model is built on. It uses DuckDB as the computation backend for the pair scoring, which handles the candidate generation and blocking step — filtering the space of possible pairs before running the full model.&lt;/p&gt;

&lt;p&gt;For corporate entity mentions, the features that ended up mattering:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;String similarity&lt;/strong&gt; on the canonical name: catches abbreviations and common shorthand&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alias coverage&lt;/strong&gt;: once a surface form is in the registry, future instances match by lookup rather than model score — this is how "the largest memory chipmaker in the world" eventually becomes reliable without its string similarity to "Samsung Electronics" ever improving&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token overlap&lt;/strong&gt; on co-occurring named entities within the same article: an article about DRAM yields tends to mention different companies than an article about battery chemistry&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Base rate weighting&lt;/strong&gt;: a rare entity (one mention, no confirmed aliases) shouldn't get absorbed into a high-frequency entity just because they share a token&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Splink learns the feature weights from labeled pairs. The training set was several hundred manually verified matches and non-matches from the first two thousand articles. Not large. But I had enough confirmed Samsung/Samsung SDI pairs to get the context features weighted correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The point where this became a service
&lt;/h2&gt;

&lt;p&gt;Somewhere around the third project where I had the same entity resolution problem — merging customer records from two systems, with the same fragmentation-versus-false-merge tradeoff — I stopped treating this as a per-project function and wrapped it into a standalone FastAPI service.&lt;/p&gt;

&lt;p&gt;The knowledge graph pipeline now calls it over HTTP. Other things call it the same way. The registry is shared across callers. A match that one pipeline discovers helps every other caller that sees the same entity later.&lt;/p&gt;

&lt;p&gt;That service is what I'm calling &lt;a href="https://hannune.ai" rel="noopener noreferrer"&gt;ER API&lt;/a&gt;. It runs on Railway, it's live, and the registry it's backing is the same one that's kept the investing knowledge graph from turning into a tangle of phantom nodes.&lt;/p&gt;

&lt;p&gt;Next: what it takes to keep this running in production — the parts that only showed up after the first real caller wasn't me.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built on &lt;a href="https://github.com/moj-analytical-services/splink" rel="noopener noreferrer"&gt;Splink&lt;/a&gt; for probabilistic record linkage. Part 1 is &lt;a href="https://dev.to/hannune/building-an-investing-knowledge-graph-part-1-from-does-this-news-matter-to-a-graph-i-can-query-11bi"&gt;here&lt;/a&gt;. Part 2 is &lt;a href="https://dev.to/hannune/building-an-investing-knowledge-graph-part-2-the-query-sql-cant-answer-l9f"&gt;here&lt;/a&gt;. Part 3 is &lt;a href="https://dev.to/hannune/building-an-investing-knowledge-graph-part-3-same-company-seven-names-b7d"&gt;here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>graphdb</category>
      <category>knowledgegraph</category>
      <category>entityresolution</category>
      <category>python</category>
    </item>
  </channel>
</rss>
