<?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>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>
    <item>
      <title>Building an investing knowledge graph, part 3: same company, seven names</title>
      <dc:creator>Tae Kim</dc:creator>
      <pubDate>Mon, 31 Aug 2026 02:58:56 +0000</pubDate>
      <link>https://dev.to/hannune/building-an-investing-knowledge-graph-part-3-same-company-seven-names-b7d</link>
      <guid>https://dev.to/hannune/building-an-investing-knowledge-graph-part-3-same-company-seven-names-b7d</guid>
      <description>&lt;h1&gt;
  
  
  Building an investing knowledge graph, part 3: same company, seven names
&lt;/h1&gt;

&lt;p&gt;I noticed a problem because a traversal that had been working stopped returning results. The ASML supply chain path I'd tested a week earlier came back empty. I spent a while thinking I'd broken the query. The query was fine. What had happened was that a new batch of articles from a different source used "the Dutch lithography equipment maker" and "ASML Holding NV" without ever using the string "ASML." Both landed in the graph as separate nodes. The traversal starting from the ASML node I'd been accumulating edges on couldn't reach them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seven names for one company
&lt;/h2&gt;

&lt;p&gt;When I checked the Samsung Electronics entry in the registry, it had resolved seven distinct aliases to a single entity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"samsung"
"samsung electronics"
"samsung electronics co."
"samsung foundry"
"samsung's"
"the korean company"
"the largest memory chipmaker in the world"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The foundry alias is a judgment call I went back on twice -- Samsung Foundry is technically a division with its own reporting, but in this corpus it was mostly used as shorthand for the parent in manufacturing capacity articles, so I kept it merged. The descriptive ones at the bottom are wire service shorthand. An article that opened with "Samsung Electronics" in the headline would use "the Korean company" three paragraphs later. Standard string matching returns near-zero similarity between "the largest memory chipmaker in the world" and "samsung electronics." They share no tokens.&lt;/p&gt;

&lt;p&gt;Before I had a proper setup for this, I was using token overlap to match entity mentions. The threshold was loose enough to catch most of the alias variations above. It was also loose enough to merge Samsung SDI into Samsung Electronics.&lt;/p&gt;

&lt;p&gt;Samsung SDI makes batteries. It is a separate publicly listed company. A lithium cell supply disruption hitting Samsung SDI's production line has nothing to do with Samsung Electronics' memory fab utilization. If those two ended up as the same node, events from one would show up in traversals that started from the other. I caught this specific case because I happened to be running a check on Samsung when a battery shortage article came through. Whether there are other pairs like this that I haven't caught, I genuinely don't know.&lt;/p&gt;

&lt;p&gt;The descriptive ASML alias fails because it shares no tokens with the canonical name. Samsung SDI fails to separate because it shares the most salient token with an entity it should be distinct from. There's no threshold value that handles both. Loosening the threshold enough to catch the first kind of failure causes more of the second. These are actually different problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Splink does with this
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/moj-analytical-services/splink" rel="noopener noreferrer"&gt;Splink&lt;/a&gt; treats entity resolution as a classification problem. For any candidate pair of mentions, the model computes a match probability using multiple features simultaneously and learns weights from a training set of confirmed pairs and non-matches.&lt;/p&gt;

&lt;p&gt;For corporate entities in news text, the features I ended up using: canonical name similarity is the obvious starting point -- it handles "Samsung Electronics Co." but not "the largest memory chipmaker in the world." For descriptive references, alias coverage matters more: once a mention form has been confirmed as belonging to a given entity, future occurrences match by lookup. The ASML case gets better over time this way, even though the string overlap never improves. &lt;/p&gt;

&lt;p&gt;For the Samsung SDI case, what made the difference was article-level context. An article that mentions DRAM yields and fab capacity almost certainly isn't about the battery subsidiary. Whether that context gets encoded as co-occurring industry terms or co-mentioned company names is a feature engineering choice; either way, it's information that string matching on the entity name alone can't see. I also added a base-rate feature after a few early merges looked wrong -- a rare entity with one mention shouldn't get absorbed into a high-frequency entity just because they share a token.&lt;/p&gt;

&lt;p&gt;Splink learned weights across all of this from a training set of a few hundred manually verified pairs from the early corpus. Not large. But enough to get the Samsung SDI case right once I included the context features, which I hadn't initially.&lt;/p&gt;

&lt;p&gt;The registry now has 47,853 resolved entities. That number has been climbing -- each new article either matches an existing entity or creates a new node that might get merged later as evidence accumulates. A few merges from early on are probably still wrong. Some entries I initially kept separate have since merged when more articles came in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Still a threshold
&lt;/h2&gt;

&lt;p&gt;Splink produces a probability, not a final answer. You still decide where to cut.&lt;/p&gt;

&lt;p&gt;For this graph, I set that conservatively. A pair below a certain value stays split. Some valid aliases end up never linking to their entity -- nodes with few edges that mostly sit disconnected. I ran a check on a sample and most looked like genuine unknowns where there wasn't enough article evidence to confirm the connection. A few were probably real aliases I missed. That's the tradeoff: some fragmentation, in exchange for higher confidence that a merged entity represents one thing.&lt;/p&gt;

&lt;p&gt;How that threshold gets set in practice, and what happens to the registry as new articles change the evidence picture, is part 4.&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;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>graphdb</category>
      <category>knowledgegraph</category>
      <category>dataengineering</category>
      <category>entityresolution</category>
    </item>
    <item>
      <title>Building an investing knowledge graph, part 2: the query SQL can't answer</title>
      <dc:creator>Tae Kim</dc:creator>
      <pubDate>Sat, 29 Aug 2026 02:52:23 +0000</pubDate>
      <link>https://dev.to/hannune/building-an-investing-knowledge-graph-part-2-the-query-sql-cant-answer-l9f</link>
      <guid>https://dev.to/hannune/building-an-investing-knowledge-graph-part-2-the-query-sql-cant-answer-l9f</guid>
      <description>&lt;p&gt;Here is a Cypher query I can now run against the investing knowledge graph:&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="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;source:&lt;/span&gt;&lt;span class="n"&gt;Company&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;"ASML"&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;:SUPPLY_CUT&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;EXPORT_RESTRICTION&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;PRODUCTION_DELAY&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="m"&gt;4&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;downstream:&lt;/span&gt;&lt;span class="n"&gt;Company&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;downstream.name&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;company&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt;
  &lt;span class="nf"&gt;length&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;hops&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;IN&lt;/span&gt; &lt;span class="nf"&gt;relationships&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;r.article_id&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;evidence_articles&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;IN&lt;/span&gt; &lt;span class="nf"&gt;relationships&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;r.event_date&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;event_dates&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;hops&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_dates&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="ss"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It returns paths. Not rows. Each result in the output is a chain from the source company to a downstream company, with the article ID and event date at every link. I can see whether NVIDIA appears in the results, how many steps away, and what sequence of articles establishes the connection. The depth bound is a parameter I tune at query time.&lt;/p&gt;

&lt;p&gt;I didn't write this first. This is where I ended up after trying two other things that didn't work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The search index
&lt;/h2&gt;

&lt;p&gt;The article database sits in Elasticsearch, index called &lt;code&gt;economic_news_articles_en&lt;/code&gt;, currently 9,414 documents. Starting with what I had:&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;GET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;economic_news_articles_en/_search&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;"query"&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;"bool"&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;"must"&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;"match"&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;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"chip packaging export restriction"&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;span class="nl"&gt;"range"&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;"published_at"&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;"gte"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2023-01-01"&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;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;"_source"&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="s2"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"published_at"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"companies_mentioned"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;15&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;Fourteen results. Some are on the specific restriction. A few match incidentally. The NVIDIA articles are nowhere in these results because NVIDIA isn't mentioned in any of them. This is not a deficiency in Elasticsearch. It returned exactly what it should. The question I wanted to ask is a different kind of question: it requires following edges between things, and the search index doesn't model edges. Relevance tuning would not have helped here.&lt;/p&gt;

&lt;h2&gt;
  
  
  The SQL attempt
&lt;/h2&gt;

&lt;p&gt;I had generated a relational structure during the build process. An &lt;code&gt;article_events&lt;/code&gt; table, each row an event type, a subject company, an affected company, and a date. The first query:&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="k"&gt;c&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;span class="n"&gt;ae&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ae&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_date&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;article_events&lt;/span&gt; &lt;span class="n"&gt;ae&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;companies&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ae&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ae&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subject_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;companies&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ASML'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;ae&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_type&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'supply_cut'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'export_restriction'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'production_delay'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;ae&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_date&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives first-hop results: companies directly named in events where ASML is the subject. Going one hop further:&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;WITH&lt;/span&gt; &lt;span class="n"&gt;first_hop&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;ae&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_id&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;affected_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ae&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_date&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;article_events&lt;/span&gt; &lt;span class="n"&gt;ae&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ae&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subject_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;companies&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ASML'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;ae&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_type&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'supply_cut'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'export_restriction'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'production_delay'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="k"&gt;c&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;span class="n"&gt;ae2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ae2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_date&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;first_hop&lt;/span&gt; &lt;span class="n"&gt;fh&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;article_events&lt;/span&gt; &lt;span class="n"&gt;ae2&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;ae2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subject_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;affected_id&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;companies&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ae2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ae2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_date&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;fh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_date&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;ae2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_date&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I had to know upfront how many hops I was looking for. Two CTEs for two hops. A three-hop query needs three. If the connection I care about happens to be five steps deep, a three-hop query misses it quietly. Recursive CTEs handle variable depth but bring their own performance questions, and by that point I was building something that wasn't really a SQL use case anymore.&lt;/p&gt;

&lt;p&gt;The schema itself was losing information. Events in articles don't always have clean subject-object pairs. An article might describe an event that involves three or four companies in ways I couldn't reduce to one row without discarding something.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the graph answered it
&lt;/h2&gt;

&lt;p&gt;The Cypher query at the top of this post returned paths. The evidence traveled with the result. For each chain in the output I had the article IDs and the event dates at each intermediate link, not just the endpoint. The &lt;code&gt;*1..4&lt;/code&gt; depth parameter meant I could check four hops without rewriting anything.&lt;/p&gt;

&lt;p&gt;The graph at this point holds 84,962 nodes and 275,293 relationships, built from those same 9,414 articles. On the Cypher query, response time is acceptable. When a path doesn't exist in the data, the query returns nothing, which is a correct answer. It means the graph doesn't have evidence for that connection, not that the connection doesn't exist in the world.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the graph assumed
&lt;/h2&gt;

&lt;p&gt;For the traversal to be useful, the same company needed to map to the same node regardless of how different articles referred to it.&lt;/p&gt;

&lt;p&gt;That didn't happen automatically. Articles about ASML might say "ASML Holding NV" in a formal announcement, then a wire service shortens it to just "ASML," then a blog post never names the company and calls it "the Dutch lithography equipment maker" instead. If those land as three separate nodes in the graph, the traversal silently breaks. None of the three nodes accumulates enough edges to connect to anything interesting.&lt;/p&gt;

&lt;p&gt;The flip side caused different problems. ASML Cymer is a subsidiary. It makes excimer laser light sources, not the lithography systems the parent is known for. Merging that name into the ASML parent node because the strings share characters would misattribute events. A supply disruption that hit only the subsidiary's production line would look, in the graph, like it hit ASML's main business.&lt;/p&gt;

&lt;p&gt;Getting entity mentions to resolve correctly was the bottleneck I hadn't expected. That's what part 3 covers: how string similarity fails in both directions at once, and what probabilistic matching over multiple signals looks like in practice for 47,853 resolved entities.&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;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>graphdb</category>
      <category>knowledgegraph</category>
      <category>dataengineering</category>
      <category>investing</category>
    </item>
    <item>
      <title>When your calls can't leave your infrastructure: the compliance constraint that shapes the whole AI stack</title>
      <dc:creator>Tae Kim</dc:creator>
      <pubDate>Fri, 28 Aug 2026 02:37:46 +0000</pubDate>
      <link>https://dev.to/hannune/when-your-calls-cant-leave-your-infrastructure-the-compliance-constraint-that-shapes-the-whole-ai-4kec</link>
      <guid>https://dev.to/hannune/when-your-calls-cant-leave-your-infrastructure-the-compliance-constraint-that-shapes-the-whole-ai-4kec</guid>
      <description>&lt;p&gt;I wrote before about &lt;a href="https://dev.to/hannune/why-i-run-speech-to-text-locally-instead-of-calling-a-cloud-api-59j7"&gt;why I run speech-to-text locally&lt;/a&gt;—for me it started as a personal data hygiene decision. But when I've talked to other people about local AI pipelines, a different version of the same constraint comes up: not "I'd prefer the audio to stay local" but "the audio legally cannot leave our environment."&lt;/p&gt;

&lt;p&gt;That's a different problem. A preference shapes your tooling choices. A compliance constraint shapes your architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the constraint actually looks like
&lt;/h2&gt;

&lt;p&gt;The most common version I hear: enterprise customer agreements. A company builds a software tool that serves clients in regulated industries. Those clients have data handling requirements that specify data can only be processed in specific jurisdictions or on approved infrastructure. When that company wants to add AI-powered call analysis, every cloud transcription API is immediately out of scope—not because of policy preference, but because the customer contract requires it.&lt;/p&gt;

&lt;p&gt;Attorney-client privilege is another version. Law firms and their clients have specific expectations about where privileged communications go. Using a third-party transcription service means the audio passes through systems outside that privileged relationship. Whether that technically breaks privilege is a legal question that most firms don't want to be the test case for.&lt;/p&gt;

&lt;p&gt;Healthcare is the most formalized version: PHI (protected health information) that appears in patient calls needs to stay within HIPAA-compliant infrastructure. Some cloud providers offer BAAs (Business Associate Agreements) that extend that coverage, but plenty of healthcare organizations have decided the simplest answer is a processing pipeline that stays entirely on infrastructure they control.&lt;/p&gt;

&lt;p&gt;The common thread: the constraint isn't about the transcription output (text) but about the audio itself, during processing. It's a data residency and custody question.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architectural implication: it's not just transcription
&lt;/h2&gt;

&lt;p&gt;Here's where this gets interesting from a systems perspective. If you accept the constraint that audio can't leave your infrastructure, and you're trying to build something useful from that audio—summaries, action items, entity extraction, follow-up flags—the constraint propagates through every step that touches the content.&lt;/p&gt;

&lt;p&gt;You transcribe locally. But then what?&lt;/p&gt;

&lt;p&gt;If you send the transcript to a cloud LLM for summarization, you've sent the content of the call off your infrastructure. The audio stayed local, but the information left. For most compliance constraints, this doesn't actually satisfy the requirement.&lt;/p&gt;

&lt;p&gt;So the constraint ends up requiring:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Local transcription (Whisper or equivalent)&lt;/li&gt;
&lt;li&gt;Local embeddings if you're doing semantic search across transcripts&lt;/li&gt;
&lt;li&gt;Local LLM inference if you're doing summarization, extraction, or classification&lt;/li&gt;
&lt;li&gt;Local storage, obviously&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You end up needing a full local inference stack, not just a transcription component. The compliance requirement that started with "we can't use cloud STT" turns into "we need to run the whole pipeline on infrastructure we control."&lt;/p&gt;

&lt;h2&gt;
  
  
  This is the constraint I'm designing around
&lt;/h2&gt;

&lt;p&gt;The setup I've been building runs Whisper for transcription, bge-m3 for embeddings, and Gemma for image/document processing—all on a single local server. I covered the &lt;a href="https://dev.to/hannune/running-three-ai-models-on-one-local-server-when-your-vram-doesnt-cover-all-of-them-b7g"&gt;resource management side of running three models on one box&lt;/a&gt; in an earlier post.&lt;/p&gt;

&lt;p&gt;The "nothing leaves the box" constraint wasn't an afterthought. It was the first design decision, and it determined the whole stack. Every model choice, every pipeline component was evaluated against it: does this require sending data somewhere I don't control? If yes, it's out.&lt;/p&gt;

&lt;p&gt;The tradeoffs are real. Local inference is slower than cloud APIs for equivalent hardware. You own the updates and maintenance. When a better Whisper model ships, you're updating your own deployment. The TCO calculation looks different depending on how much you're processing.&lt;/p&gt;

&lt;p&gt;But for the use cases where the compliance constraint is genuine—where "it's in the customer agreement" or "it's a regulatory requirement"—those tradeoffs aren't optional. The architecture has to start from the constraint.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I haven't built yet
&lt;/h2&gt;

&lt;p&gt;The local inference stack is working. What I'm building toward is the layer that makes it actually useful: intake from multiple channels (calls, email, messages), process everything locally, surface what matters in a daily report.&lt;/p&gt;

&lt;p&gt;The compliance constraint applies there too—not just transcription. Every step processes content that, in an NDA or regulated environment, can't transit external APIs. So the design for the report generation layer has to be local all the way through.&lt;/p&gt;

&lt;p&gt;That's the hard part I'm working on now: not "can I transcribe calls locally" (yes, that's working), but "can I build the summarization and reporting layer locally at quality that's actually useful."&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Other posts in this series: &lt;a href="https://dev.to/hannune/running-three-ai-models-on-one-local-server-when-your-vram-doesnt-cover-all-of-them-b7g"&gt;Running three AI models on one server&lt;/a&gt; | &lt;a href="https://dev.to/hannune/why-i-run-speech-to-text-locally-instead-of-calling-a-cloud-api-59j7"&gt;Why local STT over cloud API&lt;/a&gt; | &lt;a href="https://dev.to/hannune/missed-calls-missed-revenue-the-local-first-phone-assistant-im-building-2086"&gt;Missed calls, missed revenue&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>selfhosted</category>
      <category>python</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Building an investing knowledge graph, part 1: from "does this news matter" to a graph I can query</title>
      <dc:creator>Tae Kim</dc:creator>
      <pubDate>Mon, 24 Aug 2026 06:24:08 +0000</pubDate>
      <link>https://dev.to/hannune/building-an-investing-knowledge-graph-part-1-from-does-this-news-matter-to-a-graph-i-can-query-11bi</link>
      <guid>https://dev.to/hannune/building-an-investing-knowledge-graph-part-1-from-does-this-news-matter-to-a-graph-i-can-query-11bi</guid>
      <description>&lt;p&gt;I keep hitting the same question when a headline crosses my feed about a company I'm holding, or thinking about holding: does this news actually reach the thing I care about? A chip export restriction hits some supplier nobody's heard of. Two weeks later a manufacturer I do care about moves, and the headline that started it never mentioned them once. I wanted to see that chain, the actual event-to-event path, and no sentiment score was going to show it to me.&lt;/p&gt;

&lt;p&gt;So I tried the obvious thing first: search my article database for the company name and read whatever comes back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the article database stopped helping
&lt;/h2&gt;

&lt;p&gt;I have a database of financial news articles, 9,414 documents at the moment, each indexed with the companies, people, and events mentioned. "Show me articles about Samsung" works fine. That's what search is for. "Show me what happens downstream when a supplier gets sanctioned" falls apart immediately, because a table of articles has no concept of downstream. Each row is independent. Nothing connects article 4,201 about a supplier to article 6,830 about a company three steps down the chain (those two numbers are made up, but that's the shape of the problem). Unless I read every row myself and hold the connections in my head, which is what I was already doing, badly.&lt;/p&gt;

&lt;p&gt;Filtering and sorting, a table does great. Walking a chain of "this caused that caused that" just isn't something a flat table does. The information might be sitting right there across a dozen rows. There's no native way to say "A affects B affects C." Tables were never built for that question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Swapping rows for nodes and edges
&lt;/h2&gt;

&lt;p&gt;A knowledge graph fixes this at the structural level. Instead of rows you have nodes (companies, people, events) and edges (supplies, owns, sanctioned, competes with), and once that structure exists, "what's downstream of this event" stops being a research project. You start at a node and walk outward.&lt;/p&gt;

&lt;p&gt;That's what I've been building: articles get parsed for entities and events, those get written into a graph store, and a question like "what's connected to this supplier three hops out" becomes something the graph itself can answer. It currently holds 84,962 nodes and 275,293 relationships, all extracted from those same articles.&lt;/p&gt;

&lt;p&gt;None of this is a novel idea. Graphs for multi-hop reasoning are well established. The part that hurt showed up one layer down, before any traversal logic even mattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that actually blocked me: the entities were wrong
&lt;/h2&gt;

&lt;p&gt;A traversal is only as good as the nodes it's walking. My first batches of extracted articles produced a mess I didn't expect to be the hard part: the same company kept landing in the graph as several disconnected nodes, because the articles never call it the same thing twice.&lt;/p&gt;

&lt;p&gt;Here's one company, exactly as the pipeline extracted it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Samsung"&lt;/li&gt;
&lt;li&gt;"Samsung Electronics"&lt;/li&gt;
&lt;li&gt;"Samsung Electronics Co."&lt;/li&gt;
&lt;li&gt;"Samsung Foundry"&lt;/li&gt;
&lt;li&gt;"Samsung's" (possessive, mid-sentence)&lt;/li&gt;
&lt;li&gt;"the Korean company" (a lazy second reference)&lt;/li&gt;
&lt;li&gt;"the largest memory chipmaker in the world" (same article, different lazy reference)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Seven surface forms, one real company. That last one is my favorite, I only caught it because a "company" node named an entire sentence looked absurd in the browser. Write each form into the graph as its own node and a query for everything connected to Samsung silently misses six of them. The causal chain I wanted literally can't form. The graph thinks it's looking at seven strangers.&lt;/p&gt;

&lt;p&gt;And the failure runs the other way too, which took me longer to appreciate. Another article mentioned "삼성SDI": Samsung SDI, a real, separately listed subsidiary that makes batteries, not chips. Merge that into the Samsung Electronics node and the graph starts lying. SDI's battery contracts become evidence about a semiconductor business, and every chain through that node inherits the false premise.&lt;/p&gt;

&lt;p&gt;So the bottleneck wasn't graph technology at all. It was entity resolution: given messy surface forms from unstructured text, decide which ones are the same real-world thing and which only look alike. Naive string matching fails both directions at once. "The largest memory chipmaker in the world" shares zero characters with "Samsung," and "Samsung SDI" is one string-edit away from a wrong merge. You need probabilistic matching over multiple signals, and a memory of what you've already resolved.&lt;/p&gt;

&lt;h2&gt;
  
  
  So I built it
&lt;/h2&gt;

&lt;p&gt;I ended up building a standalone entity resolution service rather than a one-off cleanup function, mostly because this exact problem had already bitten me in every project that merges data from more than one source. It's built on &lt;a href="https://github.com/moj-analytical-services/splink" rel="noopener noreferrer"&gt;Splink&lt;/a&gt;, the open source probabilistic record linkage library, wrapped in a FastAPI service. Feed it candidate mentions and it scores how likely two of them refer to the same entity, keeping a registry of resolved entities and known aliases so it gets better the more it sees. As of today the registry holds 47,853 resolved entities and 47,883 aliases. The Samsung example resolves correctly now: all seven mentions collapse into one node, Samsung SDI stays separate.&lt;/p&gt;

&lt;h2&gt;
  
  
  And it's live
&lt;/h2&gt;

&lt;p&gt;The resolver runs as a deployed cloud service, sitting in front of the graph pipeline. Every incoming article gets its entity mess cleaned there before anything touches the investing knowledge graph.&lt;/p&gt;

&lt;p&gt;If you're merging entities from more than one source, articles, KYC documents, supply chain records, customer files from separate systems, you will meet the Samsung problem eventually. That's what &lt;a href="https://hannune.ai" rel="noopener noreferrer"&gt;ER API&lt;/a&gt; is for. It's live and in beta. Next post: the resolver mechanics, how it decides "Samsung Foundry" and "the largest memory chipmaker in the world" are the same company without also swallowing Samsung SDI.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>python</category>
      <category>datascience</category>
    </item>
    <item>
      <title>Missed calls, missed revenue: the local-first phone assistant I'm building</title>
      <dc:creator>Tae Kim</dc:creator>
      <pubDate>Sat, 22 Aug 2026 02:34:47 +0000</pubDate>
      <link>https://dev.to/hannune/missed-calls-missed-revenue-the-local-first-phone-assistant-im-building-2086</link>
      <guid>https://dev.to/hannune/missed-calls-missed-revenue-the-local-first-phone-assistant-im-building-2086</guid>
      <description>&lt;p&gt;Previous posts in this series covered the infrastructure: &lt;a href="https://dev.to/hannune/running-three-ai-models-on-one-local-server-when-your-vram-doesnt-cover-all-of-them-b7g"&gt;running three AI models sequentially on one server&lt;/a&gt; and &lt;a href="https://dev.to/hannune/why-i-run-speech-to-text-locally-instead-of-calling-a-cloud-api-59j7"&gt;why I run speech-to-text locally instead of calling a cloud API&lt;/a&gt;. This one is about the business problem I'm building toward.&lt;/p&gt;

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

&lt;p&gt;I run a solo consulting practice. Client communication arrives over three channels: phone calls (including voicemails I check hours later), messaging apps, and email. None of these talk to each other.&lt;/p&gt;

&lt;p&gt;After a busy stretch, I found myself doing a manual audit: scrolling back through four or five apps, trying to reconstruct what commitments I'd made, who I hadn't responded to, what questions were still open. It's time-consuming and I kept missing things.&lt;/p&gt;

&lt;p&gt;The expensive miss is a phone call. Someone calls, leaves a voicemail, doesn't follow up in email. If I'm mid-task when the call comes in and don't process the voicemail until the next day, that lead has likely moved on. Industry data suggests small businesses miss 40–60% of incoming calls. I don't know exactly what my number is, but I've been that business.&lt;/p&gt;

&lt;p&gt;The tools I evaluated each handle one channel. Transcription services for calls. Email summary tools for inbox. Nothing I found reads all three and produces a single output. The integration gap is where the problem actually lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm building
&lt;/h2&gt;

&lt;p&gt;The goal is a daily summary: here's who contacted you, here's what they wanted, here's what's still open, here are the likely follow-ups.&lt;/p&gt;

&lt;p&gt;The infrastructure pieces are working individually. Whisper running locally handles call transcription—audio stays on my server, never goes to an external API. bge-m3 handles embeddings for semantic search. Gemma handles images and screenshots. All running sequentially on one box.&lt;/p&gt;

&lt;p&gt;What I'm building is the layer on top: intake → process → report.&lt;/p&gt;

&lt;p&gt;Two problems I've been figuring out this week:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. What the output should actually contain
&lt;/h3&gt;

&lt;p&gt;Before building the generator, I spent time designing the output format. A daily report isn't useful if it's just a list of transcripts. The items that actually need to surface:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Missed contacts: someone reached out and I haven't replied&lt;/li&gt;
&lt;li&gt;Commitments: explicit statements I made that need follow-through ("I'll send that over by Friday")&lt;/li&gt;
&lt;li&gt;Scheduling candidates: date/time mentions that might be pending appointments&lt;/li&gt;
&lt;li&gt;Open questions: things they asked that I said I'd look into&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The hard part wasn't picking the categories—it was making each one specific enough to actually implement. "Show me missed follow-ups" is ambiguous. "Flag any contact where they reached out and I have no outbound reply within 48 hours" is implementable. I went through the format item by item and rewrote vague categories as concrete conditions.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Linking the same person across channels
&lt;/h3&gt;

&lt;p&gt;If someone calls Monday and emails Tuesday, those land in two separate systems with no shared ID. To produce a per-contact summary, I need to link them.&lt;/p&gt;

&lt;p&gt;This is an entity resolution problem—the same class of problem I work on in my main client work, building ER pipelines for corporate data. The structure is identical: match records that don't share a unique identifier.&lt;/p&gt;

&lt;p&gt;For comms data, the linking fields are usually phone number or email address. Complications:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;People use multiple numbers (mobile, work, home)&lt;/li&gt;
&lt;li&gt;Email aliases (&lt;code&gt;john.smith@company.com&lt;/code&gt; and &lt;code&gt;jsmith@company.com&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Display names don't match across apps&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My current sketch: normalize phone numbers (strip formatting, expand country codes), match email with tolerance for common alias patterns, and handle name variants as a fallback signal rather than a primary match key. I haven't implemented this yet—this is still the design phase.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where things stand
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Working:&lt;/strong&gt; Local transcription (Whisper), local embeddings (bge-m3), local VLM (Gemma). All running sequentially on one server. Audio, documents, and images stay local.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Building:&lt;/strong&gt; The pipeline that takes call transcripts + email content + message content and produces the daily report. The output format is designed. The entity resolution approach is sketched. Nothing is integrated end to end.&lt;/p&gt;

&lt;p&gt;The gap between "pieces working individually" and "system producing something I'd actually use daily" is what I'm working through now. Next concrete step: get the report generator to produce output I'd actually want to read, not just a proof of concept that technically runs.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Previously in this series: &lt;a href="https://dev.to/hannune/running-three-ai-models-on-one-local-server-when-your-vram-doesnt-cover-all-of-them-b7g"&gt;Running three AI models on one local server&lt;/a&gt; | &lt;a href="https://dev.to/hannune/why-i-run-speech-to-text-locally-instead-of-calling-a-cloud-api-59j7"&gt;Why local STT instead of a cloud API&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>selfhosted</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Why I run speech-to-text locally instead of calling a cloud API</title>
      <dc:creator>Tae Kim</dc:creator>
      <pubDate>Tue, 18 Aug 2026 02:34:34 +0000</pubDate>
      <link>https://dev.to/hannune/why-i-run-speech-to-text-locally-instead-of-calling-a-cloud-api-59j7</link>
      <guid>https://dev.to/hannune/why-i-run-speech-to-text-locally-instead-of-calling-a-cloud-api-59j7</guid>
      <description>&lt;h1&gt;
  
  
  Why I run speech-to-text locally instead of calling a cloud API
&lt;/h1&gt;

&lt;p&gt;&lt;a href="https://dev.to/hannune/running-three-ai-models-on-one-local-server-when-your-vram-doesnt-cover-all-of-them-b7g"&gt;Yesterday I wrote about deploying gemma, bge-m3, and whisper on a single server without enough VRAM for all three.&lt;/a&gt; This post is about why whisper is one of those three.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with cloud STT
&lt;/h2&gt;

&lt;p&gt;When you call a cloud speech-to-text API—OpenAI's Whisper endpoint, Google Speech-to-Text, Amazon Transcribe—the audio travels to their servers. For most use cases that's fine. For mine it isn't.&lt;/p&gt;

&lt;p&gt;The project I'm building transcribes work calls. Work calls contain client names, project specifics, sometimes pricing discussions. Sending that audio to a vendor's inference endpoint means it travels over the network and gets processed on hardware I don't control.&lt;/p&gt;

&lt;p&gt;Running whisper locally means the audio file stays on the machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the setup actually looks like
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Local server: RTX 3060 (12 GB VRAM)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;faster-whisper&lt;/code&gt; in &lt;code&gt;int8&lt;/code&gt; quantized mode&lt;/li&gt;
&lt;li&gt;Audio files uploaded from a phone via a PWA, stored on the same machine, processed there&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A transcription call looks like this:&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;faster_whisper&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;WhisperModel&lt;/span&gt;

&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;WhisperModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;medium&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cuda&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;compute_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;int8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transcribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;recording.m4a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;beam_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&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;segment&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s] &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No API call. No auth header. No request log on a vendor's side. The audio file doesn't leave the machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The VRAM situation
&lt;/h2&gt;

&lt;p&gt;The 12 GB card is shared with two other models. Whisper &lt;code&gt;medium&lt;/code&gt; in int8 uses roughly 2.5–3 GB of VRAM when warm. That's workable as long as the models don't run at the same time.&lt;/p&gt;

&lt;p&gt;The three models load sequentially in my pipeline: transcribe → embed → analyze. Whisper runs first, which means it has the most headroom before the other two have loaded. When gemma is handling a VLM task and the card is under pressure, whisper drops to CPU. Slower—30–60 seconds per minute of audio instead of 8–12—but it completes without crashing.&lt;/p&gt;

&lt;p&gt;I didn't anticipate needing that fallback. It showed up during the first few real recordings when I hadn't fully worked out the load order yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't solve
&lt;/h2&gt;

&lt;p&gt;Local STT doesn't fix transcription accuracy automatically. Whisper &lt;code&gt;medium&lt;/code&gt; is good but not perfect. Names, domain-specific terms, and cross-talk all degrade it. I haven't run it on enough actual work calls to have a reliable accuracy number. On clean audio, 90%+. On a speakerphone with background noise, noticeably worse.&lt;/p&gt;

&lt;p&gt;It also doesn't solve the downstream pipeline. Right now I can transcribe a call and get a text file. The next piece—extracting structured information from that transcript, linking it to what came in over text and email—is what I'm working on now. That part isn't done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not a hosted Whisper endpoint?
&lt;/h2&gt;

&lt;p&gt;Hosted Whisper endpoints exist and some are cheap. If data residency doesn't matter, they're probably the right call: faster, no hardware to manage, no VRAM budgeting.&lt;/p&gt;

&lt;p&gt;The asymmetry is the point. A slower local run that keeps audio in-house is worth the tradeoff when the content of the calls is the thing you're trying to protect. The cost of audio leaving is not symmetric with the cost of running it locally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this fits
&lt;/h2&gt;

&lt;p&gt;I'm building a phone assistant that collects what comes in over calls, texts, and emails—and turns it into something useful instead of leaving it scattered across three different apps. Local transcription is the piece that's working. Everything past the transcript is still being built.&lt;/p&gt;

&lt;p&gt;If you've dealt with local Whisper deployment on shared GPU, curious what you found: do you bother with beam_size tuning, or does the accuracy delta not matter enough at inference time to be worth it?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://dev.to/hannune/running-three-ai-models-on-one-local-server-when-your-vram-doesnt-cover-all-of-them-b7g"&gt;Previous post in this series: Running three AI models on one local server when your VRAM doesn't cover all of them&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>selfhosted</category>
      <category>llm</category>
      <category>python</category>
    </item>
    <item>
      <title>Running three AI models on one local server when your VRAM doesn't cover all of them</title>
      <dc:creator>Tae Kim</dc:creator>
      <pubDate>Mon, 17 Aug 2026 02:49:37 +0000</pubDate>
      <link>https://dev.to/hannune/running-three-ai-models-on-one-local-server-when-your-vram-doesnt-cover-all-of-them-b7g</link>
      <guid>https://dev.to/hannune/running-three-ai-models-on-one-local-server-when-your-vram-doesnt-cover-all-of-them-b7g</guid>
      <description>&lt;p&gt;The first time I tried loading Whisper, bge-m3, and gemma at the same time on my local box, it OOMâ€™d immediately. Iâ€™d known this was going to happen, but I tried anyway to see where the ceiling actually was.&lt;/p&gt;

&lt;p&gt;The machine is a workstation I already had, enough VRAM for any single model but not three simultaneously. I had some options: get more hardware, split across multiple machines, or figure out a sequential loading pattern. I went with sequential because adding hardware or managing separate boxes felt like problems I didn't want to solve yet.&lt;/p&gt;

&lt;p&gt;Sequential loading means: load whatever model you need, use it, unload before moving on. Nothing runs in parallel. This was fine for my use case because the workloads don't actually overlap in time â€” I'vm not running embedding lookups while transcribing a recording. The load time overhead adds a few seconds per task. For Whisper, that's ~4 seconds on top of ~90 seconds of transcription for a 30-minute call, which I don't notice in practice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The bge-m3 decision took the most deliberation&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I started with &lt;code&gt;all-MiniLM-L6-v2&lt;/code&gt; for embeddings. It's small and fast. The issue was Korean/English mixed documents. I'm processing meeting summaries and reference docs that switch between the two, and MiniLM's cross-lingual similarity scores were unreliable enough that I was getting wrong nearest-neighbor results. bge-m3 handles cross-lingual matching better. It costs more VRAM and I had to drop the batch size from the default to stabilize it, but the accuracy difference on my actual data was clear.&lt;/p&gt;

&lt;p&gt;One thing I didn't fully account for: when bge-m3 and Whisper are both unloaded, the load time for whichever comes next varies. bge-m3 seems to be slower on first load than subsequent loads in the same session, probably something to do with model weights caching at the OS level. I haven't investigated this properly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Whisper was the easy pick&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;medium.en. I ran large once to compare on a client call recording and the accuracy improvement wasn't worth the extra VRAM cost for my use case. Transcription time for a 30-minute recording is under two minutes with medium. That's fast enough that I'm not sitting watching it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gemma was a coin flip between a few candidates&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I needed local image analysis for screenshots and scanned documents. I tested gemma, a couple of LLaVA variants, and MiniCPM-V on maybe 20 images from my actual use case. Counted how many each model described correctly. Gemma came out ahead on document-heavy images. That test was informal enough that a different set of images might have given different results, but I had to pick something.&lt;/p&gt;

&lt;p&gt;One thing I noticed later: gemma's processing time varies a lot by input image resolution. Standard screenshots are fast. A high-res photo from a phone camera takes noticeably longer. I still haven't profiled exactly what's happening there. If you're planning around latency, test with the actual image sizes you'll be using, not benchmarks from smaller inputs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where things stand&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The sequential loading setup is stable. Iâ€™ve been running it for a few weeks without the OOM errors from the first attempt. The models work individually and I have code that chains them in sequence for a given input set.&lt;/p&gt;

&lt;p&gt;What Iâ€™m still building is the layer that makes use of this: a pipeline that processes call recordings, emails, and messages together and produces something more useful than three separate outputs. The individual pieces are working. Connecting them into a coherent pipeline is the current work.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally posted at &lt;a href="https://hannune.ai/blog/deploying-gemma-bge-m3-whisper-one-server" rel="noopener noreferrer"&gt;hannune.ai&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>selfhosted</category>
      <category>machinelearning</category>
      <category>llm</category>
    </item>
    <item>
      <title>Blocking Key Design for Entity Resolution: Why Name Normalization Fails East Asian Corporate Data</title>
      <dc:creator>Tae Kim</dc:creator>
      <pubDate>Wed, 12 Aug 2026 02:40:31 +0000</pubDate>
      <link>https://dev.to/hannune/blocking-key-design-for-entity-resolution-why-name-normalization-fails-east-asian-corporate-data-914</link>
      <guid>https://dev.to/hannune/blocking-key-design-for-entity-resolution-why-name-normalization-fails-east-asian-corporate-data-914</guid>
      <description>&lt;p&gt;Entity resolution at scale starts with a problem most introductions skip: you can't compare every pair of records. A dataset with 500,000 entities has 125 billion possible pairs. At one millisecond per comparison, exhaustive comparison takes 1,400 days.&lt;/p&gt;

&lt;p&gt;Blocking reduces this to a tractable set. You partition records into blocks using one or more keys, and only compare records that share a key. The constraint: your blocking decision determines which true matches you'll never see. A match that falls into different blocks is a miss by design.&lt;/p&gt;

&lt;p&gt;Building the entity resolution layer for &lt;a href="https://2asy.ai" rel="noopener noreferrer"&gt;2asy.ai&lt;/a&gt; — a corporate intelligence knowledge graph for East Asian markets — blocking was where the approach broke down first, in a way that was hard to diagnose.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Initial Approach: Name Normalization
&lt;/h2&gt;

&lt;p&gt;The first blocking key was a normalized company name: strip punctuation, lowercase, remove common suffixes.&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;re&lt;/span&gt;

&lt;span class="n"&gt;COMMON_SUFFIXES&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;co., ltd.&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;co.,ltd.&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;co. ltd.&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;ltd.&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;inc.&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;corp.&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;corporation&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;co.&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;llc&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;plc&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;gmbh&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;주식회사&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;(주)&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;(사)&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;유한회사&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;株式会社&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;有限会社&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;合同会社&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;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;normalize_name&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;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;str&lt;/span&gt;&lt;span class="p"&gt;:&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;name&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="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&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;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&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;[^ws]&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="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&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;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&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;s+&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; &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;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;suffix&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;COMMON_SUFFIXES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;suffix&lt;/span&gt;&lt;span class="p"&gt;):&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;name&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;suffix&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;strip&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;name&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two records sharing a normalized name go into the same block. Simple, fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where It Failed
&lt;/h2&gt;

&lt;p&gt;The failure mode was Korean and Japanese company names. A company registered as "Samsung Electronics Co., Ltd." in a Korean regulatory filing might appear as "Samsung Electronics" in a Japanese document and "삼성전자" in a Korean tax filing.&lt;/p&gt;

&lt;p&gt;After normalization, those three forms produce different blocking keys:&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="nf"&gt;normalize_name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Samsung Electronics Co., Ltd.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# → "samsung electronics"
&lt;/span&gt;&lt;span class="nf"&gt;normalize_name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Samsung Electronics&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;             &lt;span class="c1"&gt;# → "samsung electronics"  ← same block
&lt;/span&gt;&lt;span class="nf"&gt;normalize_name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;삼성전자&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                          &lt;span class="c1"&gt;# → "삼성전자"  ← different block!
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Korean script name falls into a separate block. It's never compared. The correct merge never happens.&lt;/p&gt;

&lt;p&gt;The underlying issue: a blocking key derived from a single attribute assumes that field is stable across sources. It isn't. In East Asian corporate data, the company name field is the least stable field you have. It varies by language, script, legal suffix convention, transliteration standard, and filing jurisdiction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Composite Blocking with Multiple Key Strategies
&lt;/h2&gt;

&lt;p&gt;The approach that worked was generating multiple candidate keys per record and forming a candidate pair if they share &lt;em&gt;any&lt;/em&gt; 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;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Iterator&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate_blocking_keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&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="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Iterator&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="nb"&gt;str&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Yields (key_type, key_value) pairs for a company record.&lt;/span&gt;&lt;span class="sh"&gt;"""&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;record&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;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="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;normalized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalize_name&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;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;normalized&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;yield &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name_norm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;normalized&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;entity_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;record&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;entity_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;jurisdiction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;record&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;jurisdiction_code&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="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;entity_type&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;jurisdiction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;yield &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type_jurisdiction&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;entity_type&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;jurisdiction&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;romanized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;romanize&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;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;romanized&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;phonetic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;soundex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;romanized&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;yield &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;phonetic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;phonetic&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;reg_num&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;record&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;registration_number&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="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;reg_num&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;yield &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reg_prefix&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reg_num&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_candidate_pairs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&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;dict&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;set&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="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Return all candidate pairs sharing at least one blocking key.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;key_to_records&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="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&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;int&lt;/span&gt;&lt;span class="p"&gt;]]&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;idx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;records&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;key_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key_value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;generate_blocking_keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key_value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;key_to_records&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setdefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&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;idx&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="nb"&gt;set&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="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&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;indices&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;key_to_records&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&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;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;indices&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;j&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;indices&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
                &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;indices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;indices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&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="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&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;candidates&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A pair of records is a candidate if they share any key. This creates overlap — some pairs get compared more than once — but it eliminates the category of missed merges caused by name form variation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring the Impact
&lt;/h2&gt;

&lt;p&gt;Adding the phonetic key increased candidate pairs by about 40% while recovering merges we'd been systematically missing.&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;evaluate_blocking&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ground_truth_pairs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;blocking_fn&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="nf"&gt;blocking_fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;true_matches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ground_truth_pairs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;found&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;true_matches&lt;/span&gt;
    &lt;span class="n"&gt;missed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;true_matches&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt;
    &lt;span class="n"&gt;recall&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;found&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;true_matches&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;true_matches&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
    &lt;span class="n"&gt;reduction_ratio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;len&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="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&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;2&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;recall&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;recall&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;candidate_pairs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reduction_ratio&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;reduction_ratio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;missed_true_matches&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;missed&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Name-only blocking:   recall 0.73, candidate_pairs 42_000, missed 12_500
# Composite blocking:   recall 0.94, candidate_pairs 59_000, missed 2_800
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a corporate ownership graph, the cost of a false negative is high. Two nodes for the same company means ownership relationships assigned to one aren't visible when querying through the other. The silent failure is worse than the computational overhead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Blocking Key Design Doesn't End at Ship
&lt;/h2&gt;

&lt;p&gt;Blocking key design isn't a one-time decision. As source variety increases, keys that worked for the initial source set start missing merges from new sources.&lt;/p&gt;

&lt;p&gt;The monitoring approach: sample records that weren't merged, check manually whether they should have been, and track false negative rate over time. When it spikes, it usually means a new source has naming conventions that none of the current keys handle.&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;sample_unmerged_for_review&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&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;dict&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;merged_ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;sample_size&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;50&lt;/span&gt;&lt;span class="p"&gt;,&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;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;unmerged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;records&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;i&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;merged_ids&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;by_jurisdiction&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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&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;dict&lt;/span&gt;&lt;span class="p"&gt;]]&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;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;unmerged&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;jur&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&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;jurisdiction_code&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;UNK&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;by_jurisdiction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setdefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jur&lt;/span&gt;&lt;span class="p"&gt;,&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;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;flagged&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;jur&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;recs&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;by_jurisdiction&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;total_in_jur&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;records&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&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;jurisdiction_code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;jur&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;merge_rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;total_in_jur&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;merge_rate&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;total_in_jur&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;flagged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recs&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;10&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;flagged&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;sample_size&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The goal isn't zero false negatives — it's knowing which false negative rate your blocking tradeoffs are producing, and catching when that rate degrades without explanation.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I work on entity resolution and corporate knowledge graphs at &lt;a href="https://er-api.hannune.ai" rel="noopener noreferrer"&gt;er-api.hannune.ai&lt;/a&gt; and &lt;a href="https://2asy.ai" rel="noopener noreferrer"&gt;2asy.ai&lt;/a&gt;. If you're designing blocking keys for multilingual or multi-script data, curious what strategies you've tried.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>dataengineering</category>
      <category>graphs</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>The Retry Counter Said 7, The Config Said 3</title>
      <dc:creator>Tae Kim</dc:creator>
      <pubDate>Tue, 11 Aug 2026 02:58:04 +0000</pubDate>
      <link>https://dev.to/hannune/the-retry-counter-said-7-the-config-said-3-383l</link>
      <guid>https://dev.to/hannune/the-retry-counter-said-7-the-config-said-3-383l</guid>
      <description>&lt;p&gt;A LangGraph pipeline I was reviewing last year. Token costs were running over projection, slow runs were taking about 3x the normal time. Nothing was throwing errors.&lt;/p&gt;

&lt;p&gt;When I added a retry counter per step and ran a few slow cases through, the resolve step showed a count of 7. The config had &lt;code&gt;max_retries=3&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The discrepancy came from two retry layers that had been written independently and never asked to coordinate. The step-level nodes had retry logic. The orchestrator graph had a recovery edge that re-invoked subgraphs when steps returned an error state — and that edge was triggering twice on ambiguous failure modes before the orchestrator gave up. So the actual retry count for those steps was whatever the step-level config said, plus two orchestrator re-invocations.&lt;/p&gt;

&lt;p&gt;Once I had the counter, I could see it. Before that, I was looking at prompt quality and model selection because that's what the symptoms looked like.&lt;/p&gt;

&lt;p&gt;The instrumentation was pretty simple: a counter object passed to every node, checked before retrying, incremented after.&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;time&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;defaultdict&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RetryBudget&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;run_limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;step_limit&lt;/span&gt;&lt;span class="o"&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run_limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;run_limit&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;step_limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;step_limit&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_by_step&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;can_retry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;step&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;bool&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_total&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run_limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_by_step&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;step_limit&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;step&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="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_total&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_by_step&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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="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;total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;by_step&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_by_step&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;run_limit=12&lt;/code&gt; is meant to account for two layers interacting. If each step allows 3 retries and you have two layers that can both fire, you need the ceiling to be higher than 3 or the orchestrator layer will consume the whole budget on its first re-invoke. The team was using 12 which was basically 3 steps times 4 (step + orchestrator with some slack). It's not a principled number.&lt;/p&gt;

&lt;p&gt;The orchestrator's recovery edge went through the same budget object. That was the main structural change — before, the orchestrator had no visibility into how many retries the step level had already used, and the step level had no idea the orchestrator was going to re-invoke. Sharing one budget object between both makes the interaction visible.&lt;/p&gt;

&lt;p&gt;After a week of production data, &lt;code&gt;_by_step&lt;/code&gt; showed that the resolve step was consuming most of the budget on slow runs. Looking at those specific calls, the upstream API was returning 200 with malformed JSON on a particular input pattern, and the retries were doing nothing because the response was consistently broken for that input. We ended up fixing input normalization before the call, not the retry config. But we wouldn't have known which step to look at without seeing where the retries were going.&lt;/p&gt;




&lt;p&gt;The unresolved part is what to do when the run-level budget hits zero mid-run.&lt;/p&gt;

&lt;p&gt;This pipeline accumulates meaningful intermediate state by the time it gets to the downstream steps. Early steps do entity resolution, later ones do candidate ranking, and by step 6 or 7 there's significant useful work already done. Failing the whole run discards that. Returning whatever completed without noting that the run didn't finish is wrong because the consumer runs aggregations on top of multiple runs, and incomplete results fed into aggregation without flagging produce incorrect totals.&lt;/p&gt;

&lt;p&gt;What we ended up shipping was a result object with a &lt;code&gt;complete: bool&lt;/code&gt; field and a list of which steps didn't finish. The consumer is supposed to check it before using the data. In practice, the check is inconsistent. The &lt;code&gt;complete: False&lt;/code&gt; flag gets read in some call sites and ignored in others. The next thing we're considering is making the result type force the consumer to handle the incomplete case explicitly rather than just exposing a bool that can be ignored. Either a tagged union or a checked exception type. We haven't gotten there yet and I'm not certain it would actually fix the consistency problem even if we did.&lt;/p&gt;

&lt;p&gt;There's probably a version where this becomes a dead letter queue and a human reviews the incomplete runs. We haven't needed that yet.&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>python</category>
      <category>llm</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Why F1 Is the Wrong Objective for Entity Resolution Thresholds in Corporate Data</title>
      <dc:creator>Tae Kim</dc:creator>
      <pubDate>Thu, 06 Aug 2026 02:35:36 +0000</pubDate>
      <link>https://dev.to/hannune/why-f1-is-the-wrong-objective-for-entity-resolution-thresholds-in-corporate-data-4m9g</link>
      <guid>https://dev.to/hannune/why-f1-is-the-wrong-objective-for-entity-resolution-thresholds-in-corporate-data-4m9g</guid>
      <description>&lt;p&gt;Most entity resolution pipelines are tuned to maximize F1. For corporate data, that is the wrong objective.&lt;/p&gt;

&lt;p&gt;F1 treats false positives and false negatives as equally costly. In practice they are not even close.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;false negative&lt;/strong&gt; in corporate ER means you have two records that refer to the same company — a redundancy you can fix later by rerunning the pipeline with updated parameters. A &lt;strong&gt;false positive&lt;/strong&gt; means you merged two distinct companies into one entity. Every query that touches that node now returns blended facts from two unrelated organizations. The damage is structural and hard to detect because the merged entity looks valid. It has real attributes, real edges, real documents. Downstream systems have no signal that anything is wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Production Failure That Exposed This
&lt;/h2&gt;

&lt;p&gt;I ran into this building &lt;a href="https://er-api.hannune.ai" rel="noopener noreferrer"&gt;er-api&lt;/a&gt;, an entity resolution layer for trade and corporate registry data. The initial Splink model was tuned on an F1-maximizing grid search. It performed well on held-out test pairs. In production, it merged a South Korean steel manufacturer with a logistics subsidiary that shared part of its name and a city. Both entities were real, both had filings, and the combined record passed every sanity check that had been written for individual entities.&lt;/p&gt;

&lt;p&gt;The merged node looked completely valid to any downstream query:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Real attributes from both companies&lt;/li&gt;
&lt;li&gt;Edges connecting to real counterparties&lt;/li&gt;
&lt;li&gt;Trade documents that appeared to corroborate each other&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The error was invisible at the F1 level. It only surfaced when a user noticed that a steel exporter appeared to also own a refrigerated warehouse network.&lt;/p&gt;

&lt;p&gt;The fix was not a better model. It was a different threshold structure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three Changes That Changed the Behavior
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Split the decision space into three bands
&lt;/h3&gt;

&lt;p&gt;Instead of a single match/no-match threshold, use three bands:&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;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;enum&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ERDecision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;AUTO_MERGE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auto_merge&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;REVIEW_QUEUE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;review_queue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;AUTO_REJECT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auto_reject&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ThresholdBands&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;auto_merge_floor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.95&lt;/span&gt;   &lt;span class="c1"&gt;# above this: auto-merge
&lt;/span&gt;    &lt;span class="n"&gt;review_floor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.70&lt;/span&gt;       &lt;span class="c1"&gt;# between review_floor and auto_merge_floor: review queue
&lt;/span&gt;    &lt;span class="c1"&gt;# below review_floor: auto-reject
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;classify_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;probability&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bands&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ThresholdBands&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ERDecision&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;probability&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;bands&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;auto_merge_floor&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;ERDecision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AUTO_MERGE&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;probability&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;bands&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;review_floor&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;ERDecision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REVIEW_QUEUE&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;ERDecision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AUTO_REJECT&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bands were calibrated on &lt;strong&gt;domain-specific false positive cost&lt;/strong&gt;, not test set F1. The auto-merge zone is deliberately narrow because the penalty for being wrong there is high. Most entities that a naive F1-tuned model would auto-merge end up in the review queue under this structure.&lt;/p&gt;

&lt;p&gt;In practice: on a corpus of East Asian corporate entities, moving from a single threshold (0.85) to banded thresholds (0.95/0.70) reduced false positive rate by 73% with a 12% increase in review queue volume. The review queue increase is recoverable. The false positive reduction is not available at all under single-threshold tuning.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Require multiple blocking key agreement for auto-merge
&lt;/h3&gt;

&lt;p&gt;A high match probability on name similarity alone is insufficient for the auto-merge band. The model must also agree on at least one structural attribute:&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="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AutoMergeGuard&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    Name-only high-confidence matches route to review instead of auto-merge.
    At least one structural attribute must agree before auto-merge triggers.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;structural_keys&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;registration_number_prefix&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# first 6 chars of company reg number
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jurisdiction_code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;# ISO country + subdivision
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;address_locality&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;             &lt;span class="c1"&gt;# city-level normalized
&lt;/span&gt;    &lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;candidate_pair&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="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;name_sim&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;candidate_pair&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;name_similarity&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;name_sim&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;  &lt;span class="c1"&gt;# not name-only
&lt;/span&gt;
        &lt;span class="n"&gt;structural_agreement&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;candidate_pair&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;_match&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&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;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;structural_keys&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;structural_agreement&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This guard catches the class of error where two subsidiaries of different parent companies share a similar trading name in the same industry. Name similarity alone can push the Splink score above 0.95. The structural key requirement stops the auto-merge.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Track false positive rate separately
&lt;/h3&gt;

&lt;p&gt;F1 hides false positive rate behind a harmonic mean. Pull it out as a first-class metric:&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;datetime&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ThresholdMonitor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fp_rate_alert_threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.001&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;decisions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;confirmed_fp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;auto_merge_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fp_rate_alert_threshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fp_rate_alert_threshold&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;record_decision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ERDecision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;probability&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;decisions&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;decision&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;probability&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;probability&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;timestamp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;utcnow&lt;/span&gt;&lt;span class="p"&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;decision&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;ERDecision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AUTO_MERGE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;auto_merge_count&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;record_confirmed_false_positive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;confirmed_fp&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

    &lt;span class="nd"&gt;@property&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;false_positive_rate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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;float&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;auto_merge_count&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;confirmed_fp&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;auto_merge_count&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;is_alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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;bool&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;false_positive_rate&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fp_rate_alert_threshold&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The alert threshold matters here. A threshold that stays at 0.1% false positive rate on a corpus of millions of entity pairs produces recoverable errors — the review team can handle the correction volume. At 1%, the review queue cannot keep up and bad merges accumulate faster than they can be fixed. The graph degrades quietly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Principle
&lt;/h2&gt;

&lt;p&gt;Optimizing for F1 is the right starting point for model selection. It is the wrong objective for threshold deployment.&lt;/p&gt;

&lt;p&gt;In domains where a wrong merge is harder to reverse than a missed merge — corporate registries, medical records, financial entity graphs — the calibration step needs its own cost model. The cost model inputs are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Estimated effort to detect a bad merge (can be zero if you have no downstream audit)&lt;/li&gt;
&lt;li&gt;Estimated effort to correct a bad merge (graph surgery, downstream cache invalidation, restatement)&lt;/li&gt;
&lt;li&gt;Estimated throughput of the review queue (determines how much to shift from auto-merge to review)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;F1 assumes a symmetric cost. Most production entity graphs don't have a symmetric cost. The threshold structure should reflect that.&lt;/p&gt;




&lt;p&gt;I'm building &lt;a href="https://er-api.hannune.ai" rel="noopener noreferrer"&gt;er-api&lt;/a&gt; as an entity resolution layer for East Asian corporate registry and trade document data. The asymmetric cost model described here is live in production. If you're running ER pipelines on corporate data and hitting similar false positive issues, I'm happy to compare notes.&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>dataengineering</category>
      <category>python</category>
      <category>graphdatabases</category>
    </item>
  </channel>
</rss>
