<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>imtoanle</title><description>Toan Le on production software: architecture, data-heavy systems and AI workflows. Posts, notes and experiments.</description><link>https://imtoanle.com/</link><item><title>Elasticsearch for search, ClickHouse for analytics</title><link>https://imtoanle.com/blog/elasticsearch-for-search-clickhouse-for-analytics/</link><guid isPermaLink="true">https://imtoanle.com/blog/elasticsearch-for-search-clickhouse-for-analytics/</guid><description>Why I kept a working search engine and added a second datastore only for the workload that needed it, on a solo-built B2B search platform.</description><pubDate>Tue, 27 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;From October 2025 to October 2026 I built a B2B search platform on my own: contact discovery and lead generation over hundreds of millions of searchable records. I designed it end to end, and I also deployed and operated it. That last part shaped every architecture decision, because any component I added was one I’d be running.&lt;/p&gt;
&lt;p&gt;This post is about one of those decisions: which datastore does what.&lt;/p&gt;
&lt;h2 id=&quot;two-workloads-that-look-alike&quot;&gt;Two workloads that look alike&lt;/h2&gt;
&lt;p&gt;The product asks two kinds of questions of the same data.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Interactive search and filtering.&lt;/strong&gt; A user narrows a large dataset by indexed fields and expects results while they’re still looking at the screen.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Analytics and reporting.&lt;/strong&gt; Aggregations, rankings, grouped counts and reporting scans over the same records.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Both are “queries over records”, which makes it tempting to answer both from one place. But they optimise for different things. Search wants fast discovery over indexed fields. Analytics wants to scan and aggregate many rows cheaply.&lt;/p&gt;
&lt;h2 id=&quot;what-was-already-working&quot;&gt;What was already working&lt;/h2&gt;
&lt;p&gt;Elasticsearch already handled the core search and filtering workload, and handled it well enough. The dataset was large, but large by itself wasn’t a reason to replace anything.&lt;/p&gt;
&lt;p&gt;That matters, because the easy story here would be “Elasticsearch couldn’t cope, so I brought in ClickHouse”. That isn’t what happened, and I’d rather not tell it that way.&lt;/p&gt;
&lt;h2 id=&quot;the-decision&quot;&gt;The decision&lt;/h2&gt;
&lt;p&gt;I kept the boundary simple:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark-dimmed&quot; style=&quot;--shiki-light:#24292e;--shiki-dark:#adbac7;--shiki-light-bg:#fff;--shiki-dark-bg:#22272e; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Elasticsearch → interactive search / filtering&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;ClickHouse    → analytics / aggregation / ranking / reporting&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Elasticsearch stays wherever the existing search and index model is enough. ClickHouse is used only for analytical access patterns that justify an extra pipeline. Its column-oriented storage suits aggregation and ranking, and its cost profile for those workloads was better. I’m keeping that cost point qualitative, since I don’t have a retained benchmark to put a number on it.&lt;/p&gt;
&lt;h2 id=&quot;what-i-didnt-do&quot;&gt;What I didn’t do&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Keep everything in Elasticsearch.&lt;/strong&gt; This was a real option. One datastore is simpler to run, and Elasticsearch can do aggregations. I chose the split because analytics was a distinct enough responsibility to deserve its own path, and because it kept reporting concerns from shaping the search design. It wasn’t because Elasticsearch couldn’t do it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Replace Elasticsearch with ClickHouse.&lt;/strong&gt; This would have mixed a storage migration into an analytics decision, to solve a search problem I didn’t have.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;One generic datastore for everything.&lt;/strong&gt; Minimising the number of technologies is a good default, but it shouldn’t be the guiding rule when two workloads clearly differ.&lt;/p&gt;
&lt;h2 id=&quot;what-the-second-datastore-costs&quot;&gt;What the second datastore costs&lt;/h2&gt;
&lt;p&gt;A second datastore is not free, and on a solo project the person paying was me:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;data has to be projected into an analytical shape and kept moving;&lt;/li&gt;
&lt;li&gt;schema changes may need coordinating across the search and analytics projections;&lt;/li&gt;
&lt;li&gt;freshness expectations have to be explicit, since the two can lag each other;&lt;/li&gt;
&lt;li&gt;there’s more to monitor, deploy and recover when something fails.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So the rule I took from this isn’t “two databases are better than one”. It’s that a second datastore is justified when the workload boundary is worth its operating cost. Here it was. On a smaller dataset, or with lighter reporting, I’d have stayed with one.&lt;/p&gt;
&lt;h2 id=&quot;notes&quot;&gt;Notes&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;The product is anonymised on purpose, so I call it “a B2B search platform” and describe its scale only in round terms.&lt;/li&gt;
&lt;li&gt;This is a reconstructed decision record, written after the fact from my own production ownership. It shows how I reasoned, but it isn’t an independent audit.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I keep the general version of this rule in the garden: &lt;a href=&quot;https://imtoanle.com/garden/datastore-per-access-pattern/&quot; class=&quot;wikilink&quot;&gt;A second datastore has to earn its operating cost&lt;/a&gt;.&lt;/p&gt;
</content:encoded><category>architecture</category><category>elasticsearch</category><category>clickhouse</category><category>case-study</category></item><item><title>An AI-first OCR pipeline, with people for the exceptions</title><link>https://imtoanle.com/blog/ai-first-ocr-with-exception-review/</link><guid isPermaLink="true">https://imtoanle.com/blog/ai-first-ocr-with-exception-review/</guid><description>Turning a manual document workflow into automated intake, classification, extraction and validation on Cloudflare Workers, with human review only where automation fails.</description><pubDate>Tue, 06 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;This project took a document workflow for vehicle dossiers that was done almost entirely by hand and made it automated by default.&lt;/p&gt;
&lt;h2 id=&quot;before&quot;&gt;Before&lt;/h2&gt;
&lt;p&gt;The core flow was manual:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;receive images and documents;&lt;/li&gt;
&lt;li&gt;classify them;&lt;/li&gt;
&lt;li&gt;type in or extract the fields;&lt;/li&gt;
&lt;li&gt;check the data;&lt;/li&gt;
&lt;li&gt;prepare the submission.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Every step depended on a person, so every document waited for one.&lt;/p&gt;
&lt;h2 id=&quot;after&quot;&gt;After&lt;/h2&gt;
&lt;p&gt;I built the workflow end to end, along with the review tooling:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Intake from two places.&lt;/strong&gt; Submissions arrive through a Zalo webhook, and staff can also create records in the admin.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Automatic classification&lt;/strong&gt; of each document.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;OCR field extraction.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Duplicate detection&lt;/strong&gt;, so the same document doesn’t get processed twice.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Validation rules&lt;/strong&gt; that check the extracted data.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Exception review&lt;/strong&gt; for anything automation couldn’t handle.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Submission package generation&lt;/strong&gt; at the end.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;ai-first-with-people-for-the-exceptions&quot;&gt;AI-first, with people for the exceptions&lt;/h2&gt;
&lt;p&gt;People don’t check every document before it moves on. They aren’t removed from the process either.&lt;/p&gt;
&lt;p&gt;Documents are classified and extracted automatically. A person steps in only when automated processing fails or needs correcting. Human attention goes to the cases that need it, not to re-checking work that’s already right.&lt;/p&gt;
&lt;p&gt;I’d describe it as AI-first with exception-based human review. It isn’t human-first, and it isn’t fully autonomous: the review step is where failed or wrong extractions get corrected.&lt;/p&gt;
&lt;h2 id=&quot;the-stack&quot;&gt;The stack&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Cloudflare Workers&lt;/strong&gt; for the application and processing steps;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Queues&lt;/strong&gt; to move documents through the stages;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;D1&lt;/strong&gt; for structured data;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;R2&lt;/strong&gt; for the images and files;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;React&lt;/strong&gt; for the admin and review tools.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Queues suit a staged pipeline like this one, where each document moves from intake through classification, extraction and validation.&lt;/p&gt;
&lt;h2 id=&quot;what-i-cant-tell-you&quot;&gt;What I can’t tell you&lt;/h2&gt;
&lt;p&gt;I have no published accuracy rate, throughput figure, document volume or time-saved number for this project. Without them, the honest claim is about the shape of the workflow: a manual pipeline became an automated one, with human review as the exception path. Any percentage I quoted would be one I made up.&lt;/p&gt;
&lt;p&gt;The general idea is in the garden: &lt;a href=&quot;https://imtoanle.com/garden/exception-based-human-review/&quot; class=&quot;wikilink&quot;&gt;Exception-based human review&lt;/a&gt;.&lt;/p&gt;
</content:encoded><category>ai</category><category>cloudflare</category><category>workflow</category><category>case-study</category></item><item><title>Reputation scoring over 64 million wallets</title><link>https://imtoanle.com/blog/reputation-scoring-64-million-wallets/</link><guid isPermaLink="true">https://imtoanle.com/blog/reputation-scoring-64-million-wallets/</guid><description>Backend, data and infrastructure work on Octan Network’s on-chain reputation scoring, where indexing went from hours to minutes. What I can say about it, and how sure I am.</description><pubDate>Tue, 15 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;From September 2022 to January 2025 I worked at Octan Network as a blockchain developer, building backend services and internal tools in Go and TypeScript. The project I’m writing about here is reputation scoring: ranking on-chain wallets and serving analytics about them.&lt;/p&gt;
&lt;p&gt;The project’s repository is public: &lt;a href=&quot;https://github.com/Octan-Labs/Reputation-scoring&quot;&gt;Octan-Labs/Reputation-scoring&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;my-part&quot;&gt;My part&lt;/h2&gt;
&lt;p&gt;I didn’t own all of Octan, and this isn’t a “I built everything” post. On this project my scope was:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;database design;&lt;/li&gt;
&lt;li&gt;backend APIs;&lt;/li&gt;
&lt;li&gt;the ClickHouse analytical workloads;&lt;/li&gt;
&lt;li&gt;deployment, on Kubernetes and AWS;&lt;/li&gt;
&lt;li&gt;performance optimisation.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The data stack combined TimescaleDB and ClickHouse.&lt;/p&gt;
&lt;h2 id=&quot;the-scale&quot;&gt;The scale&lt;/h2&gt;
&lt;p&gt;Production data processing scanned approximately &lt;strong&gt;64 million wallets&lt;/strong&gt;. That’s the total number of wallets scanned. It isn’t a count of users or customers, or of wallets indexed at any one moment. Blockchain numbers get inflated easily, so I want to be precise about what this one means.&lt;/p&gt;
&lt;h2 id=&quot;why-clickhouse&quot;&gt;Why ClickHouse&lt;/h2&gt;
&lt;p&gt;Reputation scoring is mostly aggregation and ranking: summarise a lot of activity per wallet, then order wallets against each other. Those are analytical workloads, and ClickHouse fits them well. There was also pressure on infrastructure cost, and ClickHouse helped there too. I’m keeping that benefit qualitative because I don’t have a cost figure I can stand behind.&lt;/p&gt;
&lt;h2 id=&quot;the-results&quot;&gt;The results&lt;/h2&gt;
&lt;p&gt;After the optimisation work, roughly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;indexing&lt;/strong&gt; went from hours to minutes;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;search&lt;/strong&gt; went from minutes to under five seconds.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These were production observations, not a controlled benchmark. I no longer have the logs or screenshots, so I give them as approximate first-person results and nothing more precise. If I quoted “a 37× speed-up” you’d be right to ask where the number came from, and I wouldn’t have an answer.&lt;/p&gt;
&lt;h2 id=&quot;what-i-took-from-it&quot;&gt;What I took from it&lt;/h2&gt;
&lt;p&gt;When one workload is mostly aggregation and ranking, give it a store built for that, rather than stretching the transactional or search side to cover it. I applied the same thinking later on a B2B search platform: &lt;a href=&quot;https://imtoanle.com/blog/elasticsearch-for-search-clickhouse-for-analytics/&quot; class=&quot;wikilink&quot;&gt;Elasticsearch for search, ClickHouse for analytics&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The general rule, and when &lt;em&gt;not&lt;/em&gt; to follow it, is in &lt;a href=&quot;https://imtoanle.com/garden/datastore-per-access-pattern/&quot; class=&quot;wikilink&quot;&gt;A second datastore has to earn its operating cost&lt;/a&gt;.&lt;/p&gt;
</content:encoded><category>data</category><category>clickhouse</category><category>kubernetes</category><category>case-study</category></item><item><title>Moving parts of a Rails monolith to Go, not all of it</title><link>https://imtoanle.com/blog/rails-monolith-selected-go-services/</link><guid isPermaLink="true">https://imtoanle.com/blog/rails-monolith-selected-go-services/</guid><description>Leading a 5–7 engineer team at JobReady: we extracted selected capabilities into Go services instead of rewriting the application.</description><pubDate>Tue, 25 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I worked at JobReady as a Ruby developer from November 2014 to October 2019, and as technical leader for a team of 5–7 engineers from July 2017 to October 2019. The work was workflow-heavy business software in Ruby on Rails: features, data changes, background processing, integrations and production support.&lt;/p&gt;
&lt;h2 id=&quot;the-leadership-part&quot;&gt;The leadership part&lt;/h2&gt;
&lt;p&gt;“Tech lead” means different things in different companies, so here’s what it meant there:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;making architecture decisions, and owning the database design that came with them;&lt;/li&gt;
&lt;li&gt;estimating work;&lt;/li&gt;
&lt;li&gt;reviewing code;&lt;/li&gt;
&lt;li&gt;mentoring engineers;&lt;/li&gt;
&lt;li&gt;planning releases.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That’s technical leadership, not people management. I’m not claiming I hired or managed anyone.&lt;/p&gt;
&lt;h2 id=&quot;the-problem&quot;&gt;The problem&lt;/h2&gt;
&lt;p&gt;Parts of the Rails monolith had become bottlenecks. We wanted certain capabilities to be faster and more reliable, and to be deployable on their own instead of only as part of the whole application.&lt;/p&gt;
&lt;h2 id=&quot;the-tempting-answer&quot;&gt;The tempting answer&lt;/h2&gt;
&lt;p&gt;When a monolith hurts, “rewrite it in something faster” is always on the table. A full rewrite also means running two systems for a long time and re-discovering business rules that only exist in the old code.&lt;/p&gt;
&lt;p&gt;We didn’t do that.&lt;/p&gt;
&lt;h2 id=&quot;what-we-did&quot;&gt;What we did&lt;/h2&gt;
&lt;p&gt;We extracted &lt;strong&gt;selected capabilities&lt;/strong&gt; into Go services and left the rest in Rails. Something moved out of the monolith only if it was causing pain:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Performance:&lt;/strong&gt; the capability was a bottleneck.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reliability:&lt;/strong&gt; it needed to be more dependable than it was inside the monolith.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Independent deployment:&lt;/strong&gt; it needed to ship on its own schedule.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Everything else stayed in Rails, where the domain logic already lived.&lt;/p&gt;
&lt;h2 id=&quot;what-changed&quot;&gt;What changed&lt;/h2&gt;
&lt;p&gt;The extracted capabilities got faster and more reliable, and could be deployed on their own. I don’t have numbers I can publish, so I’ll leave it at that rather than make some up.&lt;/p&gt;
&lt;h2 id=&quot;the-rule&quot;&gt;The rule&lt;/h2&gt;
&lt;p&gt;The rule was “move what hurts, keep what works”. Each extraction had a specific reason behind it, which made it easy to explain and review, and the rest of the application kept running as it was.&lt;/p&gt;
&lt;p&gt;The general version of this lives in the garden: &lt;a href=&quot;https://imtoanle.com/garden/extract-dont-rewrite/&quot; class=&quot;wikilink&quot;&gt;Extract what hurts, keep what works&lt;/a&gt;.&lt;/p&gt;
</content:encoded><category>architecture</category><category>rails</category><category>go</category><category>leadership</category><category>case-study</category></item></channel></rss>