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.
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.
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
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


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.