Hugging Face で公開されている 56億行規模の動画メタデータを分析していたときの話です。日本に関わる行だけ欲しいのに、1か月分で約11GB、全体では460GB。全部ダウンロードしてから WHERE country = 'JP' するのは、ディスクも時間も現実的ではありませんでした。
結論から言うと、DuckDB の httpfs 拡張 + parquet_metadata で行グループの統計を確認し、必要なバイト範囲だけ HTTP Range リクエストで読むことで、1か月分の JP データを約10秒で取得できました。この記事では、その手順とハマりどころを実装レベルでまとめます。
環境
| 項目 | バージョン / 内容 |
|---|---|
| OS | Ubuntu 22.04 (WSL2 でも可) |
| Python | 3.11 |
| DuckDB | 1.1 系(parquet_metadata の列名が 1.0 で変更されている点に注意) |
| 拡張 | httpfs |
| データ | Hugging Face 上の Parquet(合計約460GB / 約56億行) |
| ネットワーク | HTTP Range に対応した配信サーバ(Hugging Face は対応) |
DuckDB CLI でも Python でも同じように動きます。以降は Python を前提にしますが、SQL 部分はそのまま CLI に貼れます。
なぜ全量ダウンロードが不要なのか
Parquet は列指向フォーマットで、内部は**行グループ(row group)**という数万行単位のブロックに分割されています。そして各列について、行グループごとに min/max の統計情報(ゾーンマップ) を持っています。
つまり「この行グループの country 列は US 〜 US の範囲」と分かれば、WHERE country = 'JP' に対してその行グループは読む必要がない、と判断できます。これがプルーニング(枝刈り)です。
さらに Parquet はフッターにメタデータを置く構造なので、ファイル末尾の数KBだけ Range リクエストで取得すれば、全行グループの統計が手に入る。統計を見てから、必要な行グループのバイト範囲だけを取りに行けばよいわけです。
手順
1. httpfs 拡張を有効化する
INSTALL httpfs;
LOAD httpfs;
-- メタデータはキャッシュされる。繰り返し触るなら有効化推奨
SET enable_http_metadata_cache = true;
-- 同時接続を絞りたい場合はここで調整
SET threads = 8;
2. parquet_metadata で統計を確認する
parquet_metadata() は、行グループ × 列の単位で統計を返すテーブル関数です。まずは対象ファイルの country 列だけ見ます。
SELECT
row_group_id,
row_group_num_rows,
row_group_bytes,
stats_min,
stats_max
FROM parquet_metadata('https://huggingface.co/datasets/<org>/<dataset>/resolve/main/part-00001.parquet')
WHERE path_in_schema = 'country'
ORDER BY row_group_id
LIMIT 20;
ここで見るべきは 2 点です。
-
stats_min/stats_maxがNULLでないこと(書き出し側が統計を無効化していると詰む) - 行グループごとに min/max が単調に増えているか(=ソート済みか)
今回のデータは country 列でソート済みだったため、JP を含む行グループがごく一部に固まっていました。ソート済みでなくても、行グループごとの範囲が重ならなければプルーニングは効きます。逆に全行グループが AE〜ZW のような広い範囲を持つと、統計は役に立ちません。
3. そのままクエリを投げる(プルーニングは自動)
DuckDB は read_parquet 越しのフィルタで、統計に基づく行グループプルーニングを自動で行います。特別な指定は不要です。
import duckdb
con = duckdb.connect()
con.execute("INSTALL httpfs; LOAD httpfs;")
con.execute("SET enable_http_metadata_cache = true;")
url = "https://huggingface.co/datasets/<org>/<dataset>/resolve/main/part-00001.parquet"
query = f"""
SELECT *
FROM read_parquet('{url}')
WHERE country = 'JP'
"""
df = con.execute(query).df()
print(len(df)) # 1か月分のJP行
複数ファイルをまとめて対象にする場合は glob か配列で渡します。スキーマ揺れがあるときは union_by_name = true を付けます。
SELECT *
FROM read_parquet(
['https://example.com/2024-01/*.parquet',
'https://example.com/2024-02/*.parquet'],
union_by_name = true
)
WHERE country = 'JP';
4. 本当にプルーニングされたか確認する
EXPLAIN ANALYZE で、スキャンした行数とファイル数を見ます。
EXPLAIN ANALYZE
SELECT count(*)
FROM read_parquet('https://huggingface.co/datasets/<org>/<dataset>/resolve/main/part-00001.parquet')
WHERE country = 'JP';
実行計画に PARQUET_SCAN が出て、Rows が全行数より大幅に少なければ成功です。ここが全行数と同じなら、統計が無い or 型が合っていない or フィルタが pushdown されていない疑いがあります。
5. HTTP Range 対応を事前に確認する
サーバ側が Range に対応していないと、結局全量転送になります。curl で確認できます。
curl -sI -H "Range: bytes=0-1023" \
"https://huggingface.co/datasets/<org>/<dataset>/resolve/main/part-00001.parquet" \
| grep -i -E "content-range|accept-ranges|content-length"
Accept-Ranges: bytes と 206 Partial Content が返れば OK です。
実測結果
| 手法 | 転送量 | 所要時間 | 備考 |
|---|---|---|---|
| 全量ダウンロード→絞り込み | 約11GB(1か月分) | 数十分〜 | ディスク消費も大 |
| DuckDB + 統計 + Range(1か月分) | 数百MB未満 | 約10秒 | JP 行グループのみ読む |
| 同上(5か月分) | — | 実測で約170万行を取得 | ファイル数分だけスケール |
全体460GBを落とすことを考えれば、桁が2つ以上違います。
ハマりポイント
-
parquet_metadataの列名がバージョンで違う。DuckDB 1.0 より前はstats_min_value/stats_max_value、以降はstats_min/stats_max。古い記事のコピペでBinder Errorになるのはほぼこれです。 -
文字列統計は切り詰められることがある。書き出し側の設定(
statistics_truncate_length)によっては min/max が先頭数バイトだけになる。JPのような短い値なら問題ありませんが、長い ID を前方一致で絞るような使い方は誤 pruning のリスクがあります。 -
ソートされていないと効かない。書き込み側で
countryでソート(またはクラスタリング)してから Parquet 化するのが本質的に効きます。ソートできないなら Hive パーティション(country=JP/ディレクトリ)に分けるのが次善策です。 -
リダイレクトと署名付き URL。Hugging Face の
resolveURL はリダイレクトします。httpfs は追従しますが、S3 の署名付き URL を自分で発行する場合は有効期限切れで 403 になりがちです。 -
並列度を上げすぎると 429。
SET threadsとSET http_retriesで調整します。レートリミットに当たるならファイル単位で逐次処理するほうが安定します。
FAQ
Q. ソートされていない巨大 Parquet が手元にある。
A. COPY (SELECT * FROM read_parquet('in/*.parquet') ORDER BY country) TO 'out.parquet' (FORMAT parquet) で一度ソートして書き直すのが最も効きます。以後のクエリが全部速くなります。
Q. 統計が NULL だらけで効かない。
A. 書き出し側が統計を無効化しています。読み取り側ではどうにもならないので、パーティション分割されたデータを探すか、自分で再書き出しするしかありません。
Q. ローカルファイルでも同じことはできる?
A. できます。Range リクエストは不要になりますが、行グループプルーニング自体は同様に効きます。クラウド上の巨大ファイルに対して特に効果が大きい、というだけです。
ライセンスの注意
この種の公開データセットは CC BY-NC(非商用限定) のことがあります。業務利用の前に必ずライセンスを確認し、商用可否・再配布条件・出典表記の要否をチェックしてください。技術的に読めても、使ってよいとは限りません。
まとめ
押さえるべき点は 3 つです。
- Parquet は行グループ単位で min/max 統計を持っている
- ソート順(またはクラスタリング)が効いていれば、統計だけで読む範囲を絞れる
- HTTP Range と組み合わせれば、クラウド上の巨大ファイルを部分読みできる
巨大なデータを前にしたとき、まず全量を落とすか、メタデータから読み範囲を絞るか。460GB を前にして後者を選べるかどうかで、分析のイテレーション速度はまるで変わります。まずは parquet_metadata() を一度叩いて、自分のデータがどんな統計を持っているか眺めてみてください。
この記事を書いた人
BENTEN Web Works — 業務自動化・システム開発のフリーランスエンジニアです。
GAS / Python / RPA を使った業務自動化や、Web制作・システム開発のご相談を承っています。
「こんなこと自動化できる?」というご質問だけでもお気軽にどうぞ。
👉 BENTEN Web Works — 詳細・お問い合わせはこちら
🐦 X(旧Twitter) — 日々の知見を発信中