The more enrichment I added, the more often my records came out worse. That was the pattern, and it took me a while to believe it.
Nothing crashed. No errors. A city would flip to somewhere nearby. A state would "correct" itself. A phone number would show up, and it was clearly the corporate HQ line rather than the local office. Every one of those edits looked reasonable at a glance, which is exactly the kind of "helpfulness" that sneaks past review.
This is Part 3 of "How to Architect an Enterprise AI System (And Why the Engineer Still Matters)". In Part 2, I showed why I accept variance upstream and then validate hard downstream. This post is the other half of that idea: when you enrich data from several sources, you need a system that can accumulate truth without regressing it.
The core decision: a six-tier enrichment chain with per-field provenance tracking. Each tier is allowed to fill blanks. None of them is allowed to overwrite a better source.
The key insight (and why the naive approach fails)
Single-source enrichment is seductive because it's clean: one API call, one response, one "truth." It's also how you end up with 30–40% data gaps in production, because no single provider returns everything you need, consistently, across the messy variety of real emails and real companies.
So the obvious fix is to add more sources.
That's where it breaks.
Merge dictionaries in the order you happen to call the APIs and you've built a system that can't tell the difference between:
- a scraped street address from a web source, and
- a location inferred from a phone area code approximation.
Both are "values." One is a fact, the other is a guess. If you don't encode that distinction somewhere, your pipeline will happily overwrite facts with guesses and call it an improvement.
My solution is deliberately unclever. Every field gets provenance, and every source gets a numeric priority. Lower-priority sources can fill missing fields. They can't touch a field a higher-priority source already wrote.
That's the engineer's job here: not "add AI," but building the rules that stop the system from lying to itself.
How the chain works under the hood
The research node in my LangGraph pipeline runs an enrichment priority chain:
- Firecrawl enrichment (web scrape)
- Bing Search
- Domain variation retry (with a failed-domain cache)
- Serper API
- Azure Maps POI
- Phone area code approximation
Two design choices make it reliable:
- A source priority table (numeric, explicit)
- Per-field source tracking (city can come from one tier while state comes from another)
Source priority: one table, no ambiguity
The priority model lives in code, in one place. The research node doesn't sort of prefer one source over another. It reads a single map that defines what wins.
# app/langgraph_manager.py:136-143
LOCATION_SOURCE_PRIORITY = {
"firecrawl": 5,
"bing": 4,
"domain_variation": 3,
"serper": 2,
"azure_maps_poi": 1,
"phone_area_code": 0,
}
The non-obvious win is that I can reason about a conflict without reading the whole pipeline. When I'm debugging a bad record, I'm not asking which function ran last. I'm asking what priority was allowed to write this field.
Per-field provenance: the merge rule that prevents regression
The second piece is per-field source provenance tracking. I don't track "this record came from Bing." I track city_source, state_source (and the same idea applies to the other fields in the enrichment payload).
So one tier can contribute a city while another contributes a phone, and neither is allowed to stomp the other unless its priority is higher.
# app/langgraph_manager.py:146-210
# Per-field provenance tracking is handled in the research node.
# Each field has an associated "*_source" that records which tier populated it.
# Lower-priority sources fill empty fields but never overwrite higher-priority fields.
# Example fields tracked independently:
# - city_source
# - state_source
What surprised me after shipping it was how often "partial truth" is the norm. A web scrape might nail the company name and miss the city. A maps lookup might nail the city and miss the phone. Provenance lets me accept that reality without the whole thing collapsing into last-write-wins.
Tracing one record through all six tiers
When I explain this internally, I don't call it six APIs. I call it a relay race where each runner can carry the baton forward and can't swap it for a different baton.
Here's the flow at the level that matters: who gets to write which fields, and under what constraints.
The merge step is the heart of it. Every tier produces candidate values, and the merge logic decides whether each value is allowed to populate a field based on:
- whether the field is empty
- what source last populated it
- the numeric priority of the incoming source
Tier 1: Firecrawl (web scrape)
Firecrawl sits at the top (firecrawl: 5). When it returns data, it should win against everything else.
There's a business decision sitting in the code too. Firecrawl enrichment is disabled by default behind a feature flag, because it's a cost gate.
# app/config/feature_flags.py
ENABLE_FIRECRAWL_ENRICHMENT = false
That boolean is small, and it's a real line in the sand: we can run the system without the most expensive enrichment tier, and the rest of it will still behave predictably.
Which matters more than it sounds like it should. When the best tier is turned off, the remaining tiers shouldn't start overwriting each other out of desperation. Provenance is what keeps the behavior stable.
Tier 2: Bing Search (fallback enrichment)
When Firecrawl is missing fields or has low confidence, the chain can enhance using Bing Search. The rule is explicit in the research node: low confidence or missing key fields triggers the fallback.
# app/langgraph_manager.py
# Check if we need improvement: low confidence OR missing key fields
needs_improvement = (research_result.get('confidence', 0) < 0.7) or \
not research_result.get('company_name') or \
(isinstance(research_result.get('company_name'), str) and research_result['company_name'].lower().startswith('unknown')) or \
not research_result.get('phone') or \
not research_result.get('city')
The detail that matters is that Bing isn't there to compete with Firecrawl. It's there to fill the holes Firecrawl leaves behind. And because Bing has lower priority (bing: 4), it can't overwrite anything Firecrawl already populated.
Tier 3: Domain variation retry (with failed-domain cache)
Domains are messy. A scraped domain might be a vanity domain, it might redirect, or it might just be wrong.
So I added a domain variation retry mechanism, and I made it remember what failed.
# app/langgraph_manager.py:3246-3330
# Domain variation retry is handled in the research node.
# It retries enrichment using domain variants and maintains a failed-domain cache
# so the system doesn’t keep paying for the same dead ends.
Reads like a minor optimization until you run it at scale. Without a failed-domain cache, a pipeline like this will happily retry the same bad domain across many runs, on the theory that it might work this time. With the cache, failure becomes a learned fact.
Tiers 4–5: Serper and Azure Maps POI (filling mid-priority gaps)
Serper (serper: 2) and Azure Maps POI (azure_maps_poi: 1) sit in the middle. They exist for the same reason: earlier tiers often leave location and company metadata partially filled, and these two services are good at plugging those specific holes.
Neither does anything exotic. Serper adds another web-search vote when Bing and domain variation came up short. Azure Maps grounds location fields (city, state) against a geographic index when scraping didn't produce them. Both follow the same provenance rule as every other tier. Fill blanks, never overwrite a higher-priority source.
The value of making them explicit tiers, instead of lumping them into a generic "try more APIs" step, is debuggability. When a record's city_source says azure_maps_poi, I know exactly which tier populated it and at what confidence level. That traceability is the whole point.
Tier 6: Phone area code approximation
Phone area code approximation is the lowest priority (phone_area_code: 0) for a reason. It's sometimes the only thing you have, and it's also the easiest way to inject plausible nonsense.
This is where per-field provenance earns its keep.
If a city came from Firecrawl, a phone-based guess can never overwrite it.
If city is empty, the phone tier can fill it, with the source recorded as phone_area_code.
That last part matters operationally. When a recruiter sees a location, I want the system to know whether it was scraped, searched, maps-grounded, or inferred. The provenance fields are how that stays visible.
What went wrong before I built provenance
Before I tracked sources per field, I had a classic failure mode. The pipeline would "improve" a record by adding a missing phone number, and in the same merge step it would overwrite the city with a weaker guess.
It looked like progress. The payload had more filled fields.
It was a regression. A high-quality location got replaced by a low-quality one, and nobody caught it until downstream workflows started behaving strangely.
That's the trap. Enrichment failures rarely look like failures. They look like slightly-wrong facts.
The provenance model turns those silent failures into something you can reason about:
- If
city_sourceisphone_area_code, I know it's an approximation. - If
city_sourceisfirecrawl, I treat it as higher confidence. - If a field flips sources unexpectedly, that's worth investigating.
Nuances that make this pattern hold up
1) Priority is numeric because ties are poison
I chose numeric priorities (5 down to 0) because they force a total ordering. Allow "these two sources are both good" and you'll eventually have tie-breaking logic scattered across the codebase.
One map makes conflict resolution mechanical. Mechanical is what you want in production.
2) Provenance is per-field because reality is per-field
Track provenance only at the record level and you'll end up lying to yourself.
Real enrichment is patchwork:
- one source knows the company name
- another knows the phone
- another knows the city
Per-field provenance is the only representation that matches that reality.
3) Feature flags are part of architecture, not "ops stuff"
ENABLE_FIRECRAWL_ENRICHMENT = false reads like a deployment toggle. It's really an encoded business decision: the system must still function when the expensive tier is off.
That constraint shapes everything below it:
- If Firecrawl is off, Bing becomes the top tier.
- If Bing is thin, domain variation retry matters more.
- If everything fails, the pipeline still returns something, with honest provenance.
That last clause is the difference between "resilient" and "wrong without anyone noticing."
Closing
Calling six services is the easy part. The part that matters is that the system refuses to confuse a guess with a fact. Once every field carries its own provenance and every source has an explicit priority, enrichment stops being a gamble and becomes a controlled accumulation of truth, the sort of unglamorous engineering that keeps an enterprise AI system from slowly sliding into confident nonsense.
In Part 4, I'll take the same principle one step further: user corrections always win, and the system learns from them without ever pretending the correction was the model's idea in the first place.
