Semantic CSV to RDF Annotation

March 2025 → May 2025

Code sourceSemTab 2024, Accuracy Track

Automatically annotating a CSV with Wikidata entities

This was the project for the Ontologies and Semantic Web module of my ICE-LD master's degree at the University of Toulouse Jean Jaurès, carried out between March and May 2025. The brief left little room: tackle SemTab, an annual international challenge, and specifically its Accuracy Track.

The problem

A CSV file contains nothing but text. Nothing in it says that column 2 holds cities, nor that the cell "Paris" means the French capital rather than Paris, Texas or the mythological figure. As long as that meaning stays implicit, everything downstream in a data pipeline works blind: integration, cleaning, machine learning.

The challenge splits the problem into three tasks. CTA, column type annotation, means stating that this column holds cities. CEA, cell entity annotation, means stating that this particular cell denotes Q90, the city of Paris in France. CPA identifies relations between columns. I tackled the first two.

An example makes both tasks concrete. Take this table:

city country
Paris France
Berlin Germany
Rome Italy
Madrid Spain

CEA works cell by cell and must link "Paris" to Q90, "Berlin" to Q64, "Rome" to Q220. CTA works on the whole column: does it hold cities (Q515) or capitals (Q5119)? Both types are defensible, and it's the vote across all cells that settles it. The central difficulty is already here: a value in isolation is ambiguous, only the whole column lets you decide.

Evaluation is unforgiving: the annotations produced are compared against a gold standard, yielding a precision, a recall and an F1. On the WikidataTables 2024 dataset that meant 623 columns and 4,246 cells to annotate.

What already existed, and why I went another way

Before writing any code, I looked at what the challenge teams had produced. Four families stand out. Traditional approaches such as TSOTSA or DREIFLUSS rely on SPARQL and heuristic rules: fast, simple, but with performance that swings wildly from task to task. Language-model approaches exploit contextual understanding, at a considerable cost in resources. Hybrid systems such as GRAMS+ or ISI-KG combine both and top the rankings, but their technical complexity is a real barrier. Others target narrow cases, such as tables with no data at all.

The brief ruled out predicting with a language model. That left the traditional route: query the knowledge bases, extract types, vote. That's the one I followed, betting that cross-checking several sources could make up for the simplicity of the method.

Why TypeScript and Bun

The field is dominated by Python. Every tool, every example, every piece of documentation. I chose TypeScript with Bun for two reasons: I know the language far better, and I wanted to see whether you could do serious data analysis on that stack with decent performance.

The answer is yes, with a caveat. Strict typing genuinely helped on an eight-stage pipeline where each module reshapes the previous structure: a shape error shows up at compile time rather than forty minutes into a run of network requests. But the ecosystem isn't there. Where a Python project would assemble three battle-tested libraries, I wrote my own Wikidata and DBpedia clients, my own cache, my own CSV parser. Instructive, and also time not spent on the algorithm.

Five versions, and why each replaced the last

This is the project where I worked most through successive iterations, each version born from a limitation observed in the previous one.

Version 1 queried Wikidata alone. For each column: a sample of values, an entity search, type retrieval through the P31 property along with parent types, then a vote on frequencies. It worked, and it broke on nearly everything: ambiguous terms, misspelled values, a chosen type that was either too general or too specific, and heavy sensitivity to which rows the sample happened to catch.

Version 2 added DBpedia. The point wasn't to replace Wikidata but to cross-check: when an entity is found in both bases and their types line up, that type's score rises more than an isolated one's. Two sources that converge beat one that merely asserts. It also made the tool more general, since it no longer depends on a single base.

Version 3 went after data quality. Inconsistent casing, mismatched date formats, numeric separators, stray whitespace. An algorithm that shines on clean data is useless on the data you actually have: normalising upstream conditions everything that follows.

Version 4 introduced context. Until then each column was handled in isolation. Yet if one column holds "France" and another holds "Paris", the first disambiguates the second. So I added detection of semantic relations between columns, which adjusts the scores, plus an analysis of candidate entity URIs: when a candidate's URI contains a value found elsewhere in the row, its confidence goes up.

Version 5 made the whole thing usable. An LRU cache over network requests, because the same values keep coming back and each call costs about a second. An interactive CLI with presets, so I no longer had to edit a config file for every run. And above all a change in the final selection: instead of taking the highest-scoring type whatever its source, I now keep only Wikidata types, going through the mapping table when DBpedia alone answered. The challenge expects Wikidata URIs; producing anything else, however correct, counts as an error.

That architecture turned out modular enough that adding CEA cost almost nothing: the cleaning and entity-search stages were already there, only the handling of the final candidates had to change.

Results

Task F1 Precision Recall Time Targets
CTA 0.552 0.552 0.552 1 h 32 623 columns
CEA 0.651 0.651 0.651 1 h 16 4,246 cells

I never submitted anything officially to the challenge; evaluation was done locally against the gold standard. The comparison below is indicative, not a ranking.

Approach CTA CEA
ISI-KG (hybrid) 0.93 to 0.96 not submitted
TSOTSA (traditional) 0.19 to 0.72 0.069
This tool (traditional, local) 0.552 0.651

For column typing, ISI-KG and its 0.9 leave little room for illusion: an approach without learning doesn't play in the same league as a hybrid system. My 0.552 sits between TSOTSA's two runs, whose score collapses from 0.72 to 0.19 from one round to the next.

Cell annotation tells a different story. On this benchmark the only published official score is TSOTSA's, at 0.069; neither ISI-KG nor the other front-runners submitted on that task. Either CEA on WikidataTables is harder than it looks, or the teams put their effort elsewhere. Either way, reaching 0.651 with a vote across two knowledge bases was not a given.

One detail of the scores says more than the scores themselves: precision and recall are exactly identical on both tasks. The algorithm never abstains. It always answers something, and gets it wrong one time in two on CTA, one in three on CEA. A system able to say "I don't know" would have shown far better precision, at the cost of recall.

What didn't work

Numbers. This is the limitation that survived every version. A cell holding 1789 or 42 can't be looked up in a knowledge base the way a proper noun can: there is no entity to find, only a literal whose type depends entirely on context. I never produced anything convincing there.

Disambiguation. Despite the context added in version 4, "Paris" and "Mercury" remain traps: the signal from neighbouring columns is too weak when the table itself is ambiguous. The whole thing also only works in English, since the entire data-correction stage is tuned for that language.

Foundations I never verified. Spelling correction exists in the code as an empty function: I couldn't find a way to fix typos without going through a model, which the brief ruled out, and I preferred leaving the entry point in place to faking it. More awkwardly, the mapping table between DBpedia and Wikidata types, along with the entity-relation dataset, were generated by AI and integrated as-is. Those tables sit at the heart of the cross-checking, and I never reviewed them entry by entry.

The public-endpoint wall. Wikidata and DBpedia blocked me several times. I ended up with values found by trial and error until it went through: five requests per batch, half a second between batches, one second between columns. Nothing elegant, but it's what made ninety-minute runs possible without getting cut off. A local Wikidata dump would have changed everything; I simply never thought of it at the time. That same time pressure explains why the challenge's third task, CPA, was never delivered: the relations between columns are detected, version 4 depends on them, but they aren't produced in the expected format.

What I took away from it

A knowledge base isn't a single truth. Wikidata and DBpedia don't model the world the same way: type hierarchies differ, identifiers don't line up, an entity present in one may be missing from the other. Querying both and reconciling the answers isn't a setback, it is the work itself.

On method, this is the project that taught me most about moving forward in versions. Five iterations, each starting from a measured limitation of the last rather than a hunch. Writing down plainly what doesn't work in the current version is the best way I know of figuring out what to do next.

The repository has been archived since June 2025: it's a coursework project, it stops there. Looking back, the direction I'd explore is embeddings for fuzzy matching, which would likely have addressed part of the spelling and disambiguation problem without falling into the language-model prediction the brief ruled out.

Technologies used