• Opdateret 14. aug. 8 min læsning

Hvordan jeg ranker verdens nyheder uden en redaktør

Bag kulisserne i Nuze: clustering af en million overskrifter, tælling af uafhængige redaktioner i stedet for artikler, og et Firestore-lease der gik galt.

NuzeTypeScriptFirestoreCloud RunNLP

Når folk hører, at et nyhedsoverblik “ranker verden”, er det første de spørger næsten altid det samme:

“Så er det vel bare en redaktør — eller en model der spiller redaktør?”

Svaret er nej. Nuze bygger en daglig udgave ved at tælle, hvor stor en del af verden der faktisk dækker en historie. Ingen vælger toppen. Det lyder enkelt. Det er det ikke.

Jeg lancerede produktet 1. august 2026 som iOS og web. Jeg er den eneste udvikler. Her er hvordan pipelinen hænger sammen — inklusive de dele der var svære, og den infrastrukturfejl der kostede en fuld, dyr clustering-kørsel to gange.

Problemet

Nyhedsranking har traditionelt to knapper: et menneske der vælger, eller en model der gætter hvad der “er vigtigt”. Begge gør produktet til nogens smag.

Det jeg ville have, var et tal: hvor mange uafhængige redaktioner dækker den her historie lige nu, i hvor mange lande, og accelererer dækningen? Hvis svaret er “mange, spredt, og stigende”, er historien stor. Hvis svaret er “én agency-tekst på fyrre sites”, er den det ikke.

Det kræver tre ting, der alle er sværere end de lyder: et korpus der er stort nok til at være globalt, clustering der samler den samme begivenhed på tværs af sprog uden at smelte ugens nyheder sammen til én klump, og et tælleregime der ikke lader syndication stemme flere gange.

Korpusset: en million overskrifter, ingen brødtekst

Hver daglig kørsel kigger på et rullende 72-timers vindue. Det er i størrelsesordenen 1.000.000 artikler på 64 sprog, fordelt over titusindvis af nyhedsdomæner.

Teksten i korpusset er kun overskrifter. Ingen brødtekst, ingen abstracts. Det er et bevidst valg — og det sætter hele resten af pipelinen.

En overskrift er kort, formel og ofte skrevet til at ligne de andre overskrifter om samme sag. To historier om forskellige ting kan dele et navn og et sted. To historier om den samme ting kan bruge helt forskellige vinkler. Uden brødtekst har du færre signaler at skille dem med, og du er nødt til at slække similarity-tærsklerne i forhold til det, title+summary ville tillade. Hvis du ikke gør det, mister du sprog og vinkler. Hvis du slækker for meget, fusionerer du begivenheder.

Det er det kompromis, clusteringen lever med.

Clustering: blokér først, embed bagefter, spørg en LLM til sidst

At embedde en million overskrifter og køre naiv clustering hen over det hele er både dyrt og sløvt. I stedet bygger jeg kandidatblokke først.

1. Entity-baseret blocking

De sjældneste entity-par i en overskrift bruges til at samle kandidater. To artikler der begge nævner et sjældent par, lander i samme shortlist. Almindelige par (“USA”, “præsident”) åbner ikke en blok alene — de ville suge halve korpusset ind.

2. Embedding af shortlisten

Først her bliver teksten vektoriseret. Det holder embed-budgettet på det, blocking allerede har gjort relevant.

3. Greedy agglomerative clustering

Nærmeste clusters merges, så længe de stadig ligner hinanden nok. Tærsklen er bevidst løsere end den ville være med title+body, netop fordi overskrifter er tynde.

4. LLM-bekræftelse

En model får clusteret og skal sige, om det faktisk er én begivenhed. Kun et begrænset antal events bliver bekræftet per kørsel. Resten overlever ikke til ranking.

Det sidste trin er dyrt, og det er derfor det er bounded. Clusteringen må gerne være generøs. Bekræftelsen må ikke.

Det der stadig er svært: en overskrift som “Ministeren trækker sig” er næsten tom, indtil du ved hvilken minister og hvilket land. Blocking fanger det kun hvis entity-parret er sjældent nok. LLM-passet redder de clusters, hvor overskrifterne peger i samme retning uden at dele leksikon. Det redder ikke clusters, blocking aldrig byggede.

Tælleproblemet — den del der er mere interessant end clustering

Når clusters er på plads, skal historien have et tal: hvor mange redaktioner dækker den?

Råt artikelantal er det forkerte tal. Syndication betyder, at én agency-tekst kan ligge på dusinvis af sites. Fælles ejerskab betyder, at to brands kan være én redaktion. Hvis du tæller artikler, vinder den historie der er blevet kopieret flest gange — ikke den, flest uafhængige nyhedsrum har valgt at dække.

Nuze tildeler derfor en uafhængighedsnøgle per artikel, bygget af tre signaler:

  • Deklareret ejerskab. Brands i samme ejergruppe deler nøgle.
  • Shared-copy-detektion på tværs af ejere. Hvis to sites, der ikke er i samme gruppe, kører den samme tekst, foldes de, så agency-kopien ikke stemmer flere gange.
  • Titelsimilaritet inden for en ejergruppe. To nær-identiske overskrifter under samme ejer er stadig én enhed.

En historie skal have dækning fra mindst tre uafhængige enheder for overhovedet at være med. Under det er det støj, eller en lokal sag der ikke hører hjemme i et globalt overblik.

Først derefter kommer scoren: antal uafhængige redaktioner, antal forskellige lande, dækning fra et kurateret ankersæt af store medier, og 24-timers velocity. Alle fire led er log-komprimeret, så en historie der eksploderer i ét land, ikke kan løbe med hele rækkefølgen.

Det er her produktet enten er ærligt eller ej. Clustering kan være pæn, og ranking kan stadig være forkert, hvis syndication får lov at stemme.

Infrastruktur: ét job, flere leases, én publicering

Den daglige build kører som et planlagt, containeriseret Cloud Run-job, trigget af Cloud Scheduler. Stacken er TypeScript og Firestore. iOS-appen er Capacitor uden på samme produkt.

Jobbet skriver kladder. Et separat publish-trin vender den live pointer, når en udgave er færdig. Læsere ser aldrig et halvt resultat.

Concurrency er det, der gik galt først.

Hver udgave har et lease i Firestore, så overlappende scheduler-ticks ikke laver det samme dyre arbejde to gange. I den første version claimede ticks scopes uafhængigt af hinanden. To ticks kunne begge få “deres” scope og begge køre fuld clustering. Jeg betalte for den dyre del to gange, og jeg fik to sæt kladder for den samme udgave.

Fixet var enkelt, da jeg først så det: claim alle scopes op front. Hvis ét claim fejler, stopper jobbet. Ingen delvis kørsel. Ingen “jeg tager bare de scopes der er ledige”. Enten ejer jobbet hele udgaven, eller også kører det ikke.

Det er den slags fejl, man kun laver én gang, hvis man er heldig. Jeg var ikke heldig på timingen. Jeg var heldig, at publish-trinnet sad bag kladderne, så det dobbelte arbejde aldrig blev live.

En regel der er værd at stjæle

Rankingændringer valideres ved at genkøre et gemt run og diffe den resulterende top ti. Aldrig ved at ræsonnere om ændringen alene.

Det lyder pedantisk. Det er det også. Og det er den eneste grund til, at jeg tør røre scoren.

En log-komprimering der “ser rigtig ud” på tavlen, kan løfte en syndikeret agency-sag to pladser uden at nogen opdager det, medmindre man kigger på en konkret top ti før og efter. En tærskeljustering i clusteringen kan splitte en rigtig begivenhed i to og få begge til at falde under tre uafhængige enheder. Du ser det ikke i koden. Du ser det i diffen.

Jeg gemmer kørsler. Jeg replayer. Hvis top ti ikke er den, jeg kan forklare, går ændringen ikke live.

Pris og form

En fuld daglig build koster en håndfuld dollars og tager nogle titals minutter. Ét menneske kører det. Der er ikke et newsroom bag, og der er ikke et ML-team der tuner embeddings om natten.

Oversigterne i produktet er maskinskrevne under mit tilsyn. Det siger jeg højt, fordi det er sandt, og fordi det er den eneste ærlige måde at have globale resuméer på i den her skala. Ranking er mekanisk. Sproget ovenpå er genereret. Hvis du bruger Nuze, skal du vide begge dele.

Hvis du selv sidder med clustering, syndication eller scheduled jobs der ikke må overlappe, så er de to ting jeg ville tage med herfra: tæl uafhængige enheder, ikke rækker i en tabel — og claim hele scopet, før du bruger penge.

Book 20 min. intro


Artiklen er skrevet af Halfdan Harring, der bygger de daglige udgaver. Se Halfdan Harring på Nuze.

Har du et projekt, der ligner det her?

Jeg tager kun 2–3 nye kunder ad gangen. Skriv, så ser vi om det er et match.

Start en samtale