1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

たった1人+AIエージェントで、全国の公共入札を丸ごと構造化する“データ基盤”を作っている話 ― クラウドAI課金は月0円

1
Posted at

ローカルLLM(NVIDIA DGX Spark)× PostgreSQL + pgvector で、約8.2万件の落札を1件も取り違えない名寄せエンジンと、12万ベクトルの意味検索を回している newsatsu.jp の中身を、実データ付きで全部書きます。


TL;DR(3行)

  • 全国の公共工事の入札・落札データを構造化して検索できる無料ポータル newsatsu.jp の裏側。
  • LLM推論は全部ローカル(DGX Spark 2台=GB10 Grace Blackwell)。クラウドAPI課金は1リクエストもゼロ
  • そして一番の見どころは、AIエージェントがコードを書き → 別のAIエージェント48体が“敵対的に”レビューして → 数字が合わないものは正直に「❌反証」として残す、という開発フローそのものです。

「検索サイトの顔をして、中身は全国の入札の勝敗・価格・競争関係を構造化して持つデータ基盤」――この記事はその技術スタックと、データを触って初めて分かった反直感な事実を、隠さず(捏造もせず)書きます。

🧑‍💻 作っているのは、大企業の開発チームではありません。 オーナー1人が、AIエージェント群を“部下”のように使って、Web・DB・クローラ・AI・検索・予測・インフラ・監視・SEOまで全部ひとつにつないでいます。「1人 × AIエージェント」で、どこまでの規模のデータプロダクトが回るのか――その実例としても読んでもらえると嬉しいです。

想定読者:個人開発でローカルLLMを本気運用したい人 / データエンジニアリング・名寄せ・pgvectorに興味がある人 / 「AIにコードを書かせて品質をどう担保するか」を考えている人。


0. これは何のサイト?

newsatsu.jp は、日本の公共工事の入札公告・落札結果を横断検索できる、登録不要・完全無料のポータルです。

  • 各自治体・発注機関の電子入札ポータル(NEC系 ejPCJ、JACIC系 i-PPI、県・市の独自サイト等)を自動でクロール
  • PDF/HTMLから「案件名・予定価格・落札者・落札額・工種・場所」を構造化抽出
  • 会社単位・発注機関単位・工種単位・地図上で横断的に見られる

スケール感(2026-06時点のスナップショット):

指標
集約済みの入札案件 102,380 件
落札結果(会社へ正規化済 v_company_awards=81,979) 82,627 件
掲載建設会社 12,394 社
入札参加の試行ログ(敗者込み)bid_attempts 341,669 行 / 4,362社 / 52,606入札
意味検索用ベクトル(bge-m3, 1024次元) 123,873 本
対象市場規模(落札額の合計, 2023–2026) 約 ¥3.11 兆
情報網羅率 愛知 97.1% / 岐阜 82.7% / 静岡 68.1%
稼働県 東海3県(愛知・岐阜・静岡)→ 全国展開中

数字を見ると「ただのスクレイピング集計サイトでしょ?」と思うかもしれません。そこが本題で、面白いのはここからです。


1. 技術スタック全体像

まず地図を出します。全部 Docker Compose で1台(+推論用にもう1台)に同居しています。

レイヤ 採用技術
フロント Next.js 15(App Router / TypeScript)、Clerk(認証)、Tailwind、Leaflet(地図ヒートマップ)、Recharts、SWR
配信 Cloudflare(CDN + Tunnel + Cache Rules)、ISR/SSR 併用、動的サイトマップ
API FastAPI(Python)、ルーター分割、内部キーで管理系を保護
DB PostgreSQL 16 + pgvectorpgvector/pgvector:pg16)、HNSW索引、マテビュー多数
周辺 Redis(キャッシュ/キュー)、MinIO(S3互換, 生バイナリ)、Elasticsearch、Grafana/Loki、Open WebUI
クローラ Python + Playwright(ヘッドレス)+ APScheduler、ポータル別の PortalConfig レジストリ
推論 Ollama(gpt-oss:20b/120b、gemma 12B、GLM-4.7-Flashbge-m3、qwen3-vl、RapidOCR)をローカルGPUで
インフラ Docker Compose、2台を Tailscale メッシュで接続

ちなみにコード規模は クローラ Python 233ファイル / API(FastAPI)20ルーター / Web(Next.js App Router)136ファイル。スパイダー・PDF解析・参加資格判定・ML・AI生成・名寄せ・監査が、それぞれ独立モジュールに分かれています。

「Next.js + FastAPI + Postgres」までは普通です。普通じゃないのはこの先


2. 工夫①:推論API課金をゼロにするローカルLLM構成

このシステム、LLMをめちゃくちゃ使います。案件要約・会社概要生成・工種分類・参加資格判定・意味検索の埋め込み・画像/CAPTCHAのデコード……。普通にOpenAIやAnthropicのAPIに投げたら、月の請求が想像したくない金額になります。

ここで効いているのが NVIDIA DGX Spark(GB10 Grace Blackwell・128GBクラスのユニファイドメモリ)2台です。本番ノードは 20コア / RAM 実測 約121GB。

  • spark2 = 本番(DB・API・Web・クローラ)
  • spark1 = 推論専用(ほぼ常時アイドル → 計算資源として叩く)
  • 2台を Tailscale でメッシュ接続し、推論は近い方/空いている方へ

128GBユニファイドメモリのおかげで、1台のGB10に複数モデルを常駐できます(実測でgemma 約10GB + gpt-oss:20b 約13.6GB + qwen3 約45GB を同時ロード)。これで「モデルの載せ替えスラッシング」を避けつつ、用途別にモデルを使い分けています。

用途 モデル 備考
工種分類(最も重い) gemma 12B gpt-ossの約2倍速
参加資格判定(最重要) gpt-oss:20b スキーマ遵守率が高く据え置き
案件要約 / 会社概要 gemma 12B 旧gpt-oss:120bから移行(後述)
AI解説記事の生成 GLM-4.7-Flash 120bより約10倍速・1日6本を自動生成
意味検索の埋め込み bge-m3 1024次元・多言語
画像 / CAPTCHA qwen3-vl 同期処理

クラウドAPI課金=0。しかもデータは1バイトも外に出ない。スタートアップ的には、これだけで競争優位になります。

「指揮官」=GPUオーケストレータ

ただGPUが2台あるだけでは遊休が出ます。そこでジョブをGPUに割り振る“指揮官” を自作しました。

  • gpu_availability.py … Ollamaの /api/ps を2分おきに叩き、「常駐warm」と「いま稼働中」を expires_at で弁別してスナップショット化
  • gpu_dispatcher.py … スナップショット+バックログ(未処理ジョブ)を見て、アイドルなspark1へ最優先で投入。次の予約ジョブの25分前は割り込ませない安全弁つき

そして実装でハマった地味だけど重要なバグ:Ollamaが返すexpires_atナノ秒9桁で、Python 3.10のdatetimeがパースできず、稼働中GPUを「アイドル」と誤判定 → そこへ二重投入して競合、という事故が起きました。6桁に丸めて根治。こういう「外部APIの日時フォーマット」で死ぬの、あるあるですよね。

ジョブキューは“中央ディスパッチャ無し”で組む

非同期AIジョブ(ai_jobsテーブル)は、中央のディスパッチャを置かず、各ワーカが自分でキューから引くプル型にしています。PostgreSQLだけで安全な並行処理を実現する定番パターン:

-- 各ワーカが空いた瞬間に1件“だけ”ロックして引く(取り合いを起こさない)
SELECT id, payload
FROM ai_jobs
WHERE status = 'PENDING'
ORDER BY priority DESC, created_at
FOR UPDATE SKIP LOCKED
LIMIT 1;

さらに:

  • single-flight dedup:同じ入力のハッシュに pg_advisory_xact_lock を取り、同一入力の二重推論を防止
  • 結果キャッシュ ai_resultsinput_hash + 用途 + model_name + model_digest + プロンプト版 をキーにして、同じ問いには推論しない
  • リース&reaper:ワーカが落ちてもハートビート切れで自動回収、status='RUNNING'チェックでゾンビの二重適用をフェンシング

「ローカルLLM × PostgreSQLジョブキュー」は、APIコスト気にせずいくらでも回せるので、個人開発〜小規模チームに本当におすすめの構成です。


3. 工夫②:同名異社を混ぜない名寄せ不変量(8.2万件を1件も取り違えない)

ここが、このプロジェクトで一番“血が流れた”ところです。

公共工事のデータには、同じ名前の別会社(同名異社)が大量にいます。「山田建設」が愛知にも静岡にも岡山にもある。落札データには社名(商号)しか書いていないことも多い。雑にマッチすると、A社の落札がB社のページに合算され、全部の統計が壊れます。

解いた方法:カスケード名寄せ

「どのソースを信じるか」を優先順位付けしたカスケードを組みました。

① 名簿(入札参加資格者名簿)      … 県が持つ最も信頼できる原本。県でアンカーされる
② 建設業許可番号(8桁)           … 先頭2桁が県。MLIT(国交省)の許可DB
③ 法人番号                       … 国税庁。マスタに本店所在県が埋まっている
④ gBizINFO / 公式サイト           … 上場企業・大手向け
⑤ ローカルAI                     … 構造データが無いときの最終手段

普通に名寄せするだけでも大変ですが、ここで効いた仕掛けが2つあります。

(1) 建設業許可の「全営業所」検索(choice=2)

許可DBは本店だけ引くと同名異社を取り違えます。本店だけでなく全営業所を引くことで、「どの県のどの営業所が、この案件の現場に近いか」で曖昧性を解消。地理(現場↔本店/営業所)・工種・発注機関への過去落札の親和性で判定し、検証データで約92〜97%の一致を達成しました。

(2) 「キメラ検出」と likely_split = 0 という不変量

会社の整合性を監視するビュー v_company_integrity を作り、「県のズレ」をシグナルに同名異社の“キメラ”を検出します。

例:ある「青山型」のキメラ。1つのDB行が、静岡・奈良・名古屋の3つの別法人を混ぜて持っていた。→ 正しい法人番号で3行に分解。

ロジックはシンプルで強力:

-- 県の許可を持つのに、法人番号マスタの本店県が食い違う = 分裂キメラの疑い
SELECT *
FROM v_company_integrity
WHERE verdict = 'likely_split';
-- これが常に 0 になるようにシステムを保つ

そして全工程で守る不変量がこれ:

SELECT (SELECT count(*) FROM v_company_awards) AS vca,            -- = 81,979
       (SELECT count(*) FROM v_company_integrity
        WHERE verdict = 'likely_split') AS split;                  -- = 0

VCA = 81,979(落札の正帰属件数)と likely_split = 0(キメラ0件)を、あらゆるDB変更の前後で必ず確認する。確定した同名異社を付け替えても、大手の法人番号を直しても、累計数百件の修正を通してこの2つの数字を一度も壊さなかった。データ基盤の品質を、人間の「気をつける」ではなく機械的な不変量で守るのが肝です。

名寄せって地味ですが、ここが100%正しくないと上に乗る全機能(落札率・会社平均・市場シェア・予測)が全部嘘になる。データ事業の土台は、結局ここの泥臭さで決まります。


4. 工夫③:pgvector / HNSW を使った意味検索(眠れる12万ベクトルの起動)

埋め込みモデル bge-m3 で全案件タイトル等を1024次元ベクトル化し、123,873本pgvectorHNSW索引に積んでいます。

-- ある案件に「意味が似た案件」を、コサイン距離で近傍検索
SELECT p.id, p.title
FROM ai_embeddings e
JOIN projects p ON p.id = e.entity_id
WHERE e.kind = 'project_title'
ORDER BY e.embedding <=> $query_vec   -- HNSW索引が効く
LIMIT 10;

面白い(そして自戒的な)のは、**この最高ROIの資産が、長らく“眠っていた”**こと。索引も埋め込みも常時更新されているのに、公開検索はILIKEの部分一致のままで、ベクトルを使う消費者がほぼゼロだった時期がありました。

ローカルGPUならクエリ側を埋め込むだけで追加計算はほぼ0。「重い側(インデックス構築)は全部できているのに、軽い側(クエリ埋め込み)を配線していないだけ」――この“もったいない”を棚卸しで見つけて潰す、というのもデータ基盤運用のリアルです。


5. 工夫④:LLMの捏造を“未取得”として扱う設計(ハルシネーション監査)

LLMで会社概要を自動生成すると、それっぽい嘘を吐きます。ここを定量監査しました。

会社概要 n=362 を盲検A/B(1ペアを3人の審判エージェントが判定=183 agent-judges)で評価した結果:

モデル 捏造率
旧 gpt-oss:120b 96.4% 🚨
新 gemma 12B 0% ✅(審判全会一致100%)

gpt-oss:120bの主な捏造パターンが教訓的でした:

  • 法人マスタの**「種別コード」(301/302=法人の構造種別)を“法人格”だと誤読**して創作
  • 完工高など数値を勝手に発明(45.5億円 → 27.61万円のような取り違え)

gemmaへ全面移行し、デュアルGPUで OLLAMA_NUM_PARALLEL=3 にして再生成を並列化(110件/時 → 540件/時、約5倍)。

そしてこのプロジェクトの一貫した思想が 「捏造より未取得を正直に(捏造より“—”を出せ)」

  • 生成に失敗した概要は、それっぽい定型文を埋めず (ハイフン) を表示
  • 公告日が取れていない過去案件(実は8〜9割)は、取り込み日で埋めず「—」
  • 予定価格が取れない案件は「未取得」と正直に。間違った値より、空白の方がいい

SEOの成長は鈍るかもしれません。でもユーザーの信頼は捏造で買えない。データ事業としては、これがいちばん効く差別化だと思っています。


6. 工夫⑤:AI生成コードを敵対レビューする監査フロー

ここが、エンジニアに一番刺さると思う部分。このシステムの大半は、AIエージェント(Claude)が書いて、AIエージェントが運用しています。 ただし「AIに丸投げしてヨシ!」ではありません。AIが自分の成果物を敵対的に検証する仕組みを組んでいます。

48エージェントの並列・敵対監査

全コード・全データの総監査を、こうやって回しました:

  1. 本体が30分かけて実機でデータ地図を確認(sshpsql でread-only)
  2. Workflowで48エージェントを並列に走らせ、軸A〜H(データ品質/スキーマ性能/ML/ローカルAI/クローラ/API・Web・SEO/セキュリティ/新データ商品)を横断スキャン
  3. 各発見を“別の”エージェントが独立に再クエリして敵対検証

結果:76発見 / 2,512,839トークン / 1,026ツール実行 / 22分。検証バッジを付与しました:

  • 確認(独立再現できた):26件
  • 反証(主張を棄却。正直に残す):2件
  • ⚠️ 一部訂正(方向は正しいが数字を実機で締め直し):多数

「自分の間違いを、隠さず残す」

監査レポートには**“正直さの章”**があり、敵対検証で棄却・訂正された自分の主張をわざわざ載せています。例:

  • ❌ 「この106MBの主キー索引は死んでいる(1 scan)」→ 逆。表で最も使われる索引(674,320 scan)だった。本当に未使用なのは別の索引。
  • ⚠️ 「再入札の99.5%が値下げ」→ 94.3%が値下げ・5.5%は値上げに訂正。「round2は必ず下がる」前提のモデルは18回に1回外す。

「AIが出した結論を、別のAIが数字で殴り返し、負けたら正直に取り下げる」。これをCIのように回すと、AI開発の最大の弱点である**“それっぽい嘘”が本番に届く前に死にます**。生成AI時代の品質保証は、たぶんこの形になっていきます。

自律運用と自己修復

監査だけでなく日常運用も自律化しています:

  • 県別の自律進出オーケストレータ:状態機械(survey → spider草案 → テスト合格 → backfill → 品質チェック → 完了)を回し、5指標90%のゲートを満たさないと次に進めない
  • 自己修復:品質スコアの自動再測定(あるバグで38.5に凍結 → 修正で57.0に)、失敗した生成の自動リトライ、毎日のキメラ監査
  • 品質の停滞/退行を自動検知する県別オーディタ(停滞・退行・目標未達を3指標で監視)

7. 番外編:実データが「業界の常識」を壊す(経審神話・偽の落札率・非単調な勝率)

データが本物だと、みんなが信じている通説が実は間違っていることが見えてきます。匿名・集計ベースで2つだけ。

「経審が高い=落札しやすい」は、このデータでは概ね誤り

経営事項審査(経審)の総合P値で会社を十分位に分けて勝率を見ると:

分位 勝率
第1〜9分位(=9割の会社) 15〜18%でほぼフラット
上位10%だけ 23%

相関係数は +0.29(弱い)。つまり**「経審が高いほど勝つ」は、上位1割でようやく頭が出るだけ**。中堅企業の勝率は大手とほぼ変わらない。「経審スコアだけで序列化して煽る」のはミスリードで、逆に中小にも勝ち目があることのデータ的裏付けになります(※この知見は愛知のデータが中心である旨を明記した上での話です)。

「落札率」の7割が偽物の 1.0 だった

最大市場・愛知の落札率(award_rate)が、62,937件中43,814件(70%)でぴったり1.0。一見「落札率100%だらけ」ですが、これは予定価格が取れなかった時のプレースホルダでした。

そのうち 33,662件は 落札額 ÷ 予定価格 で真の落札率を即復元可能(復元後の中央値 0.94)。元のコードが award_rate IS NULL の行しか埋めない設計だったため、この1.0永遠にスキップしていたのです。

-- 偽の 1.0 を、復元可能な行だけ真の落札率へ(既存ガードを再利用・可逆)
UPDATE awards a
SET award_rate = round(a.bid_amount / p.scheduled_amount, 4)
FROM projects p
WHERE a.project_id = p.id
  AND a.is_winner
  AND a.award_rate = 1.0
  AND a.bid_amount < p.scheduled_amount       -- 真の1.0(bid=scheduled)は触らない
  AND a.bid_amount / p.scheduled_amount >= 0.3;

これを直すと、愛知の“使える落札率”が153件 → 約42,000件に化けました。上に乗る分析(落札率分布・会社平均・市場レポート)が、ここで初めて統計的に意味を持ちます。「集めて終わり」ではなく、集めた後の品質との戦いが本番だという好例です。

ML:勝率は「価格」に対して非単調

入札試行ログ(敗者込み)を予定価格で正規化して P(落札 | 価格比) を実測すると、勝率は単調ではないことが分かります。高すぎれば負け、低すぎれば失格、という二重の崖がある。

価格モデル自体も、落札率の分位を LightGBMの分位回帰 で条件付けして、ウォークフォワード・バックテストで現行の静的バンドを pinball loss で +18.6〜27.5% 上回りました(愛知・3 fold)。一方で「メタデータから予定価格(円)をピタリ当てる」のは、同一セル内でも予定価格が約19倍ばらつくため原理的に無理と分かり、**“当たらないものは作らない”**と正直に結論づけました。ここでも思想は一貫しています。


8. ハマったところ・学び(実話)

バズ記事だからこそ、格好いいだけじゃなく転んだ話も。

  • 外部APIの日時フォーマット:Ollamaのexpires_atがナノ秒9桁でPythonがパース不能 → GPUを誤って「アイドル」判定 → 二重投入で競合。6桁丸めで根治
  • award_rate IS NULLしか埋めない」設計の罠:7割の偽1.0を永久スキップ。“NULLじゃないが間違っている”データは、NULLより厄介。
  • 常駐プロセスのコードが更新されない:クローラはbind-mountで即反映なのに、スケジューラ本体はコンテナを作り直すまで古いコードのまま動く。「直したのに直らない」の典型。
  • 締切切れの公告が再openする:閉じる処理の後でクローラが締切超過の公告を再度openし、「もう入れない案件」を入札可能と表示。冪等ガードで抑制。
  • 集計は entity 解決済みビューでやる:生のcompany_idでGROUP BYすると同名異社が混ざる。必ず v_company_awards 経由

どれも「あー、それ踏んだことある」と頷ける泥臭さだと思います。データ基盤は、結局この積み重ねです。


9. まとめ ―― なぜこれが“偽装できない”優位なのか

newsatsu.jp の技術的な凄みを一言で言うと、

「ローカルAIで推論コストを0にし、AIが自分を敵対レビューしながら、機械的な不変量で品質を守る」

という、生成AI時代のデータ基盤の一つの完成形を、地味で巨大な公共入札という領域で実装していることです。

  • ローカルLLM(DGX Spark / GB10) … 推論はいくら回しても0円・データは外に出ない
  • 同名異社の名寄せVCA=81,979 / likely_split=0 を不変量に、8.2万落札を1件も取り違えない
  • pgvector + HNSW … 12万ベクトルの意味検索
  • 捏造より未取得を正直に … ハルシネーション率 96% → 0% を盲検A/Bで実証
  • AIの敵対的セルフレビュー … 48エージェント・250万トークンで、自分の結論を自分で棄却する

クロールは真似できても、「8万件を1件も取り違えない名寄せ」と「自分の嘘を潰す監査文化」は、一朝一夕には積み上がりません。データ事業の堀(moat)は、派手な機能ではなく、こういう地味な正確性の総和に宿るのだと思います。

そして念のため言うと、これを回しているのは オーナー1人 + AIエージェント群。いま東海3県で動いているこの基盤を全国47都道府県へ広げれば、ただの便利サイトではなく、“建設業界版の Bloomberg / SPEEDA / 帝国データバンク” に化ける余地がある――そう本気で思っています。newsatsu.jp、ぜひ一度触ってみてください。


おまけ:使った技術タグ

Next.js FastAPI PostgreSQL pgvector Ollama LLM NVIDIA DGX Spark GB10 bge-m3 LightGBM データエンジニアリング 名寄せ 生成AI AIエージェント


質問・ツッコミ・「うちはこう名寄せしてる」みたいな話、コメントで大歓迎です。
役に立ったら LGTM で応援してもらえると、続編(pgvector索引チューニング編 / ローカルLLMジョブキュー実装編)を書く励みになります🙏

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?