Multilingual HS / HTS code classification by embedding retrieval. ~500 lines of Python.
Question. Can an off-the-shelf multilingual sentence-embedding model assign 6-digit Harmonized System codes to free-text product descriptions, in any language, across the whole nomenclature rather than a hand-picked subset?
Method. Treat classification as retrieval. The index holds every HS 2022 subheading description (5,613 codes, heading prepended for context) plus 17k real product descriptions with known codes. A query is embedded with the same model and each code is scored by its best-matching index entry. Nothing is translated: multilinguality comes from the encoder.
query ──embed──▶ cosine vs index ──▶ best entry per code ──▶ top-k HS6 ──▶ US HTS lines
index = nomenclature (5,613) ∪ labelled examples (17,133)
Held-out split of the real examples (3,023 English queries, 15% of
data/examples.csv, never indexed), scored over all 5,613 codes.
| Encoder | HS6 top-1 | HS6 top-3 | HS6 top-5 | HS4 top-1 | HS2 top-1 |
|---|---|---|---|---|---|
intfloat/multilingual-e5-small (118M) |
0.421 | 0.586 | 0.668 | 0.594 | 0.788 |
BAAI/bge-m3 (568M) |
— | — | — | — | — |
Qwen/Qwen3-Embedding-0.6B |
— | — | — | — | — |
| e5-small + contrastive fine-tune | — | — | — | — | — |
Raw numbers: results/. Rows marked — have not been run yet (see Reproduce).
The 78-case multilingual benchmark (data/benchmark.csv) is also reported by
the evaluate script; its numbers are not listed here because the run predated
the HS 2022 code fix in that file.
Observations so far:
- Chapter (HS2) is right ~80% of the time; most errors are between sibling subheadings whose official wording differs by a qualifier ("other", "n.e.c.", a viscosity or weight threshold) that a short product description rarely states.
- Non-English queries land in the right neighbourhood (heading) but miss the subheading more often; a small encoder is the likely bottleneck.
- Earlier versions of this repo reported ~90% but only over 118 curated codes with templated synthetic data. Those numbers are not comparable.
uv venv && uv pip install -e . # or: pip install -r requirements.txt
python -m hsclassify.train # build models/index with e5-small (~2 min on a laptop)
uvicorn app:app --reload # http://localhost:8000POST /predict {"text": "冷冻虾"} returns the top-k codes with the matched
index entry; GET /hts/{hs6} returns the US HTS 2026 tariff lines and general
duty rate under a code; POST /ocr runs Tesseract on an uploaded image.
python -m hsclassify.evaluate # default encoder
python -m hsclassify.evaluate -m Qwen/Qwen3-Embedding-0.6B # another encoder (one at a time)
python -m hsclassify.evaluate --no-examples # zero-shot: nomenclature only
python -m hsclassify.train --finetune --epochs 1 # MNRL fine-tune, then index
python -m hsclassify.evaluate --index models/index # score a saved indexFine-tuning pairs each training description with the nomenclature text of
its code (MultipleNegativesRankingLoss, in-batch negatives); the held-out
split is never seen. Encoders larger than ~300M parameters need a GPU or a
machine with more than 16 GB of RAM; --device cpu is slower but safe.
hsclassify/data.py load nomenclature, examples, benchmark, US HTS (70 lines)
hsclassify/model.py HSClassifier: build index, predict, save/load (110 lines)
hsclassify/train.py build index, optional contrastive fine-tune (75 lines)
hsclassify/evaluate.py held-out + benchmark metrics for one or more encoders (95 lines)
app.py FastAPI demo (predict, HTS lookup, OCR) (80 lines)
templates/index.html single-page UI
data/ 6.6 MB of CSVs, documented in data/README.md
results/ evaluation outputs
Built artefacts go to models/ (git-ignored). For the Hugging Face Space,
upload that folder to a Hub repo and set HF_INDEX_REPO; app.py downloads
it on start-up. The GitHub Action in .github/workflows mirrors main to the
Space.
See data/README.md. HS nomenclature: ODC-PDDL via datasets/harmonized-system. US HTS: public domain (USITC). Labelled examples: CBP CROSS rulings, product_hscode and HSCodeComp, via the Hugging Face Hub. Code: MIT.