← Work

Vergabeautomat

German public contracts are advertised across the EU tender database and more than a hundred separate platforms, so companies that could win them never see them. I built the thing that reads both sides of that daily.

What it isA multi-tenant service, my own product
My roleEverything. Product, architecture, code, infrastructure, support
SourcesTwo integrations, one per half of the market. TED above the EU threshold, and the German federal notice service below it, which 100+ platforms must publish to by law
VolumeTED publishes 700,000+ notices a year across the EU. The federal service is the German half and I did not write a parser per portal
StackTypeScript · PostgreSQL with pgvector · Next.js · self-hosted embeddings
Runs onA VPS I provision and operate. Docker, Coolify, GitHub Actions
StatusLive at vergabeautomat.de · pre-revenue, no paying customer

The constraint that mattered most

A bid costs a small company days of work, so the question is whether a sales lead trusts the ranking enough to skip what it rejected. Given one number and no explanation, they check the portals by hand anyway.

DecisionFive weighted signals, scored separately, combined into one relevance verdict. Four shown to the user as their own bar, weight visible.
AlternativeAsk a model for one relevance score. Far less code, demos beautifully, and what most tools here do.
What it costMore interface and a worse first impression. One number looks confident. Five bars look like work.
What I did with itRetired the verdict and the weighted sum. The weights were never falsifiable, and I could not make them so.

The only relevance labels that ever existed were twenty-five I wrote myself, over one company profile. Grading my own weights against my own labels moved a precision number and proved nothing, and a PURSUE verdict on top of unfalsifiable weights asserts a business judgement the system cannot back. So the verdict went and the signals stayed: they still order results by closeness, which is a claim I can defend, unlike telling someone what to bid on.

The weights below are still the ones in the code. They renormalise the per-signal breakdown now instead of feeding a total. Deleting them would have thrown away the part that worked.

Same logic, smaller decision: a signal that cannot be computed, because a notice omits the contract value, says so instead of scoring zero. A missing value and a bad value are different facts.

How it works

TEDEU tender databaseBKMS100+ platforms by lawDE-DUPLICATEnightlySCORED PER PROFILECPV code match.32meaning.27region.18contract value.13wording.10the first four are shownNEWEST FIRST, NO TOTALemail and dashboardSIGNAL CANNOT BE COMPUTEDsays so, never scores zero
Fig. 01 — the weights are the ones in the code, and they no longer add up to a verdict

Two numbers I went back and checked

Search used to rank by adding a normalised keyword score to a cosine similarity. Both sit on the same scale and their distributions never meet: keyword ranks pile up near zero, calibrated cosine piles up around 0.65. Notices that literally contained the words the user typed were coming back at positions 158 to 164 of 164. Fusing the two lists by position instead of by score put them back on top.

That exposed a recall problem sitting underneath the ordering one. Postgres does not split German compounds, so a search for Automatisierung could never reach a notice about Prozessautomatisierung, and precision numbers cannot see a document that was never retrieved. Over fifteen realistic German queries, eleven were silently losing notices and Reinigung lost 83 per cent of them. A third arm now matches on stem prefixes, and the audit that produced those figures is a script rather than an afternoon.

The second one is the reason this section exists. Inferring procurement codes from a company's own description was accepted on a 13-of-15 hit rate, and the gate passed. Writing the follow-up measurement showed that the calibration had embedded whole descriptions, while the service that shipped asks a model for three to eight short facets and merges their neighbourhoods. The decision had been taken on evidence about a design nobody built. Calling the real one gave 15 of 15.

The improvement is not the finding. Correct codes scored between 0.8725 and 0.9288 and wrong ones between 0.8724 and 0.9348, and the highest-scoring candidate in the run was wrong. The bands sit on top of each other, so no confidence threshold can stand in for a person reading the list — which is why that screen pre-selects nothing and asks.

The product

The tender notice list in the product's German interface. Each visible row gives the notice title, the buying authority, the region, the estimated contract value, the deadline and an assignment status reading Nicht zugewiesen; most rows also carry a procurement category and a NUTS code. Values ascend down the rows in frame, from 3,586 to 141,015 euro. No column carries a score, a match percentage or a rank. Two black bars cover the signed-in account's e-mail address.
Fig. 02 — the notice list, sorted by the value column because I clicked it. No column carries a score or a rank, and the black bars cover the account e-mail.
A single tender notice. A badge above the title reads TED (EU) plus BKMS (DE). The decision card is headed Ihre Entscheidung and carries two buttons, Bieten and Nicht bieten. Below it a panel headed Warum wird diese Ausschreibung angezeigt? says there is no overall score and no ranking, only the individual signals behind this notice's visibility, and shows four bars for how far each one matched: CPV at 50 per cent, Semantik at 50 per cent, Region at 80 per cent and Wert at 70 per cent.
Fig. 03 — one notice. Kein Gesamt-Score, keine Rangfolge: four of the five signals, each showing how far it matched, and no total. The decision is two buttons.

For the technical reader

How it is built, enforced and checked

Architecture

Ports and adapters, so the scoring core has no idea where a notice came from. Keyword and vector search are two arms of one query in one Postgres, with pgvector doing the second. Embeddings run in my own container rather than through a paid API: at this volume the per-call cost decides whether the product has a margin. 42 decision records.

Enforcement

A contract test said the build was fine while the real build was red, so the check now runs a genuine docker build with every guarded variable set to its own failure message. The layering rule is a dependency-cruiser configuration and gates the deploy. A separate check asserts the AI tooling's own configuration and exits non-zero.

Checked

311 test files across the service and the interface. Tenant isolation is checked at the database layer rather than trusted at the query, because tender history is commercially sensitive and the tenant column is in the first migration.

What is not finished

No paying customer yet. It is deployed, it runs daily, and it is being validated with prospective users, which is a different claim from a product with revenue and I would rather make the smaller one.

The check that stops the retired verdict vocabulary coming back is reporting-only, and deliberately not wired into lint, because the write path it would fail still exists. So the thing I decided to remove is guarded by a check that cannot fail the build, which is the weakest kind of guard there is.

Every tenant holds its own copy of the notices it can see, which is what makes isolation cheap to prove and storage expensive to pay for. Embedding is capped at 500 notices a run, so an account arriving with thousands of them searches on two arms for its first several nights instead of three. The product states the coverage rather than serving the degraded version as if it were the finished one, and I would rather fix the cold start than keep explaining it.

Happy to walk through any of these in detail, including the parts that went wrong.