<?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: Saito</title>
    <description>The latest articles on DEV Community by Saito (@saito1).</description>
    <link>https://dev.to/saito1</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%2F4025272%2F542e9941-dc18-4f5e-8103-8d8e1389eb6c.jpg</url>
      <title>DEV Community: Saito</title>
      <link>https://dev.to/saito1</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/saito1"/>
    <language>en</language>
    <item>
      <title>Cosine similarity finds the topic, not the buyer</title>
      <dc:creator>Saito</dc:creator>
      <pubDate>Sat, 11 Jul 2026 14:41:00 +0000</pubDate>
      <link>https://dev.to/saito1/cosine-similarity-finds-the-topic-not-the-buyer-2pf6</link>
      <guid>https://dev.to/saito1/cosine-similarity-finds-the-topic-not-the-buyer-2pf6</guid>
      <description>&lt;h1&gt;
  
  
  Cosine similarity finds the topic, not the buyer
&lt;/h1&gt;

&lt;p&gt;I build a small tool called Leadgram that searches public Telegram groups for people who need what you sell. Up front, so nobody has to guess: it's my product, it's in beta, and this post is about the one problem that ate most of the build time.&lt;/p&gt;

&lt;p&gt;It wasn't the search. The search was the easy half.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two messages
&lt;/h2&gt;

&lt;p&gt;Same Telegram group, same afternoon:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;anyone got a CRM discount code?&lt;/p&gt;

&lt;p&gt;we keep losing deals because our pipeline is a mess and nobody updates anything. there has to be a better way&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you sell a CRM, the second person is your buyer. They named a pain, they're frustrated, they're actively looking for a way out. The first one is hunting a coupon for a tool they already own and are perfectly happy with.&lt;/p&gt;

&lt;p&gt;The task: build something that returns the second message and not the first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt 1: keywords
&lt;/h2&gt;

&lt;p&gt;This is what every existing tool in this space does, and it's obvious why it fails. The buyer never types the word "CRM." A keyword alert for &lt;code&gt;CRM&lt;/code&gt; returns the coupon hunter with 100% confidence and misses the person with the actual problem entirely.&lt;/p&gt;

&lt;p&gt;Worse, keyword lists rot. You can sit there for an afternoon writing out &lt;code&gt;pipeline&lt;/code&gt;, &lt;code&gt;deal flow&lt;/code&gt;, &lt;code&gt;spreadsheet hell&lt;/code&gt;, &lt;code&gt;sales tracking&lt;/code&gt; — and the next buyer will phrase it in a way you didn't think of, because people don't consult your keyword list before typing.&lt;/p&gt;

&lt;p&gt;The word isn't the signal. Move on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt 2: embeddings
&lt;/h2&gt;

&lt;p&gt;The natural next move. Embed every message, embed the query, rank by cosine similarity. Maybe thirty lines of code. It feels like it should just work.&lt;/p&gt;

&lt;p&gt;It doesn't, and the way it fails is the interesting part.&lt;/p&gt;

&lt;p&gt;Query: &lt;em&gt;"someone frustrated with their CRM and looking to switch"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Top results looked roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0.89  great thread on CRM migration strategies, saving this
0.87  we moved off HubSpot last year, happy to answer questions
0.86  honestly CRMs are all bloated garbage
0.84  does anyone have a good CRM comparison spreadsheet?
...
0.71  we keep losing deals because our pipeline is a mess...   ← the buyer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at what happened. Every single one of those top hits &lt;em&gt;is about&lt;/em&gt; CRM frustration. Cosine similarity did its job perfectly. The embedding model is not broken.&lt;/p&gt;

&lt;p&gt;The problem is that &lt;strong&gt;the thing I actually want isn't semantic similarity.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What I want is a property of the speaker's &lt;em&gt;situation&lt;/em&gt;: do they have this problem, right now, unsolved, and are they moving on it? Embedding space does not encode that. Consider four sentences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"I had this problem."&lt;/li&gt;
&lt;li&gt;"I have this problem."&lt;/li&gt;
&lt;li&gt;"Someone should build a solution to this problem."&lt;/li&gt;
&lt;li&gt;"I'm buying a solution to this problem on Monday."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They land in nearly the same neighbourhood. The vector captures the &lt;em&gt;topic&lt;/em&gt;. It has no opinion about tense, stake, agency, or timeline — and those four things are the entire difference between a lead and a bystander.&lt;/p&gt;

&lt;p&gt;There's a second problem stacked on top. A Telegram group is not a corpus of thoughtful statements. It's roughly this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gm
gm ☕
+1
anyone catch the game
😂😂😂
[sticker]
is this group still active?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ninety-something percent of it is social noise. Embed all of it and you don't get a search index, you get a very expensive way to retrieve the word "gm".&lt;/p&gt;

&lt;p&gt;So there are really two jobs, and I'd been trying to do them with one tool:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Throw away the garbage.&lt;/strong&gt; Most messages aren't signal of any kind.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Among what's left, separate the people who &lt;em&gt;have&lt;/em&gt; the problem from the people who are merely &lt;em&gt;talking about&lt;/em&gt; it.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The numbers, honestly
&lt;/h2&gt;

&lt;p&gt;Twenty groups. Around 100,000 messages. That is the entire index.&lt;/p&gt;

&lt;p&gt;That's small, and I'd rather say so than round it up to "millions." It also turned out to be the most useful property the thing has: at 100k I can actually read the failures. Every change to scoring gets checked against a set I hand-labelled myself, by eye, one message at a time. You lose that the moment you scale, and I don't think I'd have found the problem above without it.&lt;/p&gt;

&lt;p&gt;Stack: Rust for ingest and API, Postgres for metadata, ChromaDB for the vector layer, TypeScript on the front end.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I don't get to skip
&lt;/h2&gt;

&lt;p&gt;The messages get there because a Telegram account joins public groups and reads what's posted in them. That's scraping. I'm going to call it scraping, because half this category insists it's doing something else and it's tiresome.&lt;/p&gt;

&lt;p&gt;What I think is worth defending isn't the collection — it's what happens after. A scraped list of everyone who typed "CRM" is worthless and a bit grim. A short list of people who genuinely asked for help, each with the sentence that made them match attached so you can check the machine's reasoning yourself, is a different object. The filter is the product. The scraper is plumbing, and it's the plumbing everyone else ships as the product.&lt;/p&gt;

&lt;p&gt;And yes: this is a tool that can be used to cold-message strangers about things they said in public. I don't have a clean answer to that. The position I've settled on is that it reads public group messages only — never DMs, never anyone's account — and that if you're in an indexed group and want your messages gone, you email me and they're gone. &amp;lt;!-- ЗАМЕНИ на реальный адрес --&amp;gt; That's a person, not a policy page.&lt;/p&gt;

&lt;p&gt;If you think that's insufficient, I'd rather hear it than not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;Start with the noise filter, not the search. I spent weeks tuning retrieval over a corpus that was 95% "gm", which is a bit like polishing a lens while the cap is on.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write this stuff up as I go. The full workflow this turned into is &lt;a href="https://leadgram.io/en/resources/playbook" rel="noopener noreferrer"&gt;here&lt;/a&gt;, and the thing itself is &lt;a href="https://leadgram.io/en" rel="noopener noreferrer"&gt;Leadgram&lt;/a&gt; — beta, free while it lasts, and small enough that I'll personally read your bug report.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>ai</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
