Methodology, track record & blog

This page is the dashboard and blog for the whole document-extraction project — not just the real-estate data below. The general pipeline (Gemini + Claude, see the homepage) is still in development, so there's no versioned accuracy history to show yet. What exists right now is one complete, verified case study, and this page is both the proof of that work and the methodology behind it.

If you read one page on this site, read this one — it explains where every number below comes from, how it's computed, and, deliberately, what it refuses to calculate.

Track record

There's no versioned accuracy benchmark yet — the general extraction pipeline hasn't been built (Phase 1 on the roadmap). Instead of describing that as though it existed, here is what has actually been done and measured, live from the database behind this site, not a snapshot:

35

projects covered

4,343

verified transactions

96.8%

field accuracy (last measured)

79.18%

extraction completeness

Project/transaction counts computed live from the database at request time. Accuracy and completeness are measured by hand (see below), not recomputed on every page load — see the "Extraction completeness" section further down for how and when.

Blog

The Hanoi case study: extracting structured data from government documents

Published 2026-09-10

Before writing a general-purpose extraction pipeline, this project tested the underlying approach on something concrete and unglamorous: 83 real PDF and Excel files published by the Hanoi Department of Construction (Sở Xây dựng Hà Nội), listing foreign-buyer apartment purchases. Government registries, no consistent format, real defects — corrupted ZIP containers inside .xlsx files, shifted and merged spreadsheet cells, mixed date formats, and in a few PDFs, two overlapping text layers.

Where it landed

  • 35 projects, 4,343 active transactions (append-only history underneath — a correction never overwrites or deletes a record, it supersedes it)
  • Field accuracy: 96.8%, up from 84.8% before fixes — measured by cropping a row image from the source document, reading it blind, and only then checking the database, on a fixed random sample (seed 42)
  • Extraction completeness: 79.18%, measured by comparing extracted row counts against the row-numbering ("STT") the source documents print themselves — a measurement, not an estimate
  • 1,053 of 4,343 records aren't linked to a specific project — kept in the dataset and counted, not hidden

Classes of problems that actually came up

  • Corrupted .xlsx archives. An .xlsx file is a ZIP container; some had one damaged internal entry out of six, and standard libraries refused to open the whole file over a single bad entry even though the other sheets were physically intact. Fixed by reading the ZIP entries directly and salvaging what was readable, logging honestly what wasn't.
  • Shifted table structure. Some publications shift the row-number column over by one or two columns, or put the project address in a merged header cell instead of repeating it per row. A naive, global fix for this once dropped extracted records from 1,452 to 234 — it only became safe as an opt-in path that triggers strictly on a zero-result case, with full regression against a reference file set after every change.
  • Mixed Vietnamese/English nationality labels across files from different sources — solved with an explicit mapping table, not a heuristic.
  • Substring collisions in nationality matching. Short dictionary keys matched inside ordinary Vietnamese words — "hàn" (South Korea) inside "thành" (city), "áo" (Austria) inside "báo cáo" (report). The same class of bug showed up three times in different files before it was fixed properly: a closed dictionary with word-boundary checks, plus a dedicated trap fixture that must match zero times on any future change.
  • Two overlapping text layers in some PDFs. Six nationality values in the database looked like scrambled noise. Taking every other character out of one of them recovers a real word — evidence of two text runs overlaid in the PDF, not random corruption. Left unfixed: verified on 2 of the 6 cases, not all — a plausible-but-wrong guess was judged worse than an honest "unrecognized".
  • One identifier doing two jobs. A content hash was used both to identify a record and to detect whether it had changed — so correcting a typo in a date or contract number could leave the old, wrong version live instead of replacing it. This surfaced three times at different scales before being fixed architecturally with a separate stable identity key (project + unit code + contract number), independent of the content hash.

What's still unresolved — not cleaned up for this post

  • 89 groups of records sharing an identity key with no source-of-truth chosen automatically (218 live records) — left for a human decision, not auto-resolved
  • One specific contract-number extraction bug that grabs a reference-letter number instead of the real contract number
  • ~24% of records not linked to a project, mostly traced to one file that only resolves its project on page 1 of a multi-page table
  • 58 file/sheet segments still below 95% completeness by the row-number check
  • 6 nationality values recovered only partially, left as unrecognized rather than guessed

This is a live, working system, not a static write-up — the same data is browsable on a live aggregated dashboard →, and the day-by-day engineering log is at /changelog. The rest of this page is the detailed methodology behind these numbers.

1. Where the data comes from, and on what basis it's published

Every transaction record on this site originates from a foreign-buyer purchase list published by the Hanoi Department of Construction (Sở Xây dựng Hà Nội, soxaydung.hanoi.gov.vn). These lists are official public disclosures: under Vietnamese housing regulations, developers report foreign purchases to the provincial construction authority, and the authority periodically publishes the consolidated lists as part of its foreign ownership quota (30% of units per building) oversight duty. Publishing an aggregated, source-linked version of already-public government records is the basis on which this project exists.

Coverage today: Hanoi only. The codebase contains scaffolding referencing Ho Chi Minh City and Da Nang (seepipeline/spiders/), but no data from those cities has ever been loaded — they are unused code, not a live source. Claims elsewhere on earlier versions of this site implying multi-city or real-time coverage were incorrect and have been removed.

2. How collection actually works

The dataset behind this site was built from a batch of PDF and Excel files already published on the Hanoi Department of Construction site, processed bypipeline/etl/hanoi_bulk_processor.py. This is a manual, on-demand pipeline run — not a scheduled scraper. The server cron only runs a weekly database backup; there is no automated crawl.

A Scrapy-based spider (pipeline/spiders/hanoi.py) exists in the repository but has never been run against production (no run logs, no proxy credentials configured). Its own code comments note that Hanoi's portal blocks non-Vietnamese IPs and would require residential proxies to reach — which would mean bypassing an access restriction. That conflicts with this project's rule to use only open sources without circumventing protections, so that spider is documented here as unused, unverified, and not endorsed for activation without a deliberate decision to either respect the IP block (and find another lawful access path) or not collect from that endpoint at all.

Any future automated collection from this or any source is expected to keep reasonable delays between requests, identify itself, and respect the source's published access rules — the same standard this document holds the current batch process to, even though today there is no live crawler to apply it to.

3. Extraction completeness

We measure our own extraction losses and publish them here — that is a stronger claim than staying quiet about them, not a weaker one.

How it's measured. Vietnamese foreign-buyer publications number their rows in order in an "STT" (số thứ tự) column — the document itself states how many rows it contains, via its highest row number. For every source file (or, for spreadsheets with multiple independent lists, every sheet) we compare the row numbers we actually extracted against that maximum and record exactly which numbers are missing. This is a measurement, not an estimate: it tells us precisely how many rows and which ones, not a guess.

79.18%

Overall extraction completeness across reconcilable source files, measured 24 Aug 2026. Calculated as (rows extracted ÷ highest source row number), summed across all reconcilable files.

Completeness is not correctness. The number above measures how many rows we extracted out of how many the source document says exist — it says nothing about whether the values inside those rows (date, contract number, nationality, project) are right. We measure that separately, by hand: a random sample of extracted rows checked one field at a time against the original document image, before looking at what the database says for that row.

96.8%

Field-level accuracy on a random 50-record sample (seed 42), measured 24 Aug 2026. Progression on the same sample as parser fixes landed: 84.8% 92.4% → 96.8%. Not recomputed automatically — a fresh figure needs a fresh random sample and a fresh manual read, not a formula. See how this is measured.

Not every file can be reconciled this way. A file (or spreadsheet sheet) counts as reconcilable only when we can confidently identify an STT column at all — a small number of files use a layout where that isn't structurally certain, and those are excluded from the percentage above rather than assumed complete or incomplete.

Known classes of loss (full breakdown with row counts per class, as last measured, in the engineering log): rows dropped because a required field — usually the address — couldn't be located on that row; rows on pages where the source table structure wasn't recognized; rows correctly excluded because the underlying table concerned a resale to a third party (personal data of a non-buyer, out of scope for this dataset by design) rather than a purchase; and a structural finding specific to this measurement — some files carry a quota-summary row (total apartment count for the building) that briefly got mistaken for the row-count ceiling itself, inflating the apparent loss for a handful of files, corrected once found.

What's being done about it. Fixes are applied one loss class at a time, largest first, each verified against a fixed reference set of files before and after the change so a fix can't silently drop rows elsewhere while gaining them here. Scanned pages without any extractable text layer are a separate, harder case — today's source files all have a text layer, so that ceiling is currently zero rows for this dataset, but it won't stay that way as new sources are added.

4. How each metric is computed

All figures on /insights and the public API (GET /api/insights, full spec at /openapi.yaml) are computed live from the transactions and projects tables at request time — nothing is a stale precomputed snapshot.

  • Registrations by month — count of transaction rows grouped by the month of contract_date. Rows with no parseable date are excluded, not assigned to a month.
  • Nationality distribution — count of rows grouped by normalized buyer_nationality, overall and broken out by year of contract_date.
  • District distribution — count of rows grouped by district, extracted from the transaction's address text where a Vietnamese administrative district name was recognizable.
  • Project ranking — projects ordered by their count of linked transaction rows. This ranks how much data we have on file per project, not how many total apartments it has or how "complete" its foreign quota is.
  • Transaction pace — for each project, its transaction count divided by the number of distinct calendar months in which at least one of its transactions occurred. This is an observed average over active months, not a monthly run-rate projection.
  • Unresolved rate — share of transaction rows that could not be matched to a project in the registry (by filename or address pattern). These rows are kept in the dataset, just not attributed to a project.

5. What is in the data, and what is not

In the data

  • · Hanoi foreign-buyer transactions, mostly 2024–2026 filings
  • · Buyer nationality (as reported)
  • · Contract date and, where extractable, contract number
  • · Project/district association, where resolvable
  • · Source file, page/sheet, and an extraction-confidence score per row

Not in the data

  • · Ho Chi Minh City or Da Nang transactions
  • · Any transaction before ~2024 sourcing (older contract_date values exist inside later filings, but pre-2024 coverage itself is not complete)
  • · Long-term leasehold / rental records
  • · Total unit counts per building (see §6)
  • · Buyer names or any other personal identifier — not published on this site

6. Known limitations

  • Source publications have irregular gaps — a month with zero registrations on this site may mean zero activity, or it may mean the Department simply hadn't published a list covering that period yet. We cannot always tell those apart.
  • Some source files could not be parsed or were only partially recovered (corrupted archive, unrecognized layout, no extractable text layer). This is logged, not silently dropped — see the engineering data-quality log.
  • Nationality lists likely include some Việt Kiều (overseas Vietnamese with foreign citizenship) purchases from before August 2024, when regulatory treatment changed. Records aren't re-classified after the fact.
  • Deduplication is address + nationality + date + contract-number based. Where a contract number wasn't extractable, two distinct same-day transactions at the same address can collide into one record; conversely, small formatting differences between files can leave true duplicates unmerged.
  • Project matching (~17–20% of rows) fails when an address doesn't match anything in our project registry closely enough — those rows stay in the dataset as unresolved rather than being force-matched.
  • The live database may lag behind the most recent local re-processing of source files while a pending schema migration is applied — if so, the "generated at" timestamp on /insights still reflects what is actually live.

7. Why there is no foreign-quota fill calculation

Vietnamese housing law caps foreign ownership at 30% of units per building. It would be natural to want a "X% of quota used" figure for each project. This site deliberately does not compute one.

The reason is simple: that calculation needs the total unit count per building as its denominator, and there is no reliable public source for that number across our project list. A small number of individual filings happen to mention a building's total unit count, but most don't, and treating a partial, inconsistent field as if it were a complete dataset would produce numbers that look authoritative while being wrong for most projects. A percentage computed from an unreliable denominator is worse than no percentage — it invites a decision on data we don't actually have.

If a trustworthy, complete source for total-unit counts becomes available, this is the page that would be updated to say so — and only then would a quota-fill figure be added.