Skip to content

The Corpora

Three full-text corpora were harvested from the Sisältöhaku demo service of Kansallisarkisto, the National Archives of Finland. Together they hold 7,941,378 OCR/HTR-recognised documents — 19.4 GB of text — spanning 859 to 1938.

corpus documents text period content ingested
tuomiokirjat 7,835,557 19.25 GB 1610–1931 Court records yes
voudintilit 98,945 131 MB 1539–1635 Bailiff accounts yes
df 6,876 7.4 MB 859–1530 Medieval charters yes

The harvest lives in .data/, which is git-ignored: 6.3 GB of gzipped JSON Lines has no place in a repository, and it is a snapshot that should be re-harvested rather than versioned. scripts/harvest.py does that — make harvest, or uv run python scripts/harvest.py --index all — writing the same three files per corpus that the July 2026 harvest produced. Re-harvesting df reproduces the stored snapshot record for record.

How the harvester works

Two public POST endpoints sit behind the frontend. /api/search takes Algolia-style requests and answers with true, uncapped counts and facet values; /api/export-all returns the documents, and is what the site's own download button calls. Counts have to come from the first because the second caps both its results and its reported total at 10,000 — so a saturated response cannot be distinguished from a complete one by looking at it.

Slicing therefore escalates only as far as it must, sized against real counts:

slice key filter
all none — the whole corpus fits (this is df)
y=1539 range: {alkuvuosi: {min, max}}
c=<collection>\|y=1539 that, plus refinementList on the collection facet

Two encoding details are load-bearing, and both fail silently — the filter is dropped and an unfiltered page comes back looking entirely valid. The year range must be the object form {"min": y, "max": y}; the "1539:1539" string that InstantSearch itself serialises is accepted and ignored. And the collection facet is named Aineistokokonaisuus in tuomiokirjat but Aineistokokonaisuus_vt in voudintilit.

Each finished slice is appended as its own gzip member and then recorded in checkpoint.json, so --resume continues an interrupted run. A crash between those two steps can duplicate a single slice; --verify (or make verify-data) checks that the objectIDs are still unique.

Provenance

The corpora are downloaded from Sisältöhaku, the content-search demo service of Kansallisarkisto, through the same public JSON endpoints the site's own "download results" button uses. Run make harvest, or uv run python scripts/harvest.py --index all. Each corpus directory gets the harvester's report.json recording what was and was not retrieved, and checkpoint.json recording the slice-by-slice progress that makes a run resumable.

Coverage is 98.25% of the live index (7,941,378 of 8,082,496). The gap is systematic, not random: Elasticsearch enforces a 10,000-document from + size ceiling per query and the public frontend exposes only two filterable axes, so 22 facet cells exceed the ceiling and their surplus is unreachable by this route — 128,297 of them from the Vyborg town court archive alone. The documented Elasticsearch API at es.demo.kansallisarkisto.fi has no such ceiling; with an API key those records are retrievable.

A harvest is a snapshot, and the index moves. Between two harvests voudintilit went from 99,031 to 99,125 documents. Nothing in a harvested file signals that drift, so anything Kansallisarkisto has added, corrected or re-recognised since the run is simply absent. Re-run the harvester when currency matters; the live service is always the authority.

df — Diplomatarium Fennicum

The scholarly edition of medieval documents relating to Finland — charters, letters, account entries and narrative sources. The only corpus of the three with geographic coordinates.

source field type becomes meaning
objectID string object_id Elasticsearch document id
df string df, df_number DF number — the citable charter identifier
transcript string transcript Transcribed text
indexterm string indexterm Subject classification (Finnish)
issuingplace string issuingplace Place of issue — historical Swedish form for Finland and Sweden, modern name elsewhere
issuingplacecountry string issuingplacecountry Country of issue (Finnish)
language string language Document language (Finnish label)
dating_start_year int dating_start_year, year_from Earliest possible date
dating_end_year int dating_end_year, year_to Latest possible date
_geoloc object lat, lng Coordinates of the issuing place

Plus a derived searchable_text, which the full-text index is built over.

The traps, and what the schema does about them

The corpus has four documented conventions that quietly break a naive loader. Each is handled at ingest rather than left for every query to remember.

0 in a year field means "unknown", not year zero. Stored as a literal 0 it pollutes every date range. The ingest maps it to null.

6,843 of 6,876 charters (99.5%) have dating_end_year == 0. A filter written as dating_end_year <= X therefore matches at most 33 charters, because SQL excludes nulls from comparisons — silently reducing a date-bounded search to 0.5% of the corpus. The ingest derives a closed interval (year_from, year_to) so the filter is a plain two-column overlap test.

2,464 charters (36%) have an empty transcript. They are catalogued but untranscribed. searchable_text folds in place, country, index term and language, so they stay findable — and the formatter labels them, so an empty transcript reads as "not transcribed" rather than as an empty record.

Language labels are unaccented Finnish: the value is venaja, not venäjä. Filtering on the accented form returns nothing. The tool description says so; nothing rewrites the value.

Two further points shape the ingest. _geoloc is populated for 4,142 charters (60%); the rest have both lat and lng null, never one alone — which is why the schema is declared rather than inferred, since a batch of entirely-unlocated charters would infer as a null column and fail to merge with a later float batch. And the file_id field, which the other two corpora carry, is a zero-padded string in voudintilit and an integer in tuomiokirjat — another reason the schema is pinned per corpus.

Can everything be found?

Every record in the corpus was swept: take a distinctive word from each charter's own indexable text and check the charter comes back. The answer, after the fixes it prompted, is all 6,876.

Four charters carry no transcript, place, index term or language at all, so there was nothing for any index to hold and no keyword could reach them. The citation is now part of the search text — written as df <number>, so the way a researcher actually types it, DF 18, matches — which closes the gap without changing any content-query count.

The sweep found three further ways documents had been silently unfindable:

what scale fix
Words fused to footnote superscripts (Hundæbæth⁶) or split by editorial brackets (eccl[esi]a) 2,266 occurrences in 621 records; 4,801 in 1,969 apparatus stripped from the search text, kept in transcript
de, den, om returned nothing — a Swedish analyser had removed them as stop words, though they are Latin content words tokens in 1,735 / 846 / 1,133 documents stop-word removal turned off for this corpus
Quoted phrase queries raised instead of searching, so the MCP layer reported an internal error every phrase query positions added to the index

A fourth problem is not a bug but a property of the material, and it is the largest of all: spelling was never standardised, so bref and breff are the same word yet share only 71 of their 2,104 charters. No index setting fixes that — stemming handles inflection, not orthography — so df_search exposes fuzzy, which takes bref from 257 charters to 2,212 at 96.9% precision. It is opt-in because the engine makes fuzzy and stemming mutually exclusive: a fuzzy term skips analysis and konungen drops from 279 hits to 6. See Search Tips.

Recovering the fused words moved the verified reference counts slightly upward — konung 277 → 279, ecclesia 600 → 602 — which is what recovered recall looks like. DF 3453, for instance, reads eccl[esi]a in the source and was previously unreachable by ecclesia.

voudintilit — bailiff accounts

The Swedish crown's bailiff accounts for the Häme and Satakunta bailiwicks, 1539–1635 — the yearly account books in which the bailiffs recorded the taxes they collected. A record is a page, not a document: 98,945 pages from 1,582 volumes, each volume one bailiwick's accounts for one year. Every volume carries exactly one title and one year pair.

source field type becomes meaning
objectID string page_id Always <ay_id>_<file_id> — the handle voudintilit_get_page takes
ay_id int volume_id The volume; Astia's aineistoId
file_id zero-padded string file_id, page The page within the volume, always four digits
aineistokokonaisuus string collection Hämeen voutikuntien tilejä (54,560 pages) or Satakunnan voutikuntien tilejä (44,385)
arkistoyksikkö string account_book The account book's title — 292 distinct, such as Sääksmäen voutikunnan tilikirja or Maakirja
alkuvuosi int year_start, year_from First year
loppuvuosi int year_end, year_to Last year
teksti string text Transcribed text

Plus searchable_text — the text, account book and collection, the same three fields Sisältöhaku's own search covers — and the reference, series and url joined from Astia. Columns are named in English rather than after the source: LanceDB filter predicates name their columns bare, and arkistoyksikkö is not an identifier to trust its parser with.

Citations come from Astia

The export carries no link and no archival reference, and neither does Sisältöhaku's own interface. Astia, Kansallisarkisto's digital archive, has both, through the two public JSON endpoints its viewer calls: one gives a volume's reference number (tunnisteet, e.g. 2372), title, dates, fonds and series; the other lists every image in the volume, titled Tiedosto N, with the file id the viewer addresses it by. Page N is Tiedosto N, so every page gets an exact link:

https://astia.narc.fi/uusiastia/viewer/?fileId=<file id>&aineistoId=<volume>

scripts/fetch_astia.py (make fetch-astia) fetches both once per volume — about 3,200 requests — into .data/voudintilit/astia.jsonl, and the ingest joins them. That happens at harvest time, never while serving: the endpoints are undocumented, and a serving path that depended on them would inherit every change Astia makes. A volume the snapshot lacks is ingested without a reference or link rather than dropped.

Its traps

Page numbers have gaps. 106 volumes skip pages and 318 do not start at page 1, because Astia holds images Sisältöhaku has no text for — volume 1576091152 has 138 images and 136 pages of text. voudintilit_get_page therefore names the nearest existing page on either side, not N ± 1.

The collection labels are Finnish genitives. Satakunta as a substring matches neither Satakunnan voutikuntien tilejä nor anything else, so the filter is a fixed choice — hame or satakunta — mapped to the full label.

One volume's years are inverted. Reference 2523, Ylä-Satakunnan tilikirja, reads 1615–1516, in Astia's own catalogue as well. The end year precedes the corpus, so it is the typo: the ingest keeps the start year for both bounds rather than stretching its 27 pages across a century.

One volume is not an account book. The 66 pages with 0 for both years are all the Satakunta series' archive catalogue — Satakunnan voudintilien arkistoluettelo, reference 103 — untitled in the export and named by Astia. They have no year, so any year filter leaves them out.

Three pages have no text. They stay findable by account book and collection, which are indexed with the text, and are labelled rather than shown blank.

Can every page be found?

Every page was swept the way the df charters were: search the page's rarest word and check the page comes back, retrying with its next-rarest words where the index splits a word differently. 96,978 of the 98,945 pages come back by a word of their own. The other 1,967 are inconclusive rather than lost — once stemmed, even their rarest words return more than the 100 results the sweep read, so the page may simply rank beyond them. None was missed.

tuomiokirjat — court records

The judgement books and minutes of Finland's lower courts, 1610–1931: the town courts (raastuvanoikeus), the district courts (kihlakunnanoikeus, arranged by tuomiokunta), the town bailiffs' courts (kämnerinoikeus), the land-partition courts, and the Vaasa and Viipuri appeal courts — 223 archives in all, 444 series, 12,284 volumes. A record is a page: one image of one volume, and a case runs across pages. The courts wrote Swedish until the late 19th century; only the archive and series names are Finnish.

source field type becomes meaning
objectID string page_id Sisältöhaku's own document id — the handle tuomiokirjat_get_page takes
ay_id int volume_id The volume; Astia's aineistoId
file_id int or zero-padded string file_id, page The page within the volume
aineistokokonaisuus string collection The archive — Turun raastuvanoikeuden renovoidut tuomiokirjat, Etelä-Pohjanmaan tuomiokunnan renovoidut tuomiokirjat, Viipurin hovioikeuden arkisto
pääsarja string series The series — a court's name for the 17th–18th-century town courts, the record type (Varsinaisten asioiden pöytäkirjat, Ilmoitusasioiden pöytäkirjat) for the later district courts
alasarja13 string subseries Up to three levels below the series, joined with /; set on 746k pages
arkistoyksikkö string unit The record type of the volume
alkuvuosi, loppuvuosi int or string year_start, year_end, year_from, year_to The volume's years
teksti string text Transcribed text — and the column the full-text index is built on
url string url The page image in Astia, present for 99.95% of pages

Plus the volume's reference — its signum, such as a:1 or KO b:34 — joined from Astia.

There is no derived search column. df and voudintilit index a searchable_text that folds catalogue fields in with the text, so that an untranscribed charter or an untitled volume stays findable. Here nothing is worth folding in — the archive and series are filters — and a duplicate of 7.8 million pages of text would cost 10 GB on disk and as much again in the copy the Space makes at boot. So the index sits on text itself, and the shared search takes the column as fts_column.

Citations come from Astia, as for voudintilit

The export links 99.95% of pages but names no signum. One metadata request per volume — make fetch-astia-tuomiokirjat, 12,284 requests, resumable — supplies it for 12,042 of the 12,284 volumes (240 carry none in Astia; two returned errors), so a hit can be cited as archivists cite these: series, signum, year, page, with the archive beneath.

Its traps

One image, two or three ids. 60,000 (volume, page) pairs appear more than once in the export under distinct objectIDs — the same link and years, identical text in 72% of cases and a re-recognised version in the rest. That is Sisältöhaku's own duplication, not the harvest's: the harvester verifies its objectIDs unique. The ingest keeps the first occurrence of each pair, so no page is ever two hits; and because the pair is not unique, the page id is the objectID rather than <volume>_<page> as for voudintilit.

Mixed field types. 315,000 rows carry their years as strings and 896,000 their page number — the latter zero-padded, as voudintilit's always are; 892,000 of those also take the <volume>_<page> id form voudintilit uses, the rest an Elasticsearch id. Both parse; the schema is declared so the two shapes land in one column.

Inverted years, in both directions. 703 pages have the end year before the start. One Kristiinankaupunki volume of 139 pages reads 1984–1895 — the start is the slip, since the courts in this corpus sat until 1931 — where voudintilit's one inverted volume read 1615–1516 with the end out of range. So the rule is: when inverted, keep the bound that lies within the corpus's plausible range, and the start when both do.

83 undated pages, all in one Hamina archive, are left out of any year filter, as the undated voudintilit catalogue is.

The corpus is uneven in time. The whole 17th century is 240,000 pages, the 1910s alone 1.1 million — the district courts' registration books of the late Grand Duchy. A thin result for an early decade is the archive.

Can every page be found?

Sweeping all 7.7 million pages by their own rarest words would take a day, so the check was run on a sample: page 17 and page 318 of every volume that has them, 20,000 pages spread over the whole corpus. 16,123 came back by a word of their own; 3,865 were inconclusive — on a corpus this size even a page's rarest word is often common enough to return more than the 100 results read, so the page may simply rank beyond them; 12 had no word of four letters; none was missed.

Choosing an analyzer

A Swedish stemmer is right for tuomiokirjat and voudintilit. For df it is a 44% plurality choice: 2,870 of its charters are Latin or German, and a Swedish stemmer mis-stems them. It remains the best single choice, and build_fts_index takes a language argument for corpora where a different one wins.

Licence and attribution

The underlying records are the property of Kansallisarkisto and were published through its Sisältöhaku demo service. This is a derived snapshot. Consult Kansallisarkisto for terms of reuse and redistribution, and cite the archive — not this snapshot — as the source of any document.