NACE recodification with RAG: a reproducible pipeline on SSPCloud
Open this tutorial as an interactive notebook:
Tip
Running the cells interactively? A Python virtual environment has already been created for you (WP10-Cluster5-nace-revision/.venv/bin/python). Click the kernel selector in the top-right corner of the notebook and pick that environment before running any cell.
This tutorial shows a Retrieval-Augmented Generation (RAG) pipeline for an automatic-coding use case: recoding free-text descriptions of economic activities into the new NACE 2.1 nomenclature. The whole pipeline runs end-to-end on the SSPCloud (Insee’s open-source data-science platform): a Qdrant vector database for retrieval, the llm.lab gateway for embeddings and generation, and S3 / MinIO for the data. The same recipe applies to any statistical nomenclature or controlled vocabulary, and to any environment that exposes an OpenAI-compatible LLM endpoint and a Qdrant instance.
Compared to the more pedagogical funathon-project2 tutorial, this notebook is direct: every step of the RAG pipeline is shown with its working code, without question/answer scaffolding.
1.1 Why RAG for recodification?
When a classification is revised (NACE 2.0 → NACE 2.1, COICOP, ISCO, …), the legacy training labels are in the wrong space and a new manual-annotation campaign large enough to retrain a classifier from scratch typically does not yet exist. Asking an LLM to recode from memory is fragile: it confuses adjacent codes, hallucinates codes that do not exist, or mixes up versions of the classification.
RAG splits the task in two:
Retrieve: find the NACE 2.1 codes whose definitions are semantically closest to the activity label, using a vector database.
Generate: ask an LLM to pick the best code from the retrieved shortlist, not from memory.
The knowledge about the nomenclature lives in the vector store, not in the LLM weights. Updating to the next revision means re-indexing, not retraining.
1.2 What this notebook covers
Stage 1: build the vector database once (§3).
Raw NACE 2.1 -> NaceDocument -> Embedding -> Qdrant collection
Stage 2: query the vector database for each activity label, then score the pipeline (§4 and §5).
Never commit your .env file. It is already listed in .gitignore. Leaking API keys can expose your services to unauthorised use.
Note
Where to put .env. Put .env at the root of this repository — whether you cloned it yourself, or launched it through the “Open this tutorial as an interactive notebook” button above (which downloads the notebook straight into the cloned repository folder).
2.2.1 Getting your llm.lab API key
Go to the llm.lab interface and sign in with your SSPCloud account.
Open Settings (top right) → Account → API Keys.
Generate a new key and copy it into LLMLAB_API_KEY.
Creating an API key on llm.lab
2.2.2 Getting your Qdrant credentials
Launch a Qdrant service in your personal SSPCloud namespace.
Creating a Qdrant service on SSPCloud
Copy the generated token and save it as QDRANT_API_KEY. The URL follows the pattern https://YOURNAMESPACE-qdrant.user.lab.sspcloud.fr/.
Retrieving the Qdrant token
2.3 Loading credentials in Python
from dotenv import load_dotenvload_dotenv()
To verify a variable was loaded:
import ostry: QDRANT_URL = os.environ["QDRANT_URL"]print("QDRANT_URL loaded successfully")exceptKeyError:raiseValueError("QDRANT_URL is not set; check your .env file")
Built 1047 NaceDocument objects
Example text (used as embedding input):
# Code: 03.11
# Title: Marine fishing
## Includes:
This class includes:
- fishing on a commercial basis in ocean and coastal waters
- taking of marine crustaceans and molluscs
- whaling
- taking of marine aquatic animals (e.g. turtles, sea squirts, tunicates, sea urchins)
## Also includes:
This class also includes:
- gathering of other marine organisms and materials (e.g. natural pearls, sponges, coral, seaweed, algae)
## Excludes:
This class excludes:
- capturing of marine mammals apart from whales (e.g. seals, walruses), see 01.70
- processing of whales on factory ships, see 10.11
- processing of fish, crustaceans and molluscs on factory ships or in factories ashore, see 10.20
- rental of pleasure boats with crew for sea and coastal water transport (e.g. for fishing trips), see 50.10
- fishing inspection, protection and patrol services, see 84.24
- fishing practiced for sport or recreation and related services, see 93.19
- operation of sport fishing preserves, see 93.19
3.4 Create the Qdrant collection
from qdrant_client.models import Distance, VectorParamsEMB_MODEL ="qwen3-embedding-8b"EMB_DIM =4096COLLECTION_NAME ="nace-collection"if client_qdrant.collection_exists(collection_name=COLLECTION_NAME): client_qdrant.delete_collection(collection_name=COLLECTION_NAME)client_qdrant.create_collection( collection_name=COLLECTION_NAME, vectors_config=VectorParams(size=EMB_DIM, distance=Distance.COSINE),)print(f"Collection '{COLLECTION_NAME}' created ({EMB_DIM}-dim, cosine).")
3.5 Embed every entry and upload to Qdrant
upsert is idempotent on the deterministic UUID, so re-running this cell after a failure does not create duplicates.
from more_itertools import chunkedfrom tqdm import tqdmBATCH_SIZE =16for doc in tqdm(nace_documents, desc="Embedding", unit="doc"): doc.get_embedding(client_llmlab, EMB_MODEL)nace_points = [doc.to_qdrant_point() for doc in nace_documents]for batch in tqdm(list(chunked(nace_points, BATCH_SIZE)), desc="Uploading", unit="batch"): client_qdrant.upsert(collection_name=COLLECTION_NAME, points=batch)print(f"Collection size: {client_qdrant.count(collection_name=COLLECTION_NAME)}")
Note
For ~1k documents this sequential loop is fine; for production-scale corpora, batch the embedding requests, run them concurrently with bounded concurrency, save progressively, and rely on the deterministic UUIDs for retry safety.
A recodification prompt has three jobs: pin the task to NACE 2.1, constrain the output to the retrieved candidates, and force a JSON shape so parsing is deterministic.
SYSTEM_PROMPT ="""\You are an expert classifier for the NACE 2.1 nomenclature (Statistical Classification of Economic Activities in the European Community, revision 2.1).You are given a free-text description of an economic activity that needs to be recoded into NACE 2.1, together with a shortlist of candidate NACE 2.1 codes retrieved by a semantic search engine. Your job is to pick the single most appropriate code from the candidates, or to declare the activity not codable if the description is too ambiguous.Always reply with a valid JSON object matching the requested schema. No explanations, no extra text."""USER_PROMPT_TEMPLATE ="""\## Activity to recode in NACE 2.1{activity}## Candidate NACE 2.1 codes and their explanatory notes{proposed_nace_descriptions}## Rules- Pick exactly one code from this list: [{proposed_nace_codes}]. Do not invent codes outside the list.- If several activities are mentioned, only consider the first one.- If the description is too vague to decide, return `nace_code: null` and `codable: false`.## Output (valid JSON only){{ "nace_code": "<one code from the candidate list, or null>", "codable": <true | false>, "confidence": <float between 0.0 and 1.0>}}"""
English activity labels with their reference NACE 2.1 code, taken from the same dataset as funathon-project2 (labels generated by an agentic AI system at low temperature).
Loaded 100 (label, reference NACE 2.1 code) pairs
Example: {'code': '55.10', 'name': 'Hotels and similar accommodation', 'label': 'Commercial lodging for transient guests.'}
from collections import Countercode_to_level = {doc.code: doc.level for doc in nace_documents}annotation_levels = Counter(code_to_level[row["code"]] for row in annotations)nomenclature_levels = Counter(doc.level for doc in nace_documents)print("NACE level of the *reference* codes in the evaluation dataset:")print(dict(sorted(annotation_levels.items())))print("\nNACE level of the codes indexed in Qdrant (the whole nomenclature):")print(dict(sorted(nomenclature_levels.items())))
NACE level of the *reference* codes in the evaluation dataset:
{4: 100}
NACE level of the codes indexed in Qdrant (the whole nomenclature):
{1: 22, 2: 87, 3: 287, 4: 651}
Important
A level mismatch is built into this evaluation. Every reference code in the evaluation dataset is a level-4 NACE code (the finest granularity — e.g. 01.11), as confirmed above. But the Qdrant collection was built from the entire nomenclature: sections (level 1), divisions (level 2) and groups (level 3) are indexed side by side with the level-4 classes they contain — only 651 of the 1,047 indexed codes (62%) are level 4, the rest are broader parent codes.
Nothing prevents the retriever from surfacing a level-1/2/3 parent code among the top-k candidates, or the LLM from picking one. When that happens, pipeline_correct in §5 scores it as a plain miss (pred_code != true_code), even though a parent code can be a legitimate, more conservative answer — for instance if the downstream use only requires the level-2 aggregate, or if the free-text description is genuinely too vague to justify a level-4 answer. Whether that should count as an error is a business-use-case decision, not something a raw string-equality metric can capture on its own.
from collections import defaultdictchildren = defaultdict(list)for doc in nace_documents:if doc.parent_code: children[doc.parent_code].append(doc.code)level3_docs = [doc for doc in nace_documents if doc.level ==3]level3_single_child = [doc.code for doc in level3_docs iflen(children.get(doc.code, [])) ==1]print(f"{len(level3_single_child)} / {len(level3_docs)} level-3 codes have exactly one level-4 child")print("Examples:", level3_single_child[:5])
143 / 287 level-3 codes have exactly one level-4 child
Examples: ['01.3', '01.5', '01.7', '02.1', '02.2']
Important
Some “parent vs. child” mismatches are not mismatches at all. As shown above, 143 of the 287 level-3 codes (50%) have exactly one level-4 child — e.g. 01.3 only ever expands to 01.30. For these, the level-3 code and its level-4 child denote the same real-world activity: predicting the parent is not an approximation, it is the right answer written one level up.
Counting these as pipeline errors artificially deflates the accuracy figures below. Before trusting an aggregate accuracy number for a use case where this matters, prune the nomenclature and/or the predicted codes: collapse parent codes with a single descendant onto that descendant (or, symmetrically, treat a prediction as correct whenever it is an ancestor of the true code and that ancestor has no other level-4 descendant). This tutorial does not implement that pruning — the metrics in §5 use plain string equality — but keep this caveat in mind when reading the results.
4.4 Batch inference on the sample
import pandas as pdfrom tqdm import tqdmrecords = []for row in tqdm(annotations, desc="Recoding", unit="label"):try: pred = run_rag_pipeline(row["label"])exceptExceptionas e: pred = {"nace_code": None, "codable": False, "confidence": 0.0, "retrieved_codes": []} tqdm.write(f"⚠ Error on '{row['label'][:60]}...': {e}") records.append({"activity": row["label"],"true_code": row["code"],"pred_code": pred.get("nace_code"),"codable": pred.get("codable", False),"confidence": pred.get("confidence", 0.0),"retrieved_codes": pred.get("retrieved_codes", []), })results = pd.DataFrame(records)print(f"\n{len(results)} activities processed")results.head()
⚠ Error on 'Cybersecurity vulnerability assessments...': Request timed out.
100 activities processed
activity
true_code
pred_code
codable
confidence
retrieved_codes
0
Commercial lodging for transient guests.
55.10
55.1
True
0.9
[55.1, 55.2, 55.9, 55.4, 55]
1
Specialized land-based animal raising
01.48
01.4
True
0.9
[01.4, 01.46, 01.44, 01.42, 01.5]
2
Risk management intermediary activities
66.22
66
True
0.9
[66.3, 96.4, 66, 77.5, 68]
3
Professional licensing exam administration
85.69
85.69
True
1.0
[82.1, 74.9, 85.69, 74.3, N]
4
Database application programming
62.10
62.10
True
1.0
[62.1, 62.10, 62.9, 63.1, 62.2]
5 Evaluation
End-to-end accuracy decomposes into a retriever contribution and an LLM contribution. If the correct code is not in the top-k, the LLM cannot recover it, so the retriever sets the pipeline’s theoretical ceiling.
\[\text{Pipeline accuracy} = \text{Retriever@k} \times \text{LLM accuracy (conditional on retrieval)}\]
results["retriever_hit"] = results.apply(lambda row: row["true_code"] in row["retrieved_codes"], axis=1)results["pipeline_correct"] = results["pred_code"] == results["true_code"]results["llm_correct_given_retriever"] = results.apply(lambda row: row["pipeline_correct"] if row["retriever_hit"] elseNone, axis=1)
Reading the error decomposition. If retriever errors dominate, the embedding model, top-k, or the indexed text are the bottleneck. If LLM errors dominate, the prompt, the generative model, or the temperature should be revisited first.
5.1 Precision–coverage trade-off
The LLM returns a confidence score. Filtering on it raises precision but lowers coverage (more labels left for manual review).
Optimistic numbers. The evaluation dataset is synthetic: clean, unambiguous one-liners generated by an LLM. Real production labels are shorter, noisier, and more ambiguous, so these figures are an upper bound on what to expect in production.