5 Source Deduplication Fixes for Semantic Listing Retrieval in Node.js (and What I Kept) A developer building a healthtech provider directory that ingests clinic listings from four feeds described collapsing duplicate records into a single canonical entity before embedding, rather than filtering duplicates after retrieval. The approach hashes national identifiers such as NPI numbers along with normalized phone and street data into an entity id, uses postal-code and specialty blocking for similarity matching on records lacking identifiers, and upserts one vector per clinic so pipeline re-runs overwrite instead of append. The developer argued the ordering keeps index cost tied to real clinics rather than feed count and preserves the full query budget for distinct results. A provider directory that ingests clinic listings from four feeds will hand you the same clinic four times. Use source deduplication as a pre-embedding step instead of a post-retrieval filter: collapse duplicate listing records into one canonical entity, embed that one, and keep every contributing source id on its metadata so a bad semantic retrieval result can be traced back to the feed that produced it. Everything below is a variation on that single decision, including the two fixes I kept and the ones I would drop first. Index cost is why the ordering matters. Say you aggregate a healthtech provider directory from a state license registry, two payer network APIs, and a nightly crawl of hospital sites. Four sources, one real-world clinic, four rows that disagree about the suite number and whether the practice name carries "PC" at the end. Embed the rows as they arrive and you pay for four vectors, four rows of storage, and — the part that actually hurts — four slots of a ten-result query budget, three of which say the same thing. Deduplicate first and the index tracks the number of real clinics, which grows far more slowly than the number of feeds you sign. Most retrieval walkthroughs are explained with one clean corpus, which is the case that never happens in aggregation. The same shape shows up in real-estate listing discovery, where a single unit gets syndicated to three portals with three photo sets and three descriptions. Different industry. Identical failure. Fuzzy matching is where everyone starts, and it's the wrong first move. Registry feeds in healthcare carry an NPI number; that is a national identifier, and two rows sharing one are the same practitioner by definition. Normalized phone and normalized street line cover most of the rest. Hash those into an entity id, group by it, and a large share of your duplicates disappear for the price of a string comparison. What's left after exact keys is the interesting part — the crawl rows with no identifier at all. Those need similarity matching, but only inside a block: same postal code, same specialty. Blocking keeps the comparison count linear-ish instead of quadratic, and it also keeps you from merging two genuinely different practices that happen to share a building. I've seen a naive address match fold an entire medical office park into one entity, which is a worse failure than a duplicate, because a duplicate is visible and a bad merge is not. It changes what a record is . Before you touch an endpoint, write down the retrieval contract: the unit is one real-world clinic, not one feed row; the filters are specialty, accepted plan, and postal region; freshness means a delisted practice leaves the collection within a day. That contract is also the troubleshooting tool. When a search fails, map the user-visible answer back to the contract and check which clause broke. The clinic never got indexed, so the ingest filter is wrong. The clinic is indexed but ranks below three copies of itself, so deduplication is wrong. The clinic is indexed, ranks first, and closed in March, so the delete path is wrong. Three very different bugs, and without the contract they all present identically as "search is broken." Text you embed should describe the entity, not the feed. I concatenate canonical name, specialty, and a plain-language service line, then leave the payer list and the license status in metadata where a filter can reach them. Vector similarity is bad at "accepts this plan" and a WHERE clause is excellent at it. The entity id is doing double duty here. It's a merge key at collapse time and an upsert id at index time, which means a re-run of the pipeline overwrites rather than appends — and a crashed job at 3am costs you a retry instead of a duplicated collection. // index-providers.ts — collapse listings by entity, then upsert one vector per clinic. import { createHash } from "node:crypto"; // Base URL and key both come from the environment — never hardcode a credential. const BASE = process.env.INFRAI API BASE; const KEY = process.env.INFRAI API KEY; if BASE || KEY throw new Error "INFRAI API BASE and INFRAI API KEY must be set" ; type Listing = { source: string; npi: string | null; name: string; street: string; zip: string; specialty: string; plans: string ; }; const norm = s: string = s.toLowerCase .replace / ^a-z0-9 +/g, " " .trim ; function entityId l: Listing : string { const key = l.npi ? npi:${l.npi} : addr:${norm l.name }|${norm l.street }|${l.zip} ; return createHash "sha1" .update key .digest "hex" .slice 0, 24 ; } function collapse rows: Listing : Map