Datasets:
The dataset viewer is not available for this subset.
Exception: SplitsNotFoundError
Message: The split names could not be parsed from the dataset config.
Traceback: Traceback (most recent call last):
File "/usr/local/lib/python3.14/site-packages/datasets/inspect.py", line 286, in get_dataset_config_info
for split_generator in builder._split_generators(
~~~~~~~~~~~~~~~~~~~~~~~~~^
StreamingDownloadManager(base_path=builder.base_path, download_config=download_config)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "/usr/local/lib/python3.14/site-packages/datasets/packaged_modules/parquet/parquet.py", line 127, in _split_generators
self.info.features = datasets.Features.from_arrow_schema(pq.read_schema(f))
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.14/site-packages/datasets/features/features.py", line 1977, in from_arrow_schema
else generate_from_arrow_type(field.type)
~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^
File "/usr/local/lib/python3.14/site-packages/datasets/features/features.py", line 1634, in generate_from_arrow_type
return Value(dtype=_arrow_to_datasets_dtype(pa_type))
~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^
File "/usr/local/lib/python3.14/site-packages/datasets/features/features.py", line 125, in _arrow_to_datasets_dtype
raise ValueError(f"Arrow type {arrow_type} does not have a datasets dtype equivalent.")
ValueError: Arrow type map<string, float ('heuristics')> does not have a datasets dtype equivalent.
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "/src/services/worker/src/worker/job_runners/config/split_names.py", line 68, in compute_split_names_from_streaming_response
for split in get_dataset_split_names(
~~~~~~~~~~~~~~~~~~~~~~~^
path=dataset,
^^^^^^^^^^^^^
config_name=config,
^^^^^^^^^^^^^^^^^^^
token=hf_token,
^^^^^^^^^^^^^^^
)
^
File "/usr/local/lib/python3.14/site-packages/datasets/inspect.py", line 340, in get_dataset_split_names
info = get_dataset_config_info(
path,
...<6 lines>...
**config_kwargs,
)
File "/usr/local/lib/python3.14/site-packages/datasets/inspect.py", line 291, in get_dataset_config_info
raise SplitsNotFoundError("The split names could not be parsed from the dataset config.") from err
datasets.inspect.SplitsNotFoundError: The split names could not be parsed from the dataset config.Need help to make the dataset viewer work? Make sure to review how to configure the dataset viewer, and open a discussion for direct support.
ViTweb
10,303 Vietnamese documents, metadata only from one public corpus, 2.7 MB of Parquet, one schema
This dataset is one row per page gao's own crawler fetched and kept: the address, the host, the fetch time, the robots decision that allowed it, and every measurement the page was judged on, with the text withheld because a crawled page carries no grant to pass it on.
Contents
- What is it
- What is in it
- Where the text came from
- How it is laid out
- Reading it
- What you can build with it
- The columns
- What this repo is
- What ships and what does not
- Things to know before you use it
- What this is not
- Citation
- Where this comes from
What is it
Vietnamese web text on the Hub is Common Crawl, three times over. fineweb-2, GlotCC and HPLT are built from overlapping snapshots of the same crawl, so the sites Common Crawl does not reach well are missing from all three at once, and Vietnamese provincial news, forums and government publishing is a good deal of what that misses.
This is gao crawling those sites itself. One row per page that was fetched and kept: the URL, the host, when it was fetched, the robots rule that allowed it, the media type, and every measurement the page was judged on, including the full heuristics map the sift produced.
What is not here is the text, and that is the design rather than an omission. A page on the open web carries no grant to redistribute it, so the crawl publishes the address and the measurements and keeps the bytes on the box that fetched them. That is a whole artifact: with the URL and the scores, somebody can fetch the same pages under their own lawful access and rebuild the same corpus, and they can do it knowing which pages are worth the request before making it.
Every page that was not kept is in open-index/vitweb-rejects with the stage and the reason, so the shape of this repo can be argued with rather than taken on trust.
What is in it
| source | documents | parts | input files | size | pinned at |
|---|---|---|---|---|---|
web |
10,303 | 28 | 3 | 2.7 MB | web-20260819, web-20260820, web-20260820b |
| total | 10,303 | 28 | 2.7 MB |
Every count here is the row count in a part's own Parquet footer, added up. None of it is what a run reported writing, because a run that died between pushing a part and writing down that it had is exactly the case a count has to be right about.
The per part version of this table is parts.csv at the root of the repo, which is one row per file with its source, its snapshot, the input file it came from, its document count and its size. It is a CSV so that it can be read without a Parquet reader, and it is small enough to open in anything.
Note that web is here at more than one revision. Both revisions are in the repo and every document in them is counted twice above. Filter to one revision by its file name prefix until the old parts are swept.
The repo grows while ingests run, so these numbers are the ones from the last time gao store index was run against it rather than a sealed total. The counts in parts.csv and the counts here always agree, because they are generated together.
Where the text came from
Four public corpora, pinned at a revision, read once, and written out under the schema below. Nothing here was crawled by us. Every one of them has its own card, its own paper in most cases, and its own terms, and the links are the place to read them.
web
10,303 documents.
Two more are pinned in the ingest manifest and are not here. CulturaX is gated on the Hub and the terms were never granted to anybody on this project, so not one byte of it has been read and it is dropped rather than pending. It is mC4 and OSCAR, both built out of Common Crawl, so it went over the same pages the sources above went over, though that is worth less than it sounds: identity here is a hash of the extracted text, and two Common Crawl derivatives in this repo share under one percent of the smaller of them, because two extractors rarely agree on every byte of a page. MADLAD-400 ships as gzipped JSONL rather than Parquet, so it costs a rewrite that the Parquet sources do not, and it is queued behind them.
The manifest with every pinned revision, every input file and its byte count is at https://github.com/tamnd/gao/blob/main/harvest/manifest.json.
How it is laid out
README.md
parts.csv
data/web/
web-20260819-00000-00000.parquet
... and 27 more
One directory per source, and the file name is the snapshot, the input file of the source the part came out of, and the part. The snapshot is the source and the revision it was pinned at, so re-pinning a source puts its new parts beside the old ones in the same directory under a different name rather than moving the directory. That is deliberate: the directory is the config name somebody writes in a load_dataset call, and a name that moves every time a source is re-pinned is a name nobody can write down.
The directories are named plainly rather than Hive style. A Hive path spells the directory snapshot=web-20260819, and then every reader who globs the repo gets a snapshot column in their result set that is in no file, sitting next to a source column that is, so the first thing the layout teaches them is a distinction they did not ask for.
Reading it
The files are Parquet and they are readable in place. Nothing below downloads the repo, and none of it needs a token, because the repo is public.
DuckDB
Install DuckDB, then:
INSTALL httpfs;
LOAD httpfs;
What is in the repo, without opening a single Parquet file. parts.csv is a CSV of one row per part, so this is a few tens of kilobytes of reading.
SELECT source, count(*) AS parts, sum(documents) AS documents,
round(sum(bytes) / 1e9, 1) AS gb
FROM 'hf://datasets/open-index/vitweb/parts.csv'
GROUP BY source ORDER BY documents DESC;
βββββββββββ¬ββββββββ¬ββββββββββββ¬βββββββββ
β source β parts β documents β gb β
β varchar β int64 β int128 β double β
βββββββββββΌββββββββΌββββββββββββΌβββββββββ€
β web β 28 β 10303 β 0.0 β
βββββββββββ΄ββββββββ΄ββββββββββββ΄βββββββββ
Count one source. A count reads the row counts out of each file's footer rather than the file, so this is a few hundred kilobytes whatever the source weighs.
SELECT count(*) AS documents
FROM read_parquet('hf://datasets/open-index/vitweb/data/web/*.parquet');
βββββββββββββ
β documents β
β int64 β
βββββββββββββ€
β 10303 β
βββββββββββββ
Group by a column. Parquet is columnar, so a query over two columns reads two columns. This one touches 10,303 documents across 28 files.
SELECT license_class, count(*) AS documents,
round(avg(n_syllables)) AS mean_syllables
FROM read_parquet('hf://datasets/open-index/vitweb/data/web/*.parquet')
GROUP BY license_class ORDER BY documents DESC;
Look at some documents. Reading text is the one thing here that is not cheap: the row groups hold 50,000 documents each, so the smallest useful read of the text column is a row group of it, which on a full sized part is a couple of hundred megabytes. That is why this one is pointed at a single part, and at the smallest part in the repo, rather than at a glob.
SELECT url, lang, n_syllables, substr(text, 1, 60) AS opening
FROM read_parquet('hf://datasets/open-index/vitweb/data/web/web-20260819-00001-00004.parquet')
WHERE lang = 'vie' AND n_syllables BETWEEN 200 AND 400
LIMIT 5;
The lang column is ISO 639-3, so the value to filter on is vie rather than the vi in this card's front matter.
Every column and its type.
DESCRIBE SELECT * FROM read_parquet('hf://datasets/open-index/vitweb/data/web/web-20260819-00001-00004.parquet');
Python
The configs in this card's front matter are what datasets reads, so a source is a config name and default is all of them.
from datasets import load_dataset
# One source, streamed rather than downloaded.
ds = load_dataset("open-index/vitweb", "web", split="train", streaming=True)
print(next(iter(ds))["url"])
Streaming is the right default here. The whole repo does not fit on most disks and one source of it does not fit on many, so a run that reads once should read over the network rather than land the corpus first.
When it does need to be on disk, take a source or a single part rather than the repo.
from huggingface_hub import snapshot_download
# One source on disk, which for the smallest of these is 2.7 MB.
snapshot_download(
"open-index/vitweb",
repo_type="dataset",
allow_patterns="data/web/*",
)
import pyarrow.parquet as pq
from huggingface_hub import hf_hub_download
# One part, for looking rather than training. Read the columns you want:
# text is most of the bytes and a row group of it is a couple of hundred MB.
path = hf_hub_download("open-index/vitweb", "data/web/web-20260819-00001-00004.parquet", repo_type="dataset")
table = pq.read_table(path, columns=["url", "host", "lang", "n_syllables"])
print(table.num_rows, table.schema.names)
What you can build with it
The repo is addresses and measurements, so what it is good for is deciding which pages are worth fetching, and measuring a crawl while it runs. Both are column scans.
A fetch list somebody else can act on
The rows are pages that were fetched once and passed every gate, so the URLs are a list of Vietnamese prose pages that are known to exist, known to be allowed by robots.txt, and known to be long enough to be worth a request.
SELECT url, host, n_syllables, lang_score
FROM read_parquet('hf://datasets/open-index/vitweb/data/*/*.parquet')
WHERE n_syllables >= 400 AND lang_score >= 0.9 AND diacritics = 'present'
ORDER BY n_syllables DESC;
Fetching them yourself is what makes the text yours to use. fetched_at says how old our reading of each page is, and robots_decision says which rule allowed it at that time rather than now.
Which Vietnamese sites are worth crawling
A host's yield is the thing every crawler wants and nobody publishes: how many pages of real prose come back per site, and how long they are.
SELECT host, count(*) AS pages, round(avg(n_syllables)) AS syllables
FROM read_parquet('hf://datasets/open-index/vitweb/data/*/*.parquet')
GROUP BY host ORDER BY pages DESC LIMIT 5;
ββββββββββββββββββββ¬ββββββββ¬ββββββββββββ
β host β pages β syllables β
β varchar β int64 β double β
ββββββββββββββββββββΌββββββββΌββββββββββββ€
β baoquangninh.vn β 82 β 1153.0 β
β baolamdong.vn β 21 β 1364.0 β
β tuoitre.vn β 7 β 1147.0 β
β vtv.vn β 6 β 660.0 β
β radio.nhandan.vn β 5 β 206.0 β
ββββββββββββββββββββ΄ββββββββ΄ββββββββββββ
Two provincial papers ahead of the national ones, on a seed list that had both. That is the gap this crawl exists to fill, and it shows up in the first four hundred pages.
Measuring a crawl rather than describing one
Joining this repo to open-index/vitweb-rejects on url gives the whole outcome of every request, which is what a yield curve is made of.
SELECT date_trunc('day', fetched_at) AS day, count(*) AS kept
FROM read_parquet('hf://datasets/open-index/vitweb/data/*/*.parquet')
GROUP BY day ORDER BY day;
url_template is the URL with its variable parts replaced, which is what the crawl budgets against. Grouping by it rather than by host is how a calendar trap or a faceted catalog shows up as one template with thousands of rows.
The columns
42 columns, in file order. Every part in this repo has all of them, and a column a stage has not run yet is null rather than absent, so a query written against one source works against the next.
| column | type | filled in by | meaning |
|---|---|---|---|
doc_id |
bytes(32) |
normalize |
blake3 of the normalized text, which is the document's identity: two documents with the same normalized text are the same document whichever path found them |
raw_id |
bytes(32) |
harvest |
blake3 of the bytes before extraction, which is what links this row back to the WARC record or the source file it came out of |
text |
string |
normalize |
the document text, normalized to NFC with canonical tone mark placement and legacy encodings already transcoded |
schema_version |
uint16 |
store |
the version of this layout, carried per row because a store appended to across a pipeline upgrade holds two versions at once and a reader has to be able to tell |
source |
string |
harvest |
which acquisition path produced the document, one of the six gao runs |
source_locator |
string |
harvest |
where in that source it came from: shard and offset for an ingested corpus, file, offset and length for a WARC record |
url |
string |
harvest |
the page it came from, canonicalized |
host |
string |
harvest |
the host of that URL, which is the unit politeness, budgets and takedowns are all measured in |
url_template |
string |
harvest |
the URL with its variable path and query parts replaced by placeholders, which is what the crawl budgets against and how a calendar trap reads as one URL rather than ten thousand |
fetched_at |
timestamp(millisecond) |
harvest |
when the document was fetched, in UTC milliseconds |
media_type |
string |
harvest |
the media type the response declared, before extraction decided what to do with it |
extractor |
string |
harvest |
name and semver of the extractor, because two documents extracted by different versions of the same extractor are not comparable |
pipeline_version |
string |
store |
semver of the cleaning pipeline that produced this row |
http_status |
uint16 |
harvest |
the status the fetch got, empty for a document that arrived through somebody else's corpus rather than through our crawl |
robots_decision |
string |
harvest |
what robots.txt said about this fetch, recorded per fetch rather than assumed from a global setting so that a consent question years later has an answer |
robots_rule |
string |
harvest |
the rule that decided it, in the spelling the site wrote |
robots_hash |
bytes(32) |
harvest |
blake3 of the robots.txt the decision was made against, so the decision can be rechecked against the file rather than against the file as it is today |
tdm_signals |
map<string, string> |
harvest |
the machine readable text and data mining reservations the response carried, keyed by mechanism and holding what that mechanism said |
consent |
string |
harvest |
what the page said about being kept and trained on, in one word, where empty means nobody asked rather than the page said yes |
lang |
string |
sift |
the language identifier's verdict, which is vie for everything in gao and is stored anyway because a column that is constant today needs no migration tomorrow |
lang_score |
float32 |
sift |
how sure the identifier was |
diacritics |
string |
sift |
present, absent or mixed, because Vietnamese written without tone marks is still Vietnamese and is still not the same distribution |
translated |
bool |
sift |
the machine translation detector's verdict, since translated Vietnamese reads as fluent to a metric and as wrong to a native speaker |
gao_qual |
float32 |
sift |
the quality classifier's score for this document |
gao_edu |
float32 |
sift |
the educational value score, which is what the gao-edu slice is selected on |
hplt_bucket |
uint8 |
harvest |
the source corpus's own quality bucket where it had one, kept so gao's classifier can be compared against an independent one rather than only against itself |
register |
string |
harvest |
the source corpus's own register label, kept for the same reason |
heuristics |
map<string, float32> |
sift |
the raw heuristic measurements rather than the verdicts, so the corpus can be refiltered at a different threshold without being recomputed from the text |
dup_cluster |
bytes(16) |
mill |
which duplicate cluster the document belongs to, empty when it is in none |
dup_cluster_size |
uint32 |
mill |
how large that cluster is |
is_representative |
bool |
mill |
marks the one document per cluster a deduplicated view keeps, with the rest left in the store because deduplication is tuned rather than maximized |
pii_level |
uint8 |
cover |
how much personal data has been removed: none, the structured identifiers, or those plus addresses and identifying names |
pii_types |
list<string> |
cover |
which kinds of personal data were found |
pii_spans |
list<span> |
cover |
where they were found, empty on every row the cleaning line writes, because the offsets index the text before it was covered and because offsets published next to covered text say where the identifiers were |
license_class |
string |
law |
the per document redistribution determination, stored by name so a file read without gao says restricted rather than 3 |
license_evidence |
string |
law |
what determined that class, since a class without evidence is a guess |
structure |
string |
harvest |
what the document is: article, forum thread, legal, thesis, gazette, transcript, which drives both the extraction handler and the mixture weights |
n_chars |
uint32 |
normalize |
how many characters the text holds |
n_syllables |
uint32 |
normalize |
how many Vietnamese syllables it holds, which is the unit that survives a change of tokenizer |
n_tokens |
uint32 |
count |
how many gao tokens it holds, under the tokenizer named in the manifest |
contam_flags |
list<string> |
pick |
which evaluation benchmarks this document overlaps, flagged rather than deleted so one store can serve a training run that excludes them and an analysis that counts them |
upstream_fields |
map<string, string> |
harvest |
the source corpus's own metadata, verbatim, which is the difference between answering a provenance question later and having to ingest again |
pii_spans is a list of structs, and the struct is:
| field | type | meaning |
|---|---|---|
start |
uint32 |
byte offset into the text where the identifier begins |
len |
uint32 |
how many bytes long it is, counted in bytes rather than runes because a reader slicing the text has bytes |
type |
string |
which kind of identifier it is, from the same set pii_types draws on |
The text column is in the schema and is null in every row of this repo, which is the point of it: the same schema, with the one thing that may not be redistributed left out, so that a reader can rebuild the text themselves from url under their own lawful access.
The full schema, including the Parquet spelling of each type and what the dictionary encoded columns cost, is at https://github.com/tamnd/gao/blob/main/SCHEMA.md.
What this repo is
What a stage wrote on its way to a release, published as it is written so that a box can push a part and delete it rather than holding what it has finished. It is public like everything else here, it is rewritten when a source is pinned again, and it is not covered by a signed manifest. A release is, and that is the difference worth knowing before anybody builds on this.
What ships and what does not
This repo carries no document text. What it carries is the URL, the provenance columns and the scores, which is what lets somebody rebuild the same corpus from the same sources under their own lawful access.
| license class | text | metadata |
|---|---|---|
| restricted | no | yes |
Every row carries its own class in the license_class column, so a reader who needs a narrower set than this repo holds can filter for it rather than trust the repo name.
Things to know before you use it
There is no text and there will not be. text is null in every row. The bytes each row was measured from are in a WARC on the machine that fetched them, they are not published, and no reading of source_locator will get anybody to them. What the locator is for is us: it is how a page can be extracted again by a later extractor without asking the site a second time.
robots.txt is honored and so is tdmrep.json. A page under a Disallow is not fetched, and a page whose site reserved its text mining rights is fetched, measured and then rejected rather than kept. Both of those are rows in open-index/vitweb-rejects with the rule on them. It means the corpus is smaller than the web and that the difference is auditable.
The measurements are gao's own and they are not a quality judgment. lang_score is the share of tokens that are Vietnamese syllables, heuristics is the full map the sift produced, and gao_qual and gao_edu are zero because the classifier behind them is trained against a reference set that does not exist yet. A page is here because it is Vietnamese prose of some length, not because it is good.
The repetition filter removes party and government prose, and that is a known fault rather than a finding about the writing. On the first run a third of the repetition rejections were article pages rather than listing pages. Two of them were pulled out and read: the extraction was clean, with no navigation and no related stories in it, and what pushed them over the threshold was the register. An official is named in full every time they are mentioned, so Δα»ng chΓ VΕ© QuyαΊΏt TiαΊΏn, PhΓ³ BΓ thΖ° Tα»nh α»§y, Chα»§ tα»ch Ủy ban MTTQ tα»nh is three occurrences of the same eight syllable gram in a nine hundred word article, and the body doing the meeting is named in full in every paragraph about it. The threshold those pages fail is Gopher's, scaled from words to syllables, and a Vietnamese title is long in syllables and carries one fact. Every page it removed is in open-index/vitweb-rejects with its measured rate, so the threshold can be moved and the corpus recomposed without fetching anything again.
It is a crawl in progress and the parts are the order it happened in. Nothing is shuffled and nothing is deduplicated across runs. Consecutive rows are frequently from one host, because a crawl of a host is a run of requests to that host, so the first N rows are a sample of one site rather than of the corpus.
A crawl is not a snapshot of the web. It is a snapshot of what a seed list plus link following reached, under a budget that closes a template once it stops producing new text. Sites nobody linked to and nobody seeded are absent, and absence here is not evidence of anything.
What this is not
This is not a release. There is no signed manifest behind it, no merkle root over the files, and no promise that a part will still be there next week under the same name. A run pushes each part as it closes it and deletes the local copy, which is what lets a box with a terabyte of disk work through corpora that do not fit on it, and it means the file list is whatever the last run got through rather than a set anybody has fixed.
What that changes for a reader:
- Read it to see what the pipeline produces, and to build on the raw text under one schema without pulling four corpora in four formats.
- Do not cite a document count off it in anything that has to still be true later. Cite a release.
- Re-pinning a source rewrites its parts under a new revision in the same directory, so a query that has to be stable should name a revision rather than a source.
- Nothing here has been deduplicated against anything else here. The same page can be in three of these corpora and it is three documents in this repo.
The releases carry the signed manifest, the dedup, and the quality filtering. They are the other repos in open-index.
Citation
If you use this, cite the corpora it is made of. They did the collecting. This repo did the reading.
For the assembly itself:
@misc{vitweb,
title = {ViTweb: Vietnamese corpora under one schema},
author = {The gao project},
year = {2026},
howpublished = {\url{https://huggingface.co/datasets/open-index/vitweb}},
note = {Built with gao, https://github.com/tamnd/gao}
}
Where this comes from
The pipeline that built it, the ingest contract every document had to pass, and the reasoning behind both are at https://github.com/tamnd/gao.
This card is generated by gao store card, from the snapshot manifest where there is one and from parts.csv where there is not. Editing it by hand works until the next run overwrites it.
- Downloads last month
- -