キャディ株式会社のブログ - TECH PLAY

TECH PLAY

キャディ株式会社

キャディ株式会社 の技術ブログ

239

はじめに はじめまして、Drawerグループ所属の もりや です。 キャディは入社して約2年になりますが、ブログ記事を書くのは初めてです。よろしくお願いします。 私は入社時から 製造業データ活用クラウドCADDi Drawer の開発に携わっており、最初のRBACベースの認可を私が中心に実装しました。 その関係から、今はIDチームで認証認可周りの開発を担当してます。 今回は、CADDi DrawerでSSOをサポートしたことについて、主にAuth0の観点で書きます。 おことわり この記事は、Auth0をある程度使ったことがある方向けに書いています。 タイトルに「1年かけて」とありますが、開発着手からリリースまでの期間を指しています。 途中で他の機能開発をしていた期間も含まれており、丸々1年を全て開発に費やしたわけではない点にご留意ください。 CADDi Drawer とは 認証観点で簡単に書くと、CADDi Drawerはマルチテナント構成のSaaSで、認証にはAuth0という認証・認可のプラットフォームを使用しています。 アプリケーション側は、Next.jsと nextjs-auth0 というライブラリを使ってAuth0を利用しています。 1つのAuth0テナントに、複数のCADDi Drawerのテナントが存在する構成になっています。 CADDi Drawerは 多くの大企業でも使用いただいています。 傾向として、ユーザー数が多いテナントほどSSOを求める声は多くあり、今回はその要望に応えるためにSSOを実装しました。 補足として、以前に 独自の認証認可基盤を開発 していたこともありましたが、今のところはAuth0を使い続けています。 本記事の構成 大まかに以下のような構成になっています。 検討: Auth0でSSOを提供するために検討したこと 実装: Auth0を使ってSSOを実装したこと 今後: 作りたい機能や課題など 時系列順には書いていないので、その点もご留意ください。 また、文字数が多くなりそうだったためかなり端折っています。 もっと細かい部分も聞いてみたいと思われた方は、ぜひ カジュアル面談 で話しましょう。 1. 検討 Auth0 での SSO の提供方法(Auth0 Organizations) CADDi Drawerで求められるSSOは、Googleログインのように全ユーザーに提供するものではなく、契約している各企業のIdPを、特定のテナントのみに紐付けてSSOを提供します。 そのため、単にアプリケーション全体で提供するのではなく、テナント単位に設定する必要があります。 調査した結果、こうした場合はAuth0 Organizationsという機能を使うと良いという結論に至りました。 Auth0 Organizations とは? Auth0 Organizationsという機能を利用することで、複数の組織を管理し、組織ごとの設定(SSOなど)が簡単に実現できます。 データ構造としては、Organization(= CADDi Drawerのテナント)という情報が新たに登場し、そこにユーザー情報を紐づけるという形になります。 補足として、これまでは app_metadata を使って所属するテナントを管理していました。 Auth0 Organizationsは、このような独自の管理を使わずに、Auth0だけで組織を管理できます。 Auth0 Organizations を使った場合のトークンの変更 Auth0から発行されるIDトークン、アクセストークンに org_id というクレームが追加されます。 また、設定を変更することで org_name というクレームも付与されます。 これらの情報を使うことで、アプリケーション側でもどのOrganization(テナント)に所属しているユーザーなのかを判断できます。 ログイン体験の決定 Auth0 Organizationsを使うと、Auth0 Applicationの単位でログインの体験を決定できます。 ここはユーザー体験に重要なポイントになるので、PdMやCSチームと相談しながら決定しました。 Type of Users ログイン可能なユーザーの種類です。以下の3つがあります。 Individuals : Auth0 Organizationsを使わない Business Users : Auth0 Organizationsが必須 Both : どちらでも可 Auth0 Organizationsを使う場合は Business Users または Both を選択する必要があります。 CADDi Drawerでは、全てのユーザーは何らかのOrganizationに所属する事を必須にしたいので Business Users を選択しました。 Login Flow ログインフローの選択肢です。以下の3つがあります。 Prompt for Credentials : 最初にユーザーはメールアドレスを入力し、メールドメインに基づいてログイン方法 (Auth0 Connection) を決定しログインする方式 Prompt for Organization : 最初にユーザーがOrganizationを入力し、該当するOrganizationにログインする方式 No Prompt : 呼び出し側のアプリケーションで判断する方式 Prompt for Credentials は一般的によく見られる形式で、既存の体験とも大きく変わらないので一見良い方法に見えました。 しかし、この体験を実現するAuth0のHome Realm Discovery (HRD) がCADDi Drawerとしては採用しづらいものでした。 HRDは、ユーザーが入力したメールアドレスではなく、メールアドレスの「ドメイン」に基づいて、ログイン方法(Auth0 Connection)を判断します。 例えば1つの企業で複数のテナントを契約している場合だと、SSOの提供に支障が発生する可能性があったため、HRDを使う判断はしませんでした。 Prompt for Organization は、ユーザーが最初にOrganizationを入力する必要があるため、ユーザー体験としてはあまり良くありません。 また、特にユーザー数が多いテナントの場合、組織名を周知するのも大変というオペレーションの課題もあります。 よって、こちらも採用しませんでした。 このあたりは色々と検討したのですが、最終的には以下のような結論になりました。 パスワードでログインするユーザーにはこれまで通りの体験を提供する SSOを利用するユーザーには、SSO専用のURLを使ってログインしてもらう Auth0の設定としては Prompt for Credentials を選択しつつ、アプリケーション側で専用URLでない場合はパスワードでのログインを行えるように制御しました。 nextjs-auth0を使ったコードのイメージは以下のとおりです(※実際に稼働しているコードとは異なります) // 参考: https://github.com/auth0/nextjs-auth0/issues/701#issuecomment-1255350171 import { handleAuth, handleLogin } from "@auth0/nextjs-auth0" ; const login = async ( req , res ) => { // 1. クエリパラメーターから Auth0 Organization の情報を取得 const organization: string | undefined = req.query. organization ; // 2. Auth0 Organization が指定されていない場合は、パスワードでのログインを強制 const connection = organization === undefined ? undefined : "Username-Password-Authentication" ; await handleLogin(req, res, { authorizationParams : { organization , connection , } , } ); } ; export default handleAuth( { login } ); このようにすることで、既存の体験の維持と、SSOの提供を両立させることができました。 ユーザーデータの作り方 SSOを提供した場合、単純に作ると同一のメールアドレスで以下の2種類のユーザーが存在可能になります。 パスワードでのログインを行うユーザー(以下「パスワードユーザー」) SSOでログインするユーザー(以下「SSOユーザー」) これは、Auth0のConnectionが異なる場合は、一意性制約が効かないためです。 (Auth0のConnection単位で見れば一意になります) メールアドレスとしては同じユーザーに見えますが、ユーザーIDは異なります。 CADDi Drawerでは、様々なデータをユーザーIDベースで管理しているので、システムとしては別のユーザーに見えます。 これは、個人の設定情報が共有できない、利用状況を把握しづらくなる、など色々と問題になりそうです。 メールアドレスをIDとして管理できるようにすれば解決できそうですが、大きな改修が必要になるため、今回は見送りました。 これも色々検討した結果、以下のような構成に落ち着きました。 全てのユーザーは、パスワードでのログイン(Username-Password-Authentication)をベースに作成する User Account Linking を使って、上記のユーザーにログイン方法だけ追加するような形式にする パスワードでのログインは、Auth0の機能的にOFFにできないので、Auth0 Actionsで制御する こうすることで、SSOを使う場合でもメールアドレスに対して必ず1つのAuth0ユーザーとなりました。 初回のログイン処理時にUser Account Linkingをしたり、ログイン方法をチェックするなど、多少複雑にはなります。 しかし、メールアドレスに対してユーザーが一意になるので、システムとしてはシンプルに扱えるようになりました。 2. 実装 Auth0 Organizations への移行(とドメイン移行) SSOのサポートのために、まずはAuth0 Organizationsへの移行しました。 大きな変更になるので、既存のログインとAuth0 Organizationsを使った新しいログインの両方のどちらも使える並行期間を設け、慎重に行いました。 また、同時にCADDi Drawerのドメイン移行の計画もあったため、SREチームと連携しながらユーザーに影響を与えないように進めました。 既存ユーザーのデータ移行 既存のデータベースにあるテナント情報から、Auth0にOrganizationを作り、所属するユーザーのデータを紐づけていきます。 Organizationに紐づける情報を追加するだけで、既存のユーザーデータには変更が発生しないので、割と気楽にできる作業でした。 移行は、以下のAPIを組み合わせて、全て自動で行うことができます。 全ユーザー情報の取得: https://auth0.com/docs/manage-users/user-migration/bulk-user-exports Organizationの作成: https://auth0.com/docs/api/management/v2/organizations/post-organizations Organizationとユーザーの紐づけ: https://auth0.com/docs/api/management/v2/organizations/post-members 注意点としてAuth0のRate Limitがそれなりに厳しいです(参考: Enterprise のRate Limit)。 本番で稼働しているので、安全に移行できるように、1リクエストごとにスリープ処理を入れ、Rate Limitに引っかからないようにしました。 Next.js で複数の Auth0 Application の対応 まずNext.js側では nextjs-auth0 の設定を2つ用意し、Auth0クライアントのインスタンスを切り替えることで、ログインの並行稼働を実現しました。 具体的には、HTTPの Host ヘッダーに応じてAuth0のドメインとクライアントIDを切り替えることで、新旧どちらのAuth0 Organizationsにも対応できるようにしました。 実際のやり方としては、まずAuth0のApplicationを2つ用意します。 既存のAuth0 Organizationsが「無効」なApplication 新規のAuth0 Organizationsが「有効」なApplication nextjs-auth0 は initAuth0 という関数を使ってAuth0のインスタンスを初期化でき、それぞれのApplicationに対応するAuth0のインスタンスを用意します。 より具体的な実装で言うと、以下のようなイメージでAuth0のインスタンスを切り替えて渡していました(※実際に稼働しているコードとは異なります) import { initAuth0 } from '@auth0/nextjs-auth0' ; export const getAuth0 = ( req ) => { const host = req. headers [ 'Host' ]; if (host === NewDomain) { return initAuth0( /* 新ドメイン用の Auth0 設定 */ ); } return initAuth0( /* 旧ドメイン用の Auth0 設定 */ ); } ; 停止を伴う移行 以下の設定については並行稼働できないので、サービス停止を行って一斉に移行しました。 Auth0のカスタムドメイン Auth0が発行したAccess Token (JWT) の検証につかうJWKsのURL 特に難しい点はなく、移行手順の準備や、Staging環境でのリハーサルを行っていたため、大きな問題なく移行を完了できました。 補足として、先に書いた通りドメイン移行と同時に行っていたので、このタイミングで旧ドメイン→新ドメインへのリダイレクト対応なども行っています。 ここまでで、ユーザーに気づかれることなく、ユーザーデータの構造変更を完了させました。 SSO 機能の実装 ここが本題ですが、実際のところSSO機能そのものはAuth0に任せるので、CADDi Drawer固有の実装が少しあった程度でした。 主に、Auth0 Actionsでの開発になります。 なお、CADDi Drawerのユーザー管理機能などの開発については省略します。 パスワードでのログインが禁止されているかのチェック 「ユーザーデータの作り方」で説明した通り、全てのユーザーはパスワードでのログイン方法を持ちます。 しかし、組織ポリシーでパスワードでのログインを禁止したいケースも発生することが想定されています。 Auth0のユーザーの app_metadata に、パスワードでのログインが禁止されているかどうかのフラグをもたせます。 Auth0 Actionsのコードのイメージは以下のとおりです(※実際に稼働しているコードとは異なります) exports .onExecutePostLogin = async ( event , api ) => { const isDisablePasswordLoginUser = event.user.app_metadata.disablePasswordLogin === true ; const isLoginWithPassword = event. connection .strategy === "auth0" ; if (isDisablePasswordLoginUser && isLoginWithPassword) { // ※注意: api.session.deny だと Auth0 セッションが残り続け、自発的に Cookie を消さない限りログインできなくなる api.session.revoke( "disallow_password_login" ); return ; } } ; SSO の初回ログイン時の User Account Linking 処理 「ユーザーデータの作り方」で説明した通り、全てのユーザーはパスワードでのログイン方法を持ちます。 よって、SSOで初回ログインした後に、User Account Linkingを行い、パスワードユーザーと統合する処理が必要になります。 前提として、ユーザーを招待する時に app_metadata.linkUserTo に統合先のユーザーIDを設定しておきます。 そして、以下のようなAuth0 Actionsを実装し、User Account Linkingを行います(※実際に稼働しているコードとは異なります) exports .onExecutePostLogin = async ( event , api ) => { // User Account Linking が必要か判定 const loginUserId = event.user.user_id; const linkUserTo = event.user.app_metadata?.linkUserTo; if (linkUserTo == null || linkUserTo === loginUserId) { return ; // Skip } const primaryUserId = linkUserTo; const secondaryUserId = loginUserId; const secondaryUserProvider = event.user.identities[ 0 ].provider; // Auth0 Organization が一致するかのチェック const managementApiClient = "(省略)" ; const {data : primaryUserOrganizations } = await managementApiClient.users.getUserOrganizations( { id : primaryUserId } ); const primaryUserOrganizationIds = primaryUserOrganizations. map (( org ) => org. id ); const secondaryUserOrganizationId = event. organization . id ; if (!primaryUserOrganizationIds. includes (secondaryUserOrganizationId)) { api.access.deny( "(Auth0 Organization の不一致エラー)" ); return ; } // User Account Linking の実行 await managementApiClient.users. link ( { id : primaryUserId } , { user_id : secondaryUserId, provider : secondaryUserProvider } , ); // ※下記で解説 api.access.deny( `request_re-login: ${ JSON . stringify ( { organization : event. organization . name , connection : event. connection . name } ) } ` , ); } ポイントは、User Account Linking成功後に api.access.deny でカスタマイズしたエラーを返す部分です。 まず前提として、このログインフローは「SSOユーザー」でログインした状態ですが、終了時点では「パスワードユーザー」に統合され、「SSOユーザー」は存在しなくなります。 なので「パスワードユーザー」に切り替える必要があります。 実は api.authentication.setPrimaryUser() というメソッドも用意されていますが、今回のCADDi Drawerの設定では使えませんでした。 以下を読む限り、Auth0 Organizationsで "Prompt for Credentials" を使用している場合は api.authentication.setPrimaryUser() を利用できないようです。 Error: Organizations is Not Supported Together with Primary User Modifications in Rules - Auth0 Community 苦肉の策として、カスタマイズしたエラーに organization と connection の情報を持たせ、Next.js側でハンドリングして、再度ログインする、という方法を取っています。 補足すると、再度ログインするといってもSSOなので、通常は何度かリダイレクトを挟むだけでユーザーの操作は不要です。そこまで大きな体験の悪化はありません。 データの不整合チェック 今回採用した設定だと、Auth0 Organizationsに所属していないユーザーはログインができなくなります。 万一そういったデータが発生した場合に備え、不整合を検出するスクリプトを作って自動でチェックをできるようにしました。 このスクリプトは、GitHub Actionsで毎日実行するように設定して、何かあれば通知が来るようにしました。 日次なのでリアルタイム性は若干低いものの、これによってデータに問題はないことを毎日確認でき、安心できました。 ちなみに、この記事を書いている時点では、テストデータなどを除いて不整合は発生していません。 3. 今後 ログイン体験の向上 現状では、テナントごとにSSO専用のURLを発行し、それを使用した場合のみSSOでログインできます。 これはログアウト後に再度SSOでログインするすることができないので、ユーザー体験が悪いなどの問題があります。 また、既存のユーザーにSSO専用URLが浸透しづらいというオペレーションの課題もあります。 専用URLなしでもSSOでログインできるように、ログインフローを見直しています。 ユーザー自身でのSSOの設定 現在は、キャディ側でSSOを設定していますが、ユーザー自身でSSOの設定を行えるようにしたいと考えています。 Self-Service Single Sign-On を使えないか検討しています。 おわりに 調査や下準備など含めると長い開発でしたが、ひとまず無事にSSOを提供できてよかったです。 まだまだ課題はありますが、少しずつ改善していきたいと思います。 We are hiring! キャディでは認証認可領域のエンジニアも絶賛募集中です! Senior Software Engineer, Backend - 認証認可 - / キャディ株式会社 recruit.caddi.tech
こんにちは、Data&Analysis部(D&A)です。 D&Aでは週1回、機械学習の勉強会を開催しており、本記事は、勉強会の内容を生成AIを活用して記事にまとめたものです。 ※勉強会内容公開の経緯は こちら ※過去の勉強会は「社内勉強会」タグからもご覧いただけます。 概要 GraphRAGの概要 RAGの概要と課題 GraphRAGの基本的な考え方 ナレッジグラフとは アルゴリズムの概要 標準的なGraphRAG MicrosoftによるGraphRAG GraphRAGの評価 ナレッジグラフの評価 GraphRAGの評価 ナレッジグラフ / GraphRAGの課題 ナレッジグラフの課題 GraphRAGの課題 参考文献 概要 今回の勉強会では、ナレッジグラフ(後述)とRAG(Retrieval-Augmented Generation)を組み合わせた技術であるGraphRAGについて調査しました。 調査の動機は、社内でRAGを用いたソリューションの検討が進められており、さらなるソリューションの創出に向けた一案としてGraphRAGが挙げられていたためです。 本記事では、GraphRAGのユースケース、アルゴリズムの概要、評価方法、そして課題について紹介します。 GraphRAGの概要 RAGの概要と課題 従来のRAGは、質問と意味的に類似する内容(コンテキスト)をベクトル検索によって抽出し、質問とコンテキストをプロンプトとしてLLM(Large Language Model)に入力することで回答を生成します。 しかし、ドメイン知識が関与する質問、特に物事間の関係性が重要な質問に対しては、関連性の低い内容を出力してしまうことがあります。 原因は回答を生成する過程で行う文章のチャンク化の際、ドメイン知識を表す文章が含まれていない可能性があるためとされています。 GraphRAGの基本的な考え方 GraphRAGは、RAGの課題を解決するために、ナレッジグラフを利用します。 質問のドメイン知識と合致する回答をナレッジグラフから出力し、それをコンテキストとすることで、質問により関連した内容を見つけ出せることが期待できます。 実務においては、強みと弱みを補い合うためにGraphRAGを全文検索やRAGと併用することが効果的です。 GraphRAGの基本的な流れは、自然言語である質問をグラフデータベース用のクエリ言語に変換し、データベースに問い合わせることでコンテキストを取得し、質問とコンテキストをLLMに入力して回答を得るというものです。 ナレッジグラフとは ナレッジグラフとは、エンティティ(ノード)同士をリレーション(エッジ)で繋いだグラフの集合のことです。 情報の関係性をグラフという繋がりで表現するため、RAGよりもドメイン知識を加味した回答を出しやすくなります。 例えば、「日本の首都は東京」という関係性は、"日本"と"東京"というエンティティが"首都"というリレーションで繋がったグラフとして表現されます。エンティティにはプロパティ(属性)を持たせることも可能です。 アルゴリズムの概要 GraphRAGのアルゴリズムは、大きく分けて下図の標準的なGraphRAGとMicrosoftによるGraphRAGの2種類があります。 標準的なGraphRAG インデクシング時: テキストなどの非構造化データからLLMを用いてエンティティとリレーションを抽出し、グラフ構造に変換してグラフデータベースに格納します。 検索時: 質問をLLMによってグラフデータベース用のクエリ言語(一般的にはCypher、他にGremlinやSPARQLなど)に変換し、グラフデータベースに問い合わせを行います。 グラフデータベースとしては、 Neo4j や Amazon Neptune などが例として挙げられます。 LangChain などのフレームワークを利用して実装することも可能です。 MicrosoftによるGraphRAG MicrosoftによるGraphRAGは、標準的なGraphRAGとは異なり、検索時にクエリ言語への変換を行いません。 インデクシング時: LLMを用いてテキストからエンティティとリレーションを抽出し、それらに紐づく文章の要約対応するベクトルをデータベースに格納します。 さらに、コミュニティ検出アルゴリズムを用いて内容が類似するエンティティをグループ化(このグループのことをコミュニティと呼ぶ)し、コミュニティ内のエンティティに紐づく文章を要約したコミュニティレポートと呼ばれるものを作成します。 検索時: 広い範囲の話題について概要を知ることをを目的としたグローバル検索(例:LLMについて教えて下さい)と、特定の話題に関して詳しく知ることをを目的としたローカル検索(例:OpenAIが提供しているLLM別に、その特徴と得意・苦手なタスク、活用事例を教えて下さい)の2つの方法質問に答えます。 グローバル検索では、各コミュニティレポートと質問に対する回答と重要度を出力し、重要度の高い回答をLLMのコンテキストにして回答を生成します。 ローカル検索では、エンティティの文章と質問文とのベクトル検索で意味が近いエンティティを抽出し、類似したエンティティに関連する情報をLLMが要約して回答します。 GraphRAGの評価 ナレッジグラフの評価 評価方法の例として以下のようなものがあります [6] 。 GNN(Graph Neural Network)を用いてナレッジグラフをベクトル化し、下流タスク(分類、ベクトル検索など)の精度で評価する。 正解データから重要な単語やエンティティを抽出し、その単語に関する内容がナレッジグラフにどれだけ含まれているかを評価する。 あるエンティティのサブカテゴリになるものがどれだけ含まれているか(=エンティティの多様性)を評価する。 例:人というエンティティに対して、友人、親子などの関連性のあるエンティティがナレッジグラフにどれだけ存在するか。 GraphRAGの評価 GraphRAGの評価方法としては、主に以下の2つがあります。 人手で作成した質問・回答と、GraphRAGが出力する内容との関連性を人手評価する。 LLMの評価手法を利用する(例: ragas 、 LLM as a judge など)。 MicrosoftのGraphRAGでは、LLMによって回答を評価する手法が用いられています。 ナレッジグラフ / GraphRAGの課題 ナレッジグラフの課題 同一内容のエンティティの統合: 表記揺れやデータソースの重複などにより、同一内容のエンティティが複数存在してしまうため、それらを統合する必要があります。 取り組みの例として、何らかのルールに基づいて統合する手法や、エンティティに関する情報を特徴量とし、エンティティの分類タスクを解き、同じ分類結果ならばエンティティを統合するという試みもあります。 情報の正確性の担保: 信頼できる情報源からデータを取得すること以外に、ナレッジグラフの情報の正確性をどのように担保するかが課題となります。 グラフの更新の難しさ: 既存のデータソースに含まれない情報(例:バズワード)、知識の変化(例:製品名が変わった)を既存のグラフとの整合性や既存のグラフ同士の関係性を保ったうえで更新させるのが難しいという課題があります。 GraphRAGの課題 トークン料の増大: エンティティ・リレーション抽出など、様々な処理でLLMを使用するため、トークン料が膨大になる可能性があります。 対策として、 Triplex などのエンティティ・リレーション抽出を効率化するツールも存在します。 その他の課題: 上記のナレッジグラフ自体の課題も、GraphRAGの品質に影響を与えるため、GraphRAGの課題と言えます。特に、同一内容のエンティティの統合については、 MicrosoftのGraphRAGに該当のフラグを設定する機能が計画されています が、本記事執筆時点(2025年4月)では未実装です。 参考文献 [1] GraphRAGをわかりやすく解説 [2] 話題のGraphRAG、その可能性と課題を理解する [3] 話題のGraphRAGとは - 内部構造の解析と実用性の考察 [4] From Local to Global: A Graph RAG Approach to Query-Focused Summarization [5] Welcome to GraphRAG [6] Structural Quality Metrics to Evaluate Knowledge Graphs
こんにちは、 Drawer Growth グループの大木です。 キャディでは、膨大な図面データを効率的に検索・活用できるよう、Elasticsearchを活用した図面検索機能を提供しています。 このシステムにより、キーワード検索から類似図面の検索、図面に紐づく受発注情報での検索などを実現しています。 しかし、この 図面と受発注情報 の関係は多対多の関係にあり、大量のデータを効率的に処理することが課題でした。 この記事では、これに対してどのように対処しているのかについて紹介します。 課題の詳細 前提: Elasticsearchにおけるデータ更新の流れ Elasticsearchに保存されるデータは、以下の2通りの方法で更新されます。 非同期の準リアルタイム更新 : 別のサービスから図面・受発注実績の情報が更新された時にメッセージを受け取り、Elasticsearchに反映します。 バッチ処理更新 : 日次バッチ処理で、図面データと受発注情報を統合したインデックスを作成しElasticsearchに保存します。 この記事では、バッチ処理更新の流れに焦点を当てて説明します。 多対多関係による性能の問題 図面データと受発注実績は 多対多の関係 にあります。 1つの図面に複数の受発注実績が紐づく可能性があります。 1つの受発注実績に複数の図面が紐づく可能性があります。 flowchart TD subgraph 図面データ D1[図面1] D2[図面2] D3[図面3] end subgraph 受発注実績 O1[受注実績1] O2[受注実績2] O3[発注実績1] O4[発注実績2] end D1 --- O1 D1 --- O2 D2 --- O1 D2 --- O3 D2 --- O4 D3 --- O2 D3 --- O4 style D1 fill:#ff9966,stroke:#333,stroke-width:2px,color:#000 style D2 fill:#ff9966,stroke:#333,stroke-width:2px,color:#000 style D3 fill:#ff9966,stroke:#333,stroke-width:2px,color:#000 style O1 fill:#6699cc,stroke:#333,stroke-width:2px,color:#000 style O2 fill:#6699cc,stroke:#333,stroke-width:2px,color:#000 style O3 fill:#99cc99,stroke:#333,stroke-width:2px,color:#000 style O4 fill:#99cc99,stroke:#333,stroke-width:2px,color:#000 この関係性により、受注実績を1件更新すると、紐づく図面データN件を更新する必要があります。 そして、受注実績をM件アップロードすると、理論上O(N*M)の計算量が必要になります。 flowchart LR subgraph "受注実績M件の更新" O1[受注実績1] O2[受注実績2] OM[受注実績M] end subgraph "紐づく図面N件の更新" O1 --> |更新| D1[図面1-1] O1 --> |更新| D2[図面1-...] O1 --> |更新| D3[図面1-3] O2 --> |更新| D4[図面2-1] O2 --> |更新| D5[図面2-...] O2 --> |更新| D6[図面2-3] OM --> |更新| D7[図面M-1] OM --> |更新| D8[図面M-...] OM --> |更新| D9[図面M-3] end style O1 fill:#6699cc,stroke:#333,stroke-width:2px,color:#000 style O2 fill:#6699cc,stroke:#333,stroke-width:2px,color:#000 style OM fill:#6699cc,stroke:#333,stroke-width:2px,color:#000 style D1 fill:#ff9966,stroke:#333,stroke-width:2px,color:#000 style D2 fill:#ff9966,stroke:#333,stroke-width:2px,color:#000 style D3 fill:#ff9966,stroke:#333,stroke-width:2px,color:#000 style D4 fill:#ff9966,stroke:#333,stroke-width:2px,color:#000 style D5 fill:#ff9966,stroke:#333,stroke-width:2px,color:#000 style D6 fill:#ff9966,stroke:#333,stroke-width:2px,color:#000 style D7 fill:#ff9966,stroke:#333,stroke-width:2px,color:#000 style D8 fill:#ff9966,stroke:#333,stroke-width:2px,color:#000 style D9 fill:#ff9966,stroke:#333,stroke-width:2px,color:#000 この計算量の問題から、受発注実績の更新はリアルタイムで行わず、バッチ処理で日次適用する運用としています。 しかし問題は、このバッチ処理自体が非常に時間がかかるようになってきたことです。 このままテナント数が増えていくと、1日では処理しきれないデータ量になる可能性があります。 処理遅延の原因 バッチ処理の遅延には大きく2つの原因がありました。 処理ロジックの非効率性 シリアルな処理構造によるI/O待ち時間の蓄積 同時に実行できる処理を順番に行うことによるリソース利用効率の低下 差分更新ではなく全件更新をする 一からドキュメントを作り直すアプローチのためそもそも時間がかかる 今回は、1の「処理ロジックの非効率性」の改善に焦点を当てて解説します。 処理フローと改善前の状況 日次更新処理の流れ 図面データ、受注実績、発注実績はそれぞれ別のインデックスで管理し、準リアルタイムに更新されています。 そして1日に一度、これらのデータを全て正規化した統合インデックスを一から作成します。 元の処理フローは以下の通りでした。 flowchart LR A["図面データ取得(500件ずつ)"] --> B["受発注データ付加(1件ずつ処理)"] B --> C["インデックス保存(500件ずつ)"] style A fill:#ff9966,stroke:#333,stroke-width:2px,color:#000 style B fill:#99cc99,stroke:#333,stroke-width:2px,color:#000 style C fill:#6699cc,stroke:#333,stroke-width:2px,color:#000 各ステップのボトルネックはI/Oであり、特に2のステップでの受発注データの取得が多くを占めていました。 逆に1, 3のステップは比較的早いのですが、シリアルに処理されているため、全体の処理時間が2に引っ張られていました。 改善方針とアプローチ Producer-Consumerパターンによる並列処理 今回のアプローチは、処理をProducer-Consumerパターンに基づいて分割し、並列実行することです。 これによりあるステップがI/O待ちしている間にも他のステップを進めることができます。 flowchart TD Producer["図面取得(Producer)"] --> QueueA["Queue A(図面データキュー)"] QueueA --> Processor["受発注データ付加処理(Consumer & Producer)"] Processor --> QueueB["Queue B(正規化済み図面データキュー)"] QueueB --> Consumer["インデックス処理(Consumer)"] style Producer fill:#ff9966,stroke:#333,stroke-width:2px,color:#000 style QueueA fill:#e6e6e6,stroke:#333,stroke-width:2px,stroke-dasharray: 5 5,color:#000 style Processor fill:#99cc99,stroke:#333,stroke-width:2px,color:#000 style QueueB fill:#e6e6e6,stroke:#333,stroke-width:2px,stroke-dasharray: 5 5,color:#000 style Consumer fill:#6699cc,stroke:#333,stroke-width:2px,color:#000 このパターンを実現するために、TypeScriptで BlockingQueue クラスを実装し、非同期処理を効率的に制御します。 ※検索バックエンドにはTypeScript(nodejs)を使用しています。 実装の詳細 BlockingQueueの実装 このクラスはproducerとconsumerの間で非同期にデータを受け渡すための仕組みを提供します(コードは簡易版です)。 export class BlockingQueue< T > { private queue : T [] = [] ; private producers : ( ( ) => void )[] = [] ; private consumers : ( (item : T ) => void )[] = [] ; constructor ( private readonly maxSize : number ) {} async enqueue ( item : T ): Promise < void > { if ( this .queue. length >= this .maxSize) { // キューがいっぱいの場合、Producerを待機させる await new Promise < void >(( resolve ) => this .producers. push (resolve)); } this .queue. push (item); // 待機中のConsumerがあれば、アイテムを渡して処理を再開させる if ( this .consumers. length > 0 ) { const consumer = this .consumers. shift (); consumer!( this .queue. shift ()!); } } async dequeue (): Promise < T > { if ( this .queue. length === 0 ) { // キューが空の場合、Consumerを待機させる const item = await new Promise < T >(( resolve ) => this .consumers. push (resolve) ); return item; } const item = this .queue. shift ()!; // 待機中のProducerがあれば、処理を再開させる if ( this .producers. length > 0 ) { const producer = this .producers. shift (); producer!(); } return item; } get length () { return this .queue. length ; } } この実装の主なポイントは、以下の通りです。 Promiseを使用してキューの状態に応じて処理をブロックします。resolveすることで処理を再開します。 キューがいっぱいの場合はproducerの処理を一時停止し、キューが空の場合はconsumerの処理を一時停止します。 バッチ処理への適用 このBlockingQueueを使用して、先に示した3つのステップを並列に実行するパイプラインを構築しました。 // 1. 図面データ取得プロセス(Producer) async function drawingProducer ( drawingQueue : BlockingQueue < Drawing >) { for await ( const batch of fetchDrawingBatches()) { for ( const drawing of batch) { await drawingQueue.enqueue(drawing); } } // 終了シグナルを送信(nullを指定回数エンキュー) for ( let i = 0 ; i < ENRICHER_COUNT ; i++) { await drawingQueue.enqueue( null as any ); } } // 2. 受発注データ付加プロセス(Consumer兼Producer) async function orderEnricher ( drawingQueue : BlockingQueue < Drawing > , enrichedQueue : BlockingQueue < EnrichedDrawing > ) { while ( true ) { const drawing = await drawingQueue.dequeue(); // 終了シグナルのチェック if (drawing === null ) { // 次のステージに終了シグナルを伝播 await enrichedQueue.enqueue( null as any ); break ; } // 受発注データを付加 const purchaseOrders = await fetchPurchaseOrders(drawing. id ); const salesOrders = await fetchSalesOrders(drawing. id ); const enrichedDrawing = enrichDrawing(drawing, purchaseOrders, salesOrders); await enrichedQueue.enqueue(enrichedDrawing); } } // 3. インデックス更新プロセス(Consumer) async function indexConsumer ( enrichedQueue : BlockingQueue < EnrichedDrawing >) { const bulkOps = [] ; let endSignalCount = 0 ; while (endSignalCount < ENRICHER_COUNT) { const enrichedDrawing = await enrichedQueue.dequeue(); // 終了シグナルのチェック if (enrichedDrawing === null ) { endSignalCount++; continue ; } bulkOps. push (enrichedDrawing); // バルク操作のバッチサイズに達したらインデックス更新 if (bulkOps. length >= BULK_SIZE) { await bulkIndex(bulkOps); bulkOps. length = 0 ; } } // 残りのアイテムがあればインデックス更新 if (bulkOps. length > 0 ) { await bulkIndex(bulkOps); } } // メイン処理 async function main () { const drawingQueue = new BlockingQueue< Drawing >( 1000 ); const enrichedQueue = new BlockingQueue< EnrichedDrawing >( 1000 ); // Producerの起動 const producer = drawingProducer(drawingQueue); // 複数のエンリッチャーを並列起動(I/O待ちが多いため) const enrichers = Array . from ( { length : ENRICHER_COUNT } , () => orderEnricher(drawingQueue, enrichedQueue) ); // 複数のインデクサーを並列起動(ES向けの書き込み並列化) const indexers = Array . from ( { length : INDEXER_COUNT } , () => indexConsumer(enrichedQueue) ); // すべての処理が完了するのを待つ await Promise . all ( [ producer, ...enrichers, ...indexers ] ); // 処理が完了したらインデックスを切り替える await switchIndex(); } 先ほど実装した BlockingQueue を使用して、各ステップのジョブが独立して並列に実行されるようにします。 終了シグナルの管理 null をキューに送ることで処理終了を通知します。 各ステージで終了シグナルを適切に伝播させ、すべての処理が確実に完了するよう設計します。 並列処理の最適化 ENRICHER_COUNT と INDEXER_COUNT パラメータで並列度を調整できます。 I/O待ちが多い箇所(特に受発注データの取得)で並列度を上げることが可能です。 これにより、ボトルネックとなっていた2のステップでのI/O待ち時間を有効活用し、全体の処理時間を短縮できます。 実際に導入した結果 実はこれ以外にも改善をいくつか行っており、それらの結果も含まれるのですが全体としてはジョブ完了までの時間を1/2以下に短縮することに成功しました。 最後に TypeScriptを用いたバッチ処理のパフォーマンス最適化について、実際のプロジェクトでの取り組みを紹介しました。 シリアル処理では待ち時間が積み重なるが、Producer-Consumerパターンを活用することで効率的な並列処理が実現可能です。 特に今回のような外部サービスとの通信が頻繁に発生する場合に効果的です。 皆さんのプロジェクトでも、ぜひこのようなパターンを活用してパフォーマンス問題に対処してみてください。 最後に、キャディでは現在エンジニアを絶賛採用中です。 また、キャディの検索周りはまだまだ課題が残っています。 本記事を読んで興味を持ってくれた方は、ぜひ一緒に解決していきましょう。 https://recruit.caddi.tech/
こんにちは、柴犬がかわいい。Tech本部の前多です。 先日、弊社でApache IcebergとTrinoによる活用事例についての記事を上げました。 caddi.tech 記事では、Icebergへのデータ投入について次の記述がありました。 ユーザがアップロードしたCSVファイルをパースしてIcebergに保存する 図面の解析結果を一定間隔のバッチで受け取りIcebergに保存する 実際のところ、ファイルからIcebergへのデータ投入はサイズによっては困難なことがありました。 今回はIcebergへのデータ投入に関するTopicをお伝えします。 データ投入で発生した課題 私たちは、クエリエンジンとしてTrinoを採用しています。 データ投入の経路はCSVファイルしかないので、CSVファイルを解析して一行ごとに TrinoのInsert文 を発行すれば十分だろうと考えていました。 また、TrinoのInsert分は以下のような複数行の一括投入も可能なので、それである程度効率よく処理ができるだろうと踏んでいました。 INSERT INTO iceberg.some_schema.some_table VALUES ( 1 , ' test1 ' ), ( 2 , ' test2 ' ),,,,,; 少量のデータでは、この方法でも問題はありませんでした。 しかし性能テストのために1000万件程度のデータを投入しようとし始めた時から、次の問題がでてきました。 1. 時間がかかりすぎる テストデータの投入を前述のtrinoの複数行INSERTを使って、10行から200行の範囲でまとめて挿入する方法で当初行っていました。 10万件程度の投入はおおよそ15分程度で終わっていたので許容範囲だと思っていましたが、 100万件の投入を超えたあたりからどんどん一度のINSERTにかかる時間が伸びていくようになりました。 Icebergのメタデータファイルの増加、GCSの負荷増加、Trinoクラスタの負荷増加などさまざまな理由が考えられますが、Trinoで連続したデータ投入を行うのは難しいのではと思い始めました。 Trinoクラスタのスケールアップなどにより改善した可能性はありますが当時は後述する別の手段を採用しています。 2. Iceberg メタデータが増え続ける Icebergはテーブル単位のレコード操作についてトランザクションのサポートがあり、トランザクションごとにデータファイルやメタデータ、マニフェストファイルが作成されます。 これは、細かいトランザクションを何度も行うとメタデータファイルが肥大化していきます。 Icebergには、古いメタデータファイルをコミット時に破棄する write.metadata.delete-after-commit.enabled というオプションがあるのですが、 これは現時点ではTrinoでサポートされていません。Issueはありますが、まだ進行中です。 iceberg.apache.org github.com 数百万件のレコードを投入した時点で、メタデータファイルは何度もInsertを繰り返した結果100MBを超える状態となっているものもあり、 これがデータ投入が遅くなった要因の1つであったと考えます。 なるべく一度のトランザクションでデータをまとめて投入する、メタデータファイルをメンテナンスするなどの必要性がわかりました。 3. ファイル単位のトランザクション制御ができない Trinoはトランザクションに関するSQLはありますがほとんどのコネクタではサポートされていません。 SQL statement support — Trino 474 Documentation Trino Iceberg connectorも同様で、原則的にauto commitで動作します。auto commit以外を設定するとエラーになりました。 そのため、Icebergに対する複数のSQL実行に対するトランザクションはなく、Trinoでは1回のINSERTでIcebergのトランザクションとなります。 よって、ファイルの各行を分割してINSERT文を発行すると、細かいコミットが詰まれていくので、ファイルデータの途中にエラーがあって処理を停止した場合、Icebergには中途半端なデータが残ったままになります。 ただしこの仕様は事前に把握していました。 そこで、今回は投入するデータに投入元のファイルIDを持たせて、中途半端なデータは後から削除できる仕様としています。 そのため、大きな問題にはなりませんが、できるならファイル単位でIcebergへのデータ投入が成功したか失敗したかのどちらかになっているのが望ましいです。 このように、私たちのケースのようなそこそこのサイズのファイルをIcebergに投入するにあたって、Trino経由のデータ投入では扱いづらいことがわかってきました。 Trino以外の手段ではApache Sparkを使うのが王道だったと思いますが、当時Trinoに加えてSparkクラスタも構築するのは現実的ではありませんでしたし、上記全ての問題が解決するのかはわかっていませんでした。 そこで、IcebergのJava APIを使用して直接Icebergにデータを書き込むことにしました。 なお、余談ですがその時に Apache Beam® (Google Cloudのマネージドサービス、Dataflowの中身)も使えないかを見ていました。 確認したところApache BeamのIcebergサポートはバッチモードではレコード1件につき1コミットとなるようで、今回の要件にはマッチしないと判断しました。基本的にはApache Beamはストリームで扱った方が良さそうです。 Iceberg Java APIについて Iceberg Java APIはIcebergテーブルフォーマットに従ったデータファイル、メタデータ、マニフェストファイルを作成し、Catalogと連携してファイルのコミットを行ってくれるライブラリです。 あまり解説されているサイトは少ないのですが公式や、Tarbularのブログのほか日本での事例解説があり、参考にさせていただきました。 iceberg.apache.org www.tabular.io knowledge.sakura.ad.jp 今回解説するソースコードの全量は こちら にあります。 Docker compose, テストコードもあるので手元で試せます。 Catalogの取得、テーブルの作成 まずは、Catalogを取得します。 今回はREST Catalogを使用し、CatalogのURIやオブジェクトストレージの認証情報を設定して初期化します。 public static RESTCatalog getCatalog(String catalogUri) { var catalog = new RESTCatalog(); Map<String, String> catalogConfig = new HashMap<>(); catalogConfig.put( "type" , "rest" ); catalogConfig.put( "uri" , catalogUri); // TODO , 実際の環境に合わせて設定内容を変えること catalogConfig.put( "io-impl" , "org.apache.iceberg.aws.s3.S3FileIO" ); catalogConfig.put( "s3.endpoint" , "http://localhost:9000" ); catalogConfig.put( "s3.path-style-access" , "true" ); catalogConfig.put( "s3.region" , "us-east-2" ); catalogConfig.put( "s3.access-key-id" , "admin" ); catalogConfig.put( "s3.secret-access-key" , "password" ); catalog.initialize( "rest" , catalogConfig); return catalog; } Catalog経由でIcebergテーブルを操作します。 テーブルの取得や作成、スキーマ変更などができます。 テーブルを作る場合はスキーマやパーティションなどの定義が必要で、今回は4項目を持つスキーマを用意します。 /** 4項目を持つテーブルのスキーマの例 */ public static final Schema SCHEMA_SAMPLE = new Schema( List.of( Types.NestedField.required( 1 , "id" , Types.UUIDType.get()), Types.NestedField.required( 2 , "name" , Types.StringType.get()), Types.NestedField.required( 3 , "price" , Types.IntegerType.get()), Types.NestedField.required( 4 , "registered_at" , Types.TimestampType.withZone()))); /** name属性のハッシュ値によるパーティションの例 */ public static final PartitionSpec SAMPLE_PARTITION = PartitionSpec.builderFor(SCHEMA_SAMPLE) .bucket( "name" , 16 ).build(); Catalogにスキーマ、パーティション、テーブルプロパティなどを設定してテーブルを作成します。 テーブルのオブジェクトストレージ上のパスも自分で決めます。論理的なテーブル名と同じにしてしまうとリネームや名前の衝突などで困るため、ハッシュ値などを含めた方が良いでしょう。 // namespaceの取得 var ns = catalog.loadNamespaceMetadata(Namespace.of(namespace)); // オブジェクトストレージのテーブルのパス var location = ns.get( "location" ) + "/" + table + "-" + UUID.randomUUID().toString().replaceAll( "-" , "" ); var table = catalog // ネームスペース、テーブル名、スキーマの指定 .buildTable(TableIdentifier.of(Namespace.of(namespace), table), schema) .withLocation(location) // パーティション、ソートオーダーなどの指定 .withPartitionSpec(partitionSpec) .withSortOrder(sortOrder) // テーブルプロパティの指定 .withProperties( Map.of( "write.metadata.delete-after-commit.enabled" , "true" , "write.metadata.previous-versions-max" , "100" , "write.object-storage.enabled" , "true" )) .create(); ここまでが下準備です。次から実際にIcebergテーブルにデータを書き込んでいきます。 シンプルなデータ投入手順 Icebergテーブルはデータファイルやメタデータファイル、マニフェストテーブルから構成されています。 Java APIを使ったプログラムでは、主にデータファイルを作成します。 データファイルに連なるマニフェスファイルやコミットで生成するメタデータファイルについてはAPIやCatalogの内部で隠蔽されているので、あまり意識する必要はありません。 まずはパーティションがないテーブルのようなシンプルな実装例を紹介します。 Catalog, tableがある前提で、トランザクションを開始してAppendオペレーションを開始し、データファイルを作成するための DataWriter を取得します。 var catalog = TableUtil.getCatalog(restCatalogUri); var tbl = TableUtil.getOrCreateTableAndNamespace( catalog, namespace, table, SampleDefinition.SCHEMA_SAMPLE, PartitionSpec.unpartitioned(), SortOrder.unsorted()); // トランザクションを開始して、Appendオペレーションを開始する。 var transaction = tbl.newTransaction(); var append = transaction.newAppend(); // データファイルのパスは自分で決める。ハッシュ、日時などを入れて衝突しないようにする。 var fileId = OffsetDateTime.now().format(DateTimeFormatter.ofPattern( "yyyy-MM-dd_HHmmss" )) + "_" + UUID.randomUUID(); // メタデータ類とは異なるパスに配置されるように /data を含める String filepath = tbl.location() + "/data/" + fileId + ".parquet" ; // DataWriterの取得 var file = tbl.io().newOutputFile(filepath); var dataWriter = Parquet.writeData(file) .schema(tbl.schema()) .createWriterFunc(GenericParquetWriter::buildWriter) .overwrite() .withSpec(PartitionSpec.unpartitioned()) .build(); 上記では、テーブルから newTransaction でトランザクションを開始して、次にトランザクションから newAppend でAppendオペレーションを開始していますが、 オペレーションが1つだけならtableから直接Appendオペレーションを作成できます。 オペレーションの種類は こちら にあります。 Appendはデータを追加するだけの単純なオペレーションで、トランザクション競合も起きません。そのほかに削除、データ更新など複数のオペレーションがありますが、今回のケースはデータ追加だけを行いますので触れません。 興味がある方は以下の記事を参考にしてください。 bering.hatenadiary.com データファイルのパスも自分で決める必要があります。ハッシュ値、タイムスタンプを含めて衝突を避けたり、オブジェクトストレージのパスを分散させて効率を高めたりするなどの工夫は自分で行います。 DataWriterへ、ファイルの一行ごとにParquet形式のデータに変換して書き込んでいきます。 最初に定義したテーブルスキーマのフィールドと型を一致させる必要があります。型変換についてはGitHubのコードを参照してください。 var record = GenericRecord.create(tbl.schema()); try ( var lines = new JsonlReader(input)) { // data add to parquetWriter while (lines.hasNext()) { var r = lines.next(); var row = record.copy(TableUtil.convertRecord(tbl.schema(), r)); dataWriter.write(row); } } レコードの追加が終わったら、書き込みを close で終了させ、その後データファイルに変換した後Appendオペレーションにデータファイルを登録します。 もし、レコードの件数やサイズに応じて複数のデータファイルを生成したい場合は、繰り返しデータファイルの生成を行なってオペレーションに登録します。 最後にAppendオペレーションのcommit、トランザクションのcommitを行うと、Catalogで競合状態を確認します。 問題なければ各種マニフェストファイルが生成、コミットされます。 これで、Icebergのデータ投入は完了です。 // writing finish, then commit data file. dataWriter.close(); var dataFile = dataWriter.toDataFile(); append.appendFile(dataFile); // commit append.commit(); transaction.commitTransaction(); データの取得、確認 ユニットテストでIcebergからデータを取得してみます。 データ取得は Scan で行います。データファイル単位で取得する方法とレコード単位で取得する方法があり、ここでは後者の方法を採用します。 以下のように、 IcebergGenerics.read(table) からselect, whereを指定してScanオブジェクトを取得し、Scanから1件ずつレコードを取得していきます。 var result = new ArrayList<Map<String, Object>>(); var scan = IcebergGenerics.read(table) // select, where の指定ができる //.select("id", "name") //.where(Expressions.lessThan("price", 100)) .build(); for ( var i = scan.iterator(); i.hasNext(); ) { var data = i.next(); var map = new HashMap<String, Object>(); for ( int k = 0 ; k < data.size(); k++) { var field = SampleDefinition.SCHEMA_SAMPLE.findField(k + 1 ); map.put(field.name(), data.get(k)); } result.add(map); } 余談ですが、通常のSQLと異なり、集計、関数、ソートといった操作や結合はサポートされていませんので、こういった操作はクエリエンジン側で行います。これらの操作がコストが高くなる理由がわかります。 テストを実行すると、Icebergへのデータ投入とその確認が検証できます。 また、以下のようにMinioコンソールで作成されたファイルを確認できます。 メタデータファイルと、1件のデータファイルが確認できます。 また、このデータはTrinoからクエリすることももちろん可能です。 Partitionに対応したデータファイルの書き込み 前述の方法は単純で件数が少ないテーブルであれば十分ですが、一方でJava APIが提供する機能はプリミティブなものだと私は感じました。 例えば、パーティションキーごとにデータファイルを分けたり、サイズに応じてデータファイルを分割するのは自分で行う必要があります。 そういった時に役立つのが org.apache.iceberg.ioパッケージの便利クラス です。 Partitionに対応したデータファイルを作成可能な PartitionedFanoutWriter がありますのでこれを使ってみます。 以下のように、appenderFactory, outputFileFactoryを生成してこれをPartitionedFanoutWriterに渡します。 outputFileFactoryはファイルを作成する情報となるpartitionId,taskId,ファイルフォーマットを受け取り、パーティションごとに分割したデータファイルのパスに含めます。 もし対象テーブルにパーティションがない場合は、 UnpartitionedWriter を代わりに使います。 ファイルサイズを指定でき、 UnpartitionedWriter を使う場合でもファイルサイズでデータファイルが分割できるので便利です。 Writerを作った後のレコード挿入はこれまで通りです。 var appenderFactory = new GenericAppenderFactory(tbl.schema()); // 複数プロセスで同時に挿入する場合は、partitionId, taskIdをプロセスごとに分けないと、同名のファイルを作ってしまう。 int partitionId = 1 ; int taskId = 1 ; var outputFileFactory = OutputFileFactory.builderFor(tbl, partitionId, taskId).format(FileFormat.PARQUET).build(); final PartitionKey partitionKey = new PartitionKey(tbl.spec(), tbl.spec().schema()); // partitionの有無に応じて、 Writerの実装を分ける。 // writerはサイズを加味してデータファイルを分割し、 // さらにPartitionedFanoutWriterは、パーティションの値でデータファイルを分割する。 var writer = partitioned ? new PartitionedFanoutWriter<Record>( tbl.spec(), FileFormat.PARQUET, appenderFactory, outputFileFactory, tbl.io(), DATAFILE_MAX_SIZE) { @Override protected PartitionKey partition(Record record) { partitionKey.partition(record); return partitionKey; } } : new UnpartitionedWriter<Record>( tbl.spec(), FileFormat.PARQUET, appenderFactory, outputFileFactory, tbl.io(), DATAFILE_MAX_SIZE); var record = GenericRecord.create(tbl.schema()); try ( var lines = new JsonlReader(input)) { // data add to parquetWriter while (lines.hasNext()) { var r = lines.next(); var row = record.copy(TableUtil.convertRecord(tbl.schema(), r)); writer.write(row); } } レコードの挿入が終わったら、writerが作ったデータファイル一覧をappendオペレーションに追加してコミットします。 for ( var dataFile : writer.dataFiles()) { append.appendFile(dataFile); } LOG.info( "insert complete. append commit" ); append.commit(); transaction.commitTransaction(); テストを実行し生成されたファイルを見ると、 /data/nnnn/nnnn/nnnn/nnnnnnnn/<field>_bucket=[hash]/ というパスでデータファイルが分割されていることがわかります。 マニフェストリストファイル(avro形式)にはデータファイルの情報が含まれています。これを確認すると、4つのデータファイルに分割されていることがわかります。 このスナップショットは、Web上でAvroを解析してくれる https://konbert.com/ の表示内容です。 これで、Java APIを使用したPartitionありのテーブルのデータ投入もできました。 まとめ Java APIを直接利用することで、私たちの場合以下のような改善ができました。 1000万件のデータ投入が、全く終わらない状況から15分に短縮できた 性能テストのデータ投入のための改善だったが、ユーザーファイル取り込みや他システムのデータ取り込みの高速化に流用できた 1ファイルのデータ投入がIceberg上の1トランザクションで実行できるようになった 一方で、Java APIの利用は、データ追記、あるいは洗い替えのための全データ削除といった単純なオペレーションで高速化が必要な場合のみに留めています。 その理由は、Java APIはデータファイル単位での操作に特化しているためです。 例えば、データの更新は、更新対象のレコードを含むデータファイルを特定し、更新後のレコードを含むデータファイルを作成し直して上書きするか無効化するといった操作が必要です。 org.apache.iceberg.ioパッケージ には変更操作をまとめてくれるような機能がありそうですが、それでも難しい操作であることには変わりはなく、このような場合はSparkやTrinoで抽象化された仕組みを使った方が良いでしょう。 以上です、クエリエンジンの仕組みと気持ちがちょっとわかるようになりました。
こんにちは、Drawer Growthグループ所属エンジニアの中山です。 今回は、先月から個人的に始めたOSSへのコントリビューション活動についてご紹介します。 ※ 会社としての取り組みではなく、あくまで個人の取り組みになります。 背景 弊社が開発している図面データ活用クラウド「CADDi Drawer」では、バックエンドの一部にRustを利用しています。また、クラウドプラットフォームとしてGoogle Cloudを利用しています。 現状、RustでGoogle Cloudのリソースを操作する際には課題があります。それは正式版(GA)のRust向けGoogle Cloud公式ライブラリがまだ存在しないということです。よってサードパーティライブラリを使うか、自前で実装する必要があります。弊社でも、サードパーティライブラリと自前実装を組み合わせて運用している状況ですが、サードパーティライブラリが今後もメンテされ続けるという保証はなく、自前実装は何かとコストがかかるため公式のライブラリを使いたいという思いがあります。 上記で「正式版」と書いたのは、実はExperimentalな公式ライブラリがあるからです。 github.com このライブラリは現在開発中で、本番環境での使用は推奨されていません( link )。 The APIs are not stable, they are not ready for use in production code. このライブラリが正式版(GA)になれば、弊社を含め多くのRustユーザーが恩恵を受けられるはずです。そこで「このライブラリ開発に少しでも関わって、サードパーティ/自前実装をいずれ置き換えられる道を探りたい」という動機からコントリビューション活動を始めました。 Google Cloud API Client Libraries for Rustとは Google Cloud API Client Libraries for Rustとは、Googleが開発中のRust向けGoogle Cloud APIクライアントライブラリです。リポジトリを見ると、初回のコミット自体は2021年ですが、実際に開発が活発化し始めたのは2024年10月頃からで比較的最近開発が活発になってきています。 Commits over time また、READMEには「Contributions to this library are always welcome and highly encouraged.」と明記されており、外部からのコントリビューションを大いに歓迎しています。コントリビュートの流れもシンプルで、Contributor License Agreement(CLA)にサイン→PRを送る→レビュー→マージという手順で進むようになっています。 コントリビュートまでの流れ まず、社内でサードパーティや自前で実装している部分のライブラリを実装できないか、Issueを立ててリクエストしてみました。しかし、現在Google側でデザインを検討中でまだ対応できる段階ではないという返答をいただき断念することにしました。 一方、リポジトリには既に多くのIssueがあり、初心者向けとして good first issue ラベルが付いているものもあります。まずはそちらから着手してみることにしました。 私が今回取り組んだIssueは以下です。 github.com このIssueの目的は、認証レイヤーに異常系のUnit Testを追加することでした。このIssueに取り組んでいいことを確認し、OKをもらったうえでやるべき内容を詳しく教えていただきました。やることは主に以下の2点でした。 全てのエラーをリトライしているのを、エラー内容に応じてリトライするように変更する 認証の異常系に関するUnit Testを追加実装する 対応のフローとしては、リポジトリをFork → Fork先のリポジトリで実装 → 本家リポジトリへPull Request → レビュー後にマージ、という一般的な手順でした。私の出したPRは以下です。 github.com コントリビュートしてみた感想 想像していたよりもあっさりできた 私はこれまでOSSを利用することはあっても、コントリビュートする機会はほとんどありませんでした。Rustは比較的新しい言語で、活発なコミュニティが魅力ですが、実際にコントリビュートするとなると少しハードルが高いように感じていました。 今回、思い切ってIssueにコメントを残してみたところ、1~2日以内に返信してくれ、かつPRもすぐにレビューしてくれるなど、非常にオープンでフレンドリーな雰囲気を感じました。「OSSコミュニティ」というと敷居が高いイメージを抱いていましたが、とても取り組みやすかったです。 かかった時間は、Issueでのやりとりが1~2日、実装が1日、PRのレビューも1~2日で終わり、トータルで1週間ほどでマージまで進めることができました。 勉強になった さらに大きな収穫だったのが、社外のコードを読む機会が得られたことです。Rustは今のチームに異動してから触り始め、ほとんど社内のコードしか経験がない状態でした。実際にGoogleが管理/運用しているコードベースや、他のコントリビューターの実装・レビューのやりとりを見ることで、これまで知らなかった設計パターンやテストの書き方など、多くの学びを得ました。 うれしい 余談です。Google公式リポジトリに自分のアイコンがあるとやっぱりテンション上がりますね。 まとめ 本記事では、Googleが開発中のGoogle Cloud API Client Libraries for Rustにコントリビュートした体験を紹介しました。このプロジェクトはまだ開発初期であり、貢献できそうなIssueもたくさんありそうです。 READMEにもある通り、外部からのコントリビューションを歓迎しているため、誰でも参加しやすい状況でコントリビューションチャンスです。 ひとまず1st contributionを達成できましたが、当初の目的である自社実装の置き換えはまだ実現していません。今後も継続的にコントリビューション活動を行い、機能拡充をサポートしていきたいと思います。興味ある人はぜひ一緒にやりましょう! 最後に、キャディでは現在エンジニアを絶賛採用中です。本記事を読んで興味を持ってくれた方はぜひご連絡ください。一緒にRustのエコシステムを充実させていきましょう! recruit.caddi.tech
こんにちは、 Drawer Growth グループの高藤です。先日、弊社の江良が活用事例として取り上げた Apache Iceberg の活用事例 にあるよう、キャディでは Apache Iceberg を採用したデータレイクハウスの構築を行っています。前回に引き続き今後計画していることについて紹介したいと思います。 先日の江良がまとめた活用事例にもある通り、現在構築しているデータレイクハウスでは、お客様が手元にある構造化データに対して、お客様自身でデータをアップロードし CADDi Drawer 内で利用できるようにしています。データレイクハウスを通じて、お客様固有のデータを CADDi Drawer 内で大量に扱うことができるようになりました。 その一方で、まだまだ解決しないといけない課題もあります。前述の記事のなかでも触れられているとおり、「全社を横断したプラットフォーム」への取り組みも必要になっています。 現在のデータレイクハウスは、お客様が手動でアップロードしたデータのみを扱っている状態です。しかし、CADDi Drawerのサービス内では、日々大量のデータが生成・更新されています。図面解析結果、受発注実績や見積りなどの業務プロセスデータなど、これらの貴重なデータはまだデータレイクハウスには統合されていません。 これらのデータが統合されていない主な理由は、CADDi Drawerのシステムアーキテクチャにあります。私たちのサービスは、複数のマイクロサービスやコンポーネントから構成されており、それぞれが独立したデータベースに情報を永続化しています。この分散アーキテクチャは柔軟性と拡張性を提供する一方で、統合的なデータ分析を難しくしています。 このような背景から、私たちは現在、これらの分散データを効率的にデータレイクハウスへ統合する方法を検討しています。その有力な解決策として、Change Data Capture (CDC) の導入を計画しています。CDCは各データベースの変更を継続的に捕捉し、それをデータレイクハウスへ伝播させる仕組みを提供します。 本記事では、Apache Iceberg ベースのデータレイクハウスに対して、CDC を用いたデータ統合アプローチを調査し、現在検討にあげている実装案を共有します。これは実装前の調査・計画段階の内容ですが、同様の課題に取り組む方々や、興味がある方にとって参考になれば幸いです。 統合すべきデータとその課題 冒頭で触れたように、CADDi Drawer内部で生成・更新される様々なデータをデータレイクハウスに統合する必要があります。ここではそれらのデータ特性と統合における課題を簡潔にまとめます。 様々なデータを統一的なデータ基盤上で分析できるようにするには、データレイクハウスに統合する必要があるのは、先日の江良がまとめた活用事例にもある通りです。 現在のデータレイクハウスでは、お客様がアップロードした構造化データを扱っていますが、サービス内で生成・更新されるデータはまだ統合されていません。これは、システムが複数のコンポーネントから構成され、それぞれが独立したデータベースに情報を永続化しているためです。 この分散アーキテクチャは開発の柔軟性と拡張性を提供する一方で、データの横断的な活用を難しくしています。そこで我々は、既存のデータベース構造を維持しながら、データレイクハウスを通じた統合的な活用基盤の構築を目指しています。 このような統合環境を実現するためには、各サービスのデータ更新を継続的かつ効率的にデータレイクハウスに反映する仕組みが必要です。そこで、Change Data Capture(CDC)技術の導入を検討しています。 CDCを活用したデータ統合アプローチ 前章で述べた課題を解決し、既存のデータベースを維持しながらデータレイクハウスでの横断的活用を実現するため、Change Data Capture(CDC)の採用を検討しています。 Change Data Capture (CDC)の基本概念 cdc CDCとは、データベース内の変更をリアルタイムで検出し、その変更情報を他のシステムに伝播させる技術です。従来のバッチ処理による全量転送と異なり、「変更があったデータのみ」を効率的に転送します。 「変更があったデータ」とはデータベースに適用された(INSERT/UPDATE/DELETE)を補足し、変更前後の値とメタデータ(テーブルスキーマなどの情報)のことを指します。 (ボヤキ) 以前携わったシステムではこのような仕組みをデータベースのトリガーを利用して行ったりしていました。後述するように現在では様々なアプリケーションやクラウドサービスが用意されているため、このような仕組みを簡単にできるのは素敵だなと思っています。 データレイクハウスへのCDC適用のメリット リアルタイム性の向上 CDC により、データ変更をほぼリアルタイムでデータレイクハウスに反映できます。このため、最新データに基づく分析結果の提供をデータレイクハウスにて実現することが可能です。 今回検討している CDC はデータベースのトランザクションログを読み取り、変更を抽出する構成を考えています。このため定期的なバッチ処理などによるデータ連携と比較しリアルタイムに近い状態での検知が可能です。 しかしながら今回データの反映先が Apache Iceberg となるため、データベースの1レコードごとに変更を伝搬してしまうと Iceberg の特性上、大量のファイルが作成されてしまい、パフォーマンス劣化などを招く恐れがあるため、注意して設計する必要があります。 分散システム間のデータ整合性確保 今回採用を検討している CDC はデータベースのトランザクションログを元にデータ変更を検知します。つまり、データベースの変更がコミットされたもののみを変更として検出することができます。これはバッチ処理などによるデータ連携など他の手法に比べて確定したデータを確実に伝搬させることができます。 また、CDC ではデータの変更だけでなく、テーブルスキーマの変更についても検知することができます。CDC でデータ変更を検知した時に伝搬するデータにスキーマの情報が含まれています。これにより、変更の受け手側でスキーマ変更に伴う処理を行うことができます。 この特性は Apache Iceberg のスキーマ進化 (Schema Evolution) と相性が良く、データ元となるサービス側でのテーブルスキーマ変更を柔軟に扱うことができます。 CDCツールと構成案 現在検討の候補としている構成です。スケーラビリティや耐障害性など非機能要件についての検証も行わないといけない状態です。近い将来、機能/非機能要件の検証を行った上で、最終的な構成の決定を行う予定です。 大きく分けて Google Cloud のマネージドサービスである Datastream または Debezium での構成を検討しています。 Google Cloud Datastream + Google Cloud Dataflow pattern-1 マネージドサービスである Datastream を採用した案です。 実は CADDi では BigQuery にデータを転送する手段としてすでに利用しているため、ある程度の実績がある状態です。 しかし、転送先が BigQuery の場合はかなり簡易な設定で利用することができる反面、BigQuery以外への転送手段は Google Cloud Storage (GCS) のみに限られてしまうため、 Apache Iceberg への書き込みには GCS に格納されたファイルを読み取り、データレイクハウスへ書き込む処理を用意する必要があります。 GCS からデータレイクハウスへの書き込みは、独自にアプリケーションの開発を行うか、Dataflow (Apache beam) を利用することを検討しています。 Pros 他案と比較し、マネージドサービスを利用するため可用性の向上を見込める Cons 独自開発、Dataflowの場合もデータ元のスキーマ変更に対する対応が他案に対して多くの工数が必要になる Debezium server iceberg pattern-2 CDC として有名な Debezium を 採用した案です。Debezium には Kafka Connect 上に実装された Debezium Connect と Kafka 不要でスタンドアロンアプリとして実行可能な Debezium Server が存在します。 この案では後者の Debezium Server で Apache Iceberg への書き込みに対応した debezium-server-iceberg を採用します。 Pros Debezium Server 単体での構成となるためシンプルな構成となる Apache Iceberg への書き込みもサポートされているため、独自に処理を追加する必要がない Cons 大規模環境での利用には検証が必要 水平スケールの可否 障害発生時の復旧などに不明点 Debezium (Kafka connect) + iceberg-kafka-connect pattern-3 前述と同様に Debezium を採用した案です。こちらは Kafka Connect 上で Debezium connect を用意し、 Apache Kafka とともに構成する案です。 この場合、 Apache Iceberg への書き込みは Debezium connect と同様に Kafka connect 上に iceberg-kafka-connect を用意し書き込みを行います。 Pros Apache Kafka, ならびに Kafka Connect を利用することでエコシステム上にある様々な source, sink コネクタの利用が可能 Kafka Connect の水平スケールが可能であるため大量の CDC イベントの処理が可能 Kafka を利用して変更イベントの永続化が行われるため耐障害性が向上できる Cons Apache Kafka の導入が必要になる CADDi ではメッセージ基盤として Cloud Pub/Sub を利用してきているため、 Apache Kafka への運用ノウハウが乏しい iceberg-kafka-connect の状況が不明 Apache/Iceberg 本体 への取り込みが始まっている 設定がかなり煩雑で学習コストが高い Kafka Connect への理解が前提知識として必要になる 詳細まで落とし込めておらず恐縮ですが、この様にいくつかの構成案を元に今後の検証を進めていく予定です。 最後に いかがでしたでしょうか? 最終的に CADDi が選択する構成まで言及することができず中途半端な内容となってしまいましたが、何かの参考にしていただければと思います。 最後にお決まりの宣伝を書かせてもらいます。キャディではエンジニアを採用しています。本記事を読んで、「製造業の AI データプラットフォーム」構想に興味を持った方、今後の課題を一緒に解決していきたいと感じた方はぜひご連絡ください。 (Apache Iceberg などデータレイクハウスに興味がある!とても詳しいという方いらっしゃればぜひカジュアル面談でも良いので声を掛けていただければと) https://recruit.caddi.tech/
こんにちは。Drawer Growth グループの江良です。 キャディが「製造業 AI データプラットフォーム」の構想を打ち出してから半年ほどが経ちました。 caddi.com このコンセプトの実現にあたっては、「AI」の部分だけでなく、「データ」の部分を支える仕組みづくりも重要になってきます。今回は、私が携わっているプロジェクトで導入した Apache Iceberg とその使いどころについて紹介したいと思います。 製造業におけるデータ活用の難しさ 本題に入る前に、まずは背景について少し補足します。 (Iceberg の話だけを読みたい人は「採用したアーキテクチャ」のところまでスキップしてください。) モノづくり産業における会社には多種多様なデータが存在する 製造業の世界で登場するデータにはさまざまなものがあります。 詳しくは キャディ、製造業AIデータプラットフォームとしての、第二章。|加藤/キャディCEO でも紹介されていますが、具体例を挙げると以下の通りです。 分類 具体例 構造化データ ・実績データ(見積実績、受注実績、発注実績、製造実績、検査実績、出荷実績、請求実績、在庫実績など) ・マスタデータ(顧客情報、製品、仕入れ先、工程、設備情報、検査器具、チャージなど) 半構造化データ ・CAD 非構造化データ ・図面 ・写真 ・文書(仕様書、不具合報告書、議事録など) (会社の規模にもよりますが)少なくとも十数種類 〜 百数種類のデータが企業内に存在することがイメージできるかなと思います。 当然ながら、それぞれのデータのスキーマは異なります。データのサイズや更新頻度も様々です。実績データに関しては、一億件近くの規模のデータが存在するケースもあります。 データのフォーマットは会社ごとに異なる 図面は、書き手の意図を確実に読み手に伝達するため、JIS 規格に基づいて標準化されています。一方で、表題欄と呼ばれる図面のメタデータ(図面番号、尺度、部品名称、設計者名、承認者名、使用する材質など)を記載する欄の様式は各社が自由に設定できます。 CAD に関しても、どのソフトウェアを使用しているかは各社でバラバラです。 実績データやマスタデータの管理方法は当然各社で異なります。PLM/PDM や ERP といったソフトウェアで管理されていることが多いですが、製造業全体で「標準」と言えるような規格はありません。 データの「活用」に向けたハードル こういった多種多様なデータを活用するためには、まず、非構造化データや半構造化データをなんらかの方法で構造化する必要があります。その上で、データ同士をなんらかの方法で紐づけて、データ同士の連関がわかるようにする必要があります。 データのフォーマットは会社ごとに異なり、さまざまなバリエーションがあります。そのため、「データ同士がどうすれば紐づくか」も一意には決まりません。 ここまでの話をまとめると、 さまざまなスキーマのデータを柔軟に取り扱うことができ、 データ同士をどのカラムで紐づけるべきかを柔軟に選択でき、 大規模なデータセットを取り扱える こういった要件を満たすことが、製造業におけるデータの「活用」を実現する上では求められます(製造業に限った話ではないかもしれませんが)。 データを活用するための一般的な解決策 さて、ここまで説明してきたような課題を解決するためにはどうすればいいでしょうか?一般的には、データエンジニアリングによるアプローチが考えられるかなと思います。 三行くらいで簡単にまとめるとこんな感じ。 データエンジニアリングを専門とするチームを組成し、 データレイクに生データを集め、 ETL パイプライン等を通じてデータを活用可能にする Snowflake 等の登場により、企業がデータ分析を始める際のハードルは大きく下がってきている印象があります。しかしながら、こうしたことを実現するためには、依然としてデータエンジニアリングを専門とするエンジニアが手を動かす必要があります。 改めて、先ほどまとめた課題を再掲します。 さまざまなスキーマのデータを柔軟に取り扱うことができ、 データ同士をどのカラムで紐づけるべきかを柔軟に選択でき、 大規模なデータセットを取り扱える (加えて、製造業に特有のユースケースに特化した機能を提供できる) 上記のような機能を SaaS として提供することで、データをよりかんたんに活用できる状態にしたい、そのための方法を考えてほしい、というのが、ぼくの所属するチームのここ半年のミッションでした。 データレイクハウスの登場 先ほど、データを活用するための一般的な解決策としてデータレイクについて触れました。大規模なデータセットを活用していく上で、データレイクのアーキテクチャは有効ですが、一方で課題もあります。 代表的な課題としては、データの一貫性に関する課題があります。データはあくまで GCS 等のストレージに配置されているだけの状態にあるため、RDBMS でいうところのトランザクションのような概念はありません。そのため、複数のプロセスから同時に書き込みをするとデータが壊れてしまう可能がありますし、中途半端に書き込みがされた状態のデータが予期せず参照されてしまう可能性もあります。 こうした課題から、近年、データレイクハウスと呼ばれるアーキテクチャが注目されてきています。 データレイクハウスアーキテクチャは、データを保存するストレージのレイヤと、データに対して SQL を実行するクエリのレイヤを分離し、その間にメタデータのレイヤを設けているのが大きな特徴です。メタデータのレイヤを設けることで、ストレージ上のデータをテーブルであるかのように抽象化したり、ACID トランザクションを実現したりすることができます。 www.databricks.com それぞれのレイヤで採用できる代表的なツールは以下の通りです。 メタデータのレイヤでは、Open Table Format と呼ばれる仕様に従ってデータが管理されます。この仕様に従ってデータを保存することで、トランザクションなどの便利な機能が使えるほか、クエリのレイヤでどのツールを使うか(Spark、Hive、Flink、Trino など)がユースケースに応じて選択可能になります。 採用したアーキテクチャ 前置きが長くなりました。キャディでの Iceberg の使いどころについての話に移ります。 キャディでは、CADDi Drawer が扱うデータのうち、構造化データを扱うサービスにて Iceberg を使用しています。構造化データのうち、特に実績にまつわるデータはレコード件数が多い傾向にあります。スキーマが不定だったり、紐付け項目が一意に定まらなかったりするという特徴も相まって、RDBMS を素朴に利用してアプリケーションを設計すると、中長期的に期待するパフォーマンスが出せないのではないか、という懸念がありました。 一方で、データの更新頻度は少なく、データの追加操作がメインのユースケースであることから、「RDBMS 以外の選択肢は本当にないのか?」を検討し、紆余曲折を経て Iceberg に辿り着きました。 各レイヤで何を採用したか 先ほど、データレイクハウスアーキテクチャはクエリ、メタデータ、ストレージの 3 つのレイヤで構成される、ということについて説明しました。それぞれのレイヤで採用できるツールにはいくつか選択肢がありますが、CADDi Drawer では Trino、Iceberg、GCS(Google Cloud Storage)を採用しました。 Open Table Format が掲げるテーマとして代表的なものに「バッチとストリーミングの統合」があります。ストリーミングのユースケースを満たすなら、Apache Spark を採用し、Structured Streaming 機能を活用するといった選択肢も考えられます。 iceberg.apache.org ですが、SQL のインタフェースを通じてデータをクエリできれば十分であり、検討時点ではストリーミングのユースケースが見当たらなかったため、比較的導入コストの小さい Trino を採用しています。(リリースまでのスケジュールが非常にタイトであったこと、今回ユーザに提供する機能はあくまでベータ版であったこと、といった事情もあったりします。) Iceberg に関しては AWS など BigTech 各社が力を入れていることから興味を持ち、採用を決めました。 データレイヤーに関しては、キャディでは Google Cloud を全面的に採用していることから GCS を採用することに決めました。 「ベータ版としての提供なのであれば BigQuery でもいいのでは…?」という考えも頭をよぎりましたが、不特定多数のユーザーに BigQuery を用いた機能を解放するとクエリコストのコントロールが難しくなりそうなため、候補からは外しました。 アーキテクチャの詳細 アーキテクチャ図は以下の通りです。 構造化データを扱うマイクロサービスは、キャディの中では珍しく Java を採用しています。静的型付けのある言語で開発したかったのと、Trino や Iceberg などのライブラリとの親和性の高さから採用を決めています。 処理の大まかな流れは以下の通りです。 ユーザがアップロードした CSV をパースして Iceberg に保存する 図面の解析結果を一定間隔のバッチで受け取り Iceberg に保存する Iceberg のデータを用いてデータの紐付けを解決し、「図面に紐づく構造化データ」を UI に表示できるようにする 緑色の線が「ユーザが CSV をアップロードしてから Iceberg に登録されるまで」の流れを表し、赤色の線が「図面の解析結果が Iceberg に登録されるまで」の流れを表しています。別のジョブを通じてデータ同士の紐付けを解決して Iceberg に書き戻し、この「解決済み」のデータを REST API から返却して、ユーザ向けの画面に表示しています。 Trino は GKE クラスタ上に用意した専用のノードにデプロイして稼働させています。コーディネータがクエリを受信し、実行計画を立てて、ワーカに対して指示を送ります。ワーカはコーディネータからタスクを受け取り、データを実際に処理します。 Iceberg Catalog としては Databricks 社の iceberg-rest-image を利用しており、こちらも GKE クラスタ上にデプロイして稼働させています。カタログの情報は AlloyDB に永続化し、ファイルの実態は GCS に保存しています。 github.com Iceberg Catalog にも選択肢がいくつかあります。詳しく知りたい方は下記の記事を参照ください。 bering.hatenadiary.com 大量のデータの INSERT 操作は、パフォーマンスの観点から Iceberg Java API を通じて実施しています。 iceberg.apache.org 所感 Iceberg および Trino を採用したことにより、 テナントごとに異なる、さまざまなスキーマのデータを柔軟に取り扱うことができる データ同士をどのカラムで紐づけるべきかを柔軟に選択できる 大規模なデータセットを取り扱える といった、当初目的としていたアーキテクチャ特性を満たすサービスを構築できました。 データの書き込み性能のスループットに関しては、1000 万件規模のデータの登録が 15min 程度で完了し、読み込み性能に関しても一般的な Web アプリケーションとして違和感のないレスポンスタイムで安定して結果を返すことを確認できました。 今後の課題 ここまで、Iceberg 導入の背景と使いどころについて説明してきました。 直近のゴールは達成できたものの、今後取り組みたいこと、改善したいポイントはたくさんあります。 全社を横断したプラットフォームへの進化 Iceberg を使った仕組みは、現在、あくまで CADDi Drawer の中の一機能という立ち位置です。将来的には CADDi Drawer のデータだけではなくCADDi Quote のデータも横断して取り扱えるよう、アプリケーションとプラットフォームに分割し、アプリケーションを横断して利用できるようにしていく必要があります。 また、こちらのインタビューでも語られている通り、製造業 AI データプラットフォーム CADDi には、今後も新規アプリケーションを追加していくことを想定しています。 www.fastgrow.jp 「3 年で数十個」 という目標を達成する上で、Iceberg を使った基盤を全社を横断したプラットフォームに進化させていく取り組みは急務といえます。 Iceberg の機能をもっと使い倒したい Iceberg にはトランザクション管理に関する仕様が定義されています。この仕様に従って実装されたクエリエンジンを利用することで、更新データの競合が疑われる場合に該当の操作を abort し、データの一貫性を保証することができます。 現時点ではデータの追記(AppendFiles)しか利用していないため、下記の資料で解説されているような同時書き込み時における課題には直面していません。 speakerdeck.com また、Iceberg には in-place table evolution という仕様が定義されています。これはテーブルのスキーマを ALTER TABLE 文を発行して変更したり、テーブルのパーティションを行うキーを後から変更したりすることができる、という機能です。 iceberg.apache.org 現時点では、一度定義したテーブルのスキーマを変更するような機能を提供していないため、この課題には直面していませんが、早晩対応が必要になりそうな予感がしています。 また、Iceberg を全社を横断したプラットフォームに進化させていく上では、各アプリケーションのデータベースに永続化されているデータを、ストリーミング処理を通じてニアリアルタイムに連携できるようにしていく必要も出てきそうです。 やることがたくさんあって大変なわけですが、これはこれで「Iceberg の真価を発揮できるチャンスがたくさんある」と言い換えることもできそうです。 マルチテナント SaaS におけるテナント分離の課題 書籍『マルチテナント SaaS アーキテクチャの構築』でも語られている通り、SaaS を提供する事業者としては、異なるテナントのデータが誤って参照されてしまうことのないよう、テナントの分離を強制する仕組みの構築が重要となります。 CADDi Drawer では、Iceberg のスキーマをテナントごとに作成し、テナントごとのテーブルをスキーマ内に作成することでデータを物理的に分離しています。異なるテナントのデータを参照できないようにする仕組みはアプリケーションのレイヤに実装しています。 こういった仕組みはアプリケーションのレイヤだけでなく、インフラのレイヤにも導入し、多層的なテナント分離を実現したいところです。ですが、現在採用している Iceberg Catalog にはそういったアクセスコントロールに関する機能はないため、やむなく断念しています。 Apache Polaris では、RBAC モデルをベースとした柔軟なアクセスコントロールの仕組みが提供されるようです。現時点では Incubation のステータスにあるため採用を見送ったのですが、正式版がリリースされた際には載せ替えを検討しています。 polaris.apache.org Iceberg の利用を検討している方は動向をウォッチしてみると良いかもしれません。 おわりに いかがだったでしょうか。 Iceberg の採用を検討している方の参考になれば幸いです。 最後に宣伝で、キャディではエンジニアを採用しています。本記事を読んで、「製造業の AI データプラットフォーム」構想に興味を持った方、今後の課題を一緒に解決していきたいと感じた方はぜひご連絡ください。 recruit.caddi.tech
こんにちは、Data&Analysis部(D&A)です。 D&Aでは週1回、機械学習の勉強会を開催しており、本記事は、勉強会の内容を生成AIを活用して記事にまとめたものものです。 ※勉強会内容公開の経緯は こちら ※過去の勉強会は「社内勉強会」タグからもご覧いただけます。 概要 Qwen2-VL の概要 技術的な特徴 主なベンチマーク結果と性能 関連モデル モデルの利用とライセンス 結論と感想 参考リンク 概要 今回の勉強会ではAlibaba Cloud が開発した Vision-Language Model (VLM) である Qwen シリーズ、特に Qwen2-VL の特徴、性能、関連モデルについて話しました。 調査した動機は、Qwenシリーズは日本語の性能が高いとされており、そのマルチモーダルモデルが画像解析を扱う我々の事業領域にマッチしていることです。またDeepSeek R1の蒸留モデルの中にQwenシリーズがあることが調査の更なる動機です。 具体的にはQwen2-VL の技術的な詳細、ベンチマーク結果、多言語対応、そして最新の Qwen 2.5 VL についてです。 また検索エンジンモデルへの応用事例や、今話題のdeepseekの開発したVLMの簡単な紹介も行います Qwen2-VL の概要 Alibabaが開発しているQwen シリーズには複数のモデルが存在します。今回はその中でマルチモーダルモデルのQwen2-VL に焦点を当てました。 Qwen2-VL は、静止画像だけでなく、ビデオや UI 操作など、多様な視覚モダリティに対応することを目指しています。 モデルサイズには複数のバリエーションがあり、最大で 720億パラメータ、最小で 20億パラメータ程度のものがあります。 パラメータの比較(論文より引用) 技術的な特徴 ここではQwen2-VLで紹介されている特徴の中で特に興味深いものを挙げます。 任意の解像度への対応: 後に解説するRoPEの2次元拡張である2D-RoPEで画像と位置情報をエンコードすることで様々な画像サイズに対応できます。論文中で「Naive Dynamic Resolution」というキーワードで紹介されています。 M-RoPE: RoPE (Rotary Position Embedding) を拡張した Multimodal Rotary Position Embedding (M-RoPE) を導入し、文字列から動画までのモダリティを扱えるようになっています。これにより、1D (文字列)、2D (画像)、そして3D(動画)のエンコードが可能になっています。 主なベンチマーク結果と性能 ここではQwen2-VLで紹介されているベンチマークの結果のうち興味深いものを挙げます。 ベンチマーク比較(論文より引用) 主要なベンチマークで、GPT-4V(ision) や Gemini Pro などの競合モデルと比較して、遜色ない、あるいは一部で上回る性能を示しています。 特に、ドキュメント理解 (VQ) やチャート理解 (UA) のタスクにおいて、良好な結果が得られています。 また複数の言語でのベンチマーク結果で、日本語においても一定の性能を発揮することが示されています。 特にマルチリンガル OCR ベンチマークの結果として、Qwen2-VL が日本語にも比較的良く対応しており、日本語を扱う用途での利用が期待されます。 GPT-4oとQwen2-VL-72Bの多言語での性能の比較(論文より引用) 関連モデル Janus-Pro: DeepSeek が開発したマルチモーダルモデルで、エンコーダーに SigLIP-L を採用しています。SigLIPは固定解像度での入力で、文書画像のような高密度なタスクにおいては Qwen2-VL の方が優位性があるかもしれません。 ColQwen2: Qwen2-VL-2B-Instruct をベースに、画像検索 (Visual Retriever) 用に ColBERT strategy を用いて訓練されたモデルです。 Google の PaliGemma を用いた場合と比較して、Qwen2-VL を用いることで日本語文書検索の性能向上が期待されます。 Qwen 2.5 VL: 最新のバージョンとして言及されており、言語モデルのデコーダーに Qwen 2.5 の言語モデルを使用し、ビジョンエンコーダーの一部を効率化したものが採用されています。既存の API 提供モデルと比較しても遜色ない性能を発揮するようです。 モデルの利用とライセンス Qwen シリーズのモデルは Hugging Face で公開されており、容易に試すことができます。 ただしモデルのライセンスについては注意が必要で、ソースコードのライセンスとモデル自体のライセンスが異なる場合があります。特に商用利用を検討する場合は、ライセンス契約の詳細を確認する必要があります。 具体的には、Qwen2VL-72Bは Qwenライセンス であり、商用利用かつユーザー数が一定以上いるサービスに利用する場合にはライセンス契約が必要です。Qwen2-VL-2B, やQwen2-VL-7Bであれば apache-2.0 なので、もう少し気軽に利用できます。 結論と感想 Qwen2-VL は、画像から動画までの推論や任意の解像度での推論を可能にする Vision-Language Model であり、高いベンチマーク性能と多言語対応能力を持っています。 日本語のベンチマークで高い性能を持った公開モデルは嬉しいですね。 Qwen2.5-VLの動向から今後は言語モデルの進化による推論能力の向上や学習の効率化が見込めそうです。また画像や動画に限らず他のモダリティの拡張もあり得るのではないでしょうか。公開されてるモデルなので今後も動向を伺いたいと思います。 参考リンク リンク一覧はこちらをクリック [2409.12191] Qwen2-VL: Enhancing Vision-Language Model's Perception of the World at Any Resolution Qwen2-VL [2410.07073] Pixtral 12B [2104.09864] RoFormer: Enhanced Transformer with Rotary Position Embedding [2307.06304] Patch n' Pack: NaViT, a Vision Transformer for any Aspect Ratio and Resolution Qwen2.5 Technical Reportの中に潜る - ABEJA Tech Blog Large Vision Language Model (LVLM) に関する最新知見まとめ (Part 1) - Speaker Deck 【Qwen2-VL】画像や動画を異なる解像度で処理できる最新VLM | AI-SCHOLAR | AI:(人工知能)論文・技術情報メディア Qwen2-VL : ローカルで動作するVision Language Model | by Kazuki Kyakuno | axinc | Medium vidore/colqwen2-v0.1 · Hugging Face [2412.15115] Qwen2.5 Technical Report [2501.15383] Qwen2.5-1M Technical Report deepseek-ai/Janus-Pro-1B · Hugging Face deepseek-ai/deepseek-llm-7b-base · Hugging Face GitHub - deepseek-ai/Janus: Janus-Series: Unified Multimodal Understanding and Generation Models https://zenn.dev/yumefuku/articles/pdf-search-colqwen2
Drawer Growthグループ所属エンジニアの中野です。先日、採用候補者の方が「Rustを勉強する際にキャディのTech Blogにお世話になった」という話をして下さりとても嬉しかったのですが、最近Rustに関するTech Blogを執筆できていなかったので久しぶりに筆を取りました。 今回は「proptestをうまく使うとテストが捗り、ドメインモデルもキレイにできる」というテーマで書きます。 TL;DR proptestの活用で、テストの見通しが良くなりレビューが捗ります。AIによるコード生成も相まって、コードのレビュー量が増える際にもテストの見通しの良さは大事になります。 また、proptestを用いてテストを書くことで、ドメインを表現する型を見直すきっかけを得ることができます。 proptestとは proptest crateのREADMEによると、proptestはproperty testing frameworkです。property testingについては Wikipedia にまとめられています。要は「特定の入力と出力の一致を確認するのではなく、ランダムに生成した多くの入力に対してプログラムを実行し、常に成り立つべき「性質(プロパティ)」を検証するテスト手法」です。これにより、幅広いケースを網羅的にテストでき、実装の正しさを効率的に確認できます。 @t_wada さんも Property-based Testing の位置づけ というスライドで、「Known unknown」へのアプローチ手法としてproperty-based testingを紹介しています。 *ここまでにproperty testingとproperty-based testingという2つの表記が登場しました。今回の文脈では両方とも同じ意味で利用しているので、以下proptest crateのREADMEに合わせてproperty testingと記載します。 proptestを使うとどう嬉しいのか 1. テストの見通しが良くなる 私のチームが管理するコードベースでは、proptestをproperty testingだけでなくexample based testing(普段よく書くテスト)を書くためにも利用しています。 例として、以下の構造体とメソッドがあるとします。このメソッドに対して、proptestを用いたパターン、用いないパターンそれぞれでテストを書いてみます。 use std :: num :: NonZeroU32; use chrono :: {DateTime, FixedOffset, Utc}; use derive_getters :: Getters; use derive_new :: new; use proptest :: strategy :: {BoxedStrategy, Strategy}; use proptest_derive :: Arbitrary; use uuid :: Uuid; #[derive( Debug , PartialEq , Arbitrary, new)] struct OrderId ( #[proptest(value = "Uuid::new_v4()" )] Uuid); #[derive( Debug , PartialEq , Arbitrary, new)] struct OrderDetailId ( #[proptest(value = "Uuid::new_v4()" )] Uuid); #[derive( Clone , Debug , PartialEq , Arbitrary, Eq , new)] struct ProductCode ( String ); #[derive( Debug , PartialEq , Arbitrary, new)] struct Quantity (NonZeroU32); #[derive( Debug , PartialEq , Arbitrary, new)] struct Price ( u32 ); #[derive( Debug , PartialEq , Eq )] struct FixedOffsetDateTime (DateTime < FixedOffset > ); impl FixedOffsetDateTime { fn now () -> Self { let now = Utc :: now (). fixed_offset (); Self (now) } fn parse_from_rfc3339 (s: &str ) -> Result < Self , String > { let date_time = DateTime :: parse_from_rfc3339 (s) . map_err ( | _e | format! ( "Fail to parse FixedOffsetDateTime from {s}" )) ? ; Ok ( Self (date_time)) } } // テストを書く際にFixedOffsetDateTimeの値を生成するための実装 impl proptest :: arbitrary :: Arbitrary for FixedOffsetDateTime { type Parameters = (); type Strategy = BoxedStrategy < Self > ; fn arbitrary_with (_args: Self :: Parameters) -> Self :: Strategy { // from 1970-01-01 upto 2170-01-01 catalyst_arbitrary :: strategy :: fixed_offset_date_time () . prop_map (FixedOffsetDateTime) . boxed () } } #[derive( Debug , Arbitrary, new)] struct OrderDetail { id: OrderDetailId, product: ProductCode, quantity: Quantity, price: Price, } #[derive( Debug , Arbitrary, new)] struct Order { id: OrderId, shipping_address: String , shipping_date: FixedOffsetDateTime, details: Vec < OrderDetail > , } impl Order { fn change_shipping_date ( self , shipping_date: FixedOffsetDateTime) -> Result < Self , String > { let sum_of_quantity = self .details . iter () . fold ( 0 , | acc, order_detail | acc + order_detail.quantity. 0 . get ()); if sum_of_quantity > 10 { return Err ( format! ( "Cannot change shipping_date because sum of quantity is {sum_of_quantity} which \ is over 10" )); } Ok ( Self { shipping_date, .. self }) } } これからchange_shipping_dateメソッドのテストを書いて行きます。 まずはproptestを利用した例です。Arrangeする際に、テスト対象に関連するquantityだけ値を渡して初期化しているので、スッキリと記述でき、何に対するテストが書かれているのか容易に把握できます。 fn any < A: proptest :: prelude :: Arbitrary > () -> A { use proptest :: strategy :: ValueTree; let runner = &mut proptest :: test_runner :: TestRunner :: deterministic (); proptest :: prelude :: any :: < A > () . new_tree (runner) . unwrap () . current () } #[test] fn test_change_shipping_date_with_proptest () { let mut order = any :: < Order > (); order.details = vec! [ OrderDetail { quantity: Quantity ( NonZeroU32 :: new ( 1 ). unwrap ()), .. any :: < OrderDetail > () }, OrderDetail { quantity: Quantity ( NonZeroU32 :: new ( 9 ). unwrap ()), .. any :: < OrderDetail > () }, ]; let new_shipping_date = FixedOffsetDateTime :: parse_from_rfc3339 ( "2025-03-09T00:00:00+09:00" ). unwrap (); let result = order. change_shipping_date (new_shipping_date); assert_eq! ( result. unwrap ().shipping_date, FixedOffsetDateTime :: parse_from_rfc3339 ( "2025-03-09T00:00:00+09:00" ). unwrap () ); } 上のコードではテストを書くためのヘルパー関数としてany関数を実装しています。私が所属するチームでは、こういったヘルパー関数をcrateに切り出して実装し、共通で利用できるようにしています。 次にproptestを利用しない例です。Arrangeのパートでテスト対象に直接関係ない値も渡して初期化する必要があるので、テストを読んだ人がどの値に注目すべきか分かりづらくなってしまいます。 #[test] fn test_change_shipping_date_without_proptest () { // Arrange let order = Order :: new ( OrderId :: new ( Uuid :: new_v4 ()), "shipping_address" . to_string (), FixedOffsetDateTime :: parse_from_rfc3339 ( "2000-01-02T00:00:00+09:00" ). unwrap (), vec! [ OrderDetail :: new ( OrderDetailId :: new ( Uuid :: new_v4 ()), ProductCode :: new ( "product_code" . to_string ()), Quantity :: new ( NonZeroU32 :: new ( 1 ). unwrap ()), Price :: new ( 100 ), ), OrderDetail :: new ( OrderDetailId :: new ( Uuid :: new_v4 ()), ProductCode :: new ( "product_code" . to_string ()), Quantity :: new ( NonZeroU32 :: new ( 2 ). unwrap ()), Price :: new ( 100 ), ), ], ); let new_shipping_date = FixedOffsetDateTime :: parse_from_rfc3339 ( "2025-03-09T00:00:00+09:00" ). unwrap (); // Act let result = order. change_shipping_date (new_shipping_date); // Assert assert_eq! ( result. unwrap ().shipping_date, FixedOffsetDateTime :: parse_from_rfc3339 ( "2025-03-09T00:00:00+09:00" ). unwrap () ); } 2. ドメインを表現するための型定義を見直す機会を得ることができる より複雑なテスト、例えばdomain_serviceのテストを書きたい場合等、型定義が甘い状態でproptestを用いたテストを書いてしまうと、意図しない箇所でテストが失敗してしまうことがあります。以下のようにProductCodeの型を厳密にするのではなく、OrderDetailの初期化メソッド内でProductCodeのバリデーションをしてしまっている場合を考えます。 #[derive( Clone , Debug , PartialEq , Arbitrary, Eq , new)] struct ProductCode ( String ); impl OrderDetail { fn new ( id: OrderDetailId, product: ProductCode, quantity: Quantity, price: Price, ) -> Result < Self , String > { // productCodeはPR-から始まる文字列である必要がある if ! product. 0 . starts_with ( "PR-" ) { return Err ( "product code must start with PR-" . to_string ()); } Ok ( Self { id, product, quantity, price, }) } } proptestを用いてOrderDetailを生成した際にorder_detail.productの値は"hgoieagjeag"などの適当な文字列になります。そのため、後続の処理で再度OrderDetailを初期化しようとした際にエラーが発生する可能性があります。この問題が発生すると、以下のように型でProductCodeを表現したほうが良いことに気づく事ができます。 #[derive( Clone , Debug , PartialEq , Eq )] pub struct ProductCode ( String ); impl FromStr for ProductCode { type Err = String ; fn from_str (s: &str ) -> Result < Self , String > { let id = s . starts_with ( "PR-" ) . then ( || ProductCode (s. to_string ())) . ok_or_else ( || "Invalid ProductCode format" . to_string ()) ? ; Ok (id) } } proptestを利用してテストがきれいに書けないときは、型定義を見直すサインかもしれません。もちろん、すべての値をnew type patternで表現する必要はないです。しかし、本来厳密な型定義が必要である箇所に気づく機会をテストを書きながら得ることができるのは嬉しいポイントです。 上記のProductCodeに対してproptest::prelude::Arbitraryを実装するサンプルは以下です。 impl proptest :: prelude :: Arbitrary for ProductCode { type Parameters = (); type Strategy = BoxedStrategy < Self > ; fn arbitrary_with (_args: Self :: Parameters) -> Self :: Strategy { const ALPHABET: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ123456789" ; let alphabet = ALPHABET. as_bytes (). to_owned (); proptest :: collection :: vec ( proptest :: prelude :: any :: < u8 > () . prop_map ( move | i | alphabet[i as usize % alphabet. len ()] as char ), 12 , ) . prop_map ( | id | { let id_string: String = id. into_iter (). collect (); ProductCode ( "PR-" . to_string () + & id_string) }) . boxed () } } まとめ AIによって生成されるコードの量が増えるに従い、エンジニアは今までに以上にコードをレビューする必要が発生しそうです。その際に、テストの見通しが良いとレビューが楽に、正確になるのではないでしょうか?
概要 LLM as a Judgeとは? なぜLLM as a Judgeが注目されているのか?  LLM as a Judgeのプロセス プロンプト設計の詳細  モデル選択の詳細 後処理の詳細 LLM as a Judgeの適用シナリオ 評価パフォーマンス改善戦略 LLM評価者の評価 課題と今後の展望 結論 余談   概要 2025年1月10日に行われたキャディ機械学習勉強会でのLLM as a Judgeに関するレビュー論文の紹介と議論を踏まえ、本記事は A Survey on LLM-as-a-Judge を読み、内容をまとめ、LLMを評価者として用いる概念、そのプロセス、利点、課題、将来展望について解説したものです。 LLM as a Judgeとは? 従来、専門家が担当していた評価業務をLLMに代行させるという試みです。これにより、評価プロセスの効率化、コスト削減、一貫性の向上が期待さ れています。 なぜLLM as a Judgeが注目されているのか?  人間の評価には限界があるため、LLMが注目されています。 スケーラビリティ: 人間の評価者は数に限りがありますが、LLMは一度学習すれば繰り返し利用できます 。 コスト: 専門家による評価は高額になりがちですが、LLMは比較的低コストで運用できます。 一貫性: 人間の評価は主観的なバイアスに影響されますが、LLMは一貫した基準で評価できます 。 LLM as a Judgeのプロセス LLM as a Judgeのプロセスは、大きく分けて以下の4つのステップで構成されます : 評価の目的を定義 何を評価するのか、どのような評価基準を用いるのかを明確にします。 評価対象となるデータの種類や粒度も決定します。 プロンプト設計 LLMが評価タスクを正しく理解し、実行できるように、適切なプロンプトを設計します。 モデル選択 汎用LLM (GPT-4, Claude, ChatGPTなど) を使うか、特定のタスクに合わせてファインチューニングされたLLMを使うかを選択します。 後処理 LLMから得られた応答を分析し、評価指標として利用できる形に変換します。 LLM-as-a-judgeのパイプライン(論文より抜粋) プロンプト設計の詳細  プロンプト設計は、LLM as a Judgeの成否を大きく左右する重要な要素です。以下に、代表的なプロンプト設計方法を紹介します スコアリング: 評価対象に対して、LLMにスコアをつけさせます。 例: ニュース記事の要約を、特定の観点 (正確性、流暢さなど) から1〜10点で評価させる。 真偽判定: 評価対象に関する質問に対して、LLMに「はい」または「いいえ」で回答させます。 例: 論文の査読コメントが、論文の改善に役立つ具体的な指摘を含んでいるかどうかを判定させる。 ペアワイズ比較: 2つの評価対象を比較させ、どちらが優れているかをLLMに判断させます。 例: 2つの文章要約のどちらが、正確性と流暢さの点で優れているかを判定させる。 多肢選択: 複数の選択肢の中から、最も適切なものをLLMに選ばせます。 例: 文章の主題として最も適切なものを、複数の選択肢から選ばせる。 モデル選択の詳細 LLMの選択肢は大きく分けて2つあります。 汎用LLM: GPT-4, Claude, ChatGPTなどの汎用的なLLMは、高い性能と安定性を誇ります。 課題: コストが高い、プライバシー情報漏洩のリスクがある、モデルの内部構造が不明瞭。 ファインチューニング済みのLLM: 特定の評価基準やデータセットに合わせて調整されたLLMは、より高い精度と一貫性を実現できる可能性があります。 課題: データセットの質によっては、バイアスが生まれる可能性があります  後処理の詳細 LLMから出力されたテキストを、評価指標として利用できるように変換するプロセスです。 トークン抽出: スコアリング、真偽判定、多肢選択などの場合、LLMがどのスコアを選択したのか、Yes/Noのどちらを選んだのかを抽出します。 ルールベースで抽出するのが一般的です。 出力形式が明確でないと抽出が困難になるため、LLMにあらかじめ出力形式を指示しておくことが重要です。 例: 「最後の文は'The better response is'で始める」という指示を出す。 出力ロジットの正規化: レビュー論文内で名言がなく詳細は不明 文章選択: LLMの出力が複数の文や段落で構成されている場合、それぞれの構造ごとに評価を行います。 LLM as a Judgeの適用シナリオ LLM as a Judgeは、様々な分野での応用が期待されています  データアノテーション: 人手によるアノテーションの代替として、LLMを活用できます。 モデル評価: LLM自身を評価するために、LLM as a Judgeの仕組みを利用できます 。 エージェント評価: エージェントの行動やプロセスを評価できます。 金融: 信用スコアリングやESGスコアリングに応用できます。 法律: 法的文書の妥当性評価に利用できます。 数学的推論: 数学的な推論能力を評価できます。 評価パフォーマンス改善戦略 LLM as a Judgeの性能を最大限に引き出すためには、以下の戦略が有効です。 プロンプト設計の改善: LLMがタスクをより良く理解できるように、プロンプトを工夫します。 Few-shot prompting: 評価例をプロンプトに含める。 評価タスクの分解: 評価ステップを細かく分割する。 出力形式の最適化: LLMに出力形式を指示する。 LLMの評価能力向上: LLM自体の評価能力を高めます。 メタ評価データセットでファインチューニングする。 評価結果に対するフィードバックを反映させる。 最終評価結果の最適化: 複数の評価結果を統合したり、LLMの出力を後処理したりすることで、評価の信頼性を高めます。 複数のLLMによる評価結果を組み合わせる。 LLMに自己検証させる。 LLM評価者の評価 LLM評価者自体の品質を評価することも重要です。 基本的な評価指標 人間との一致率: LLMの評価と人間の評価が一致する割合。 統計的指標: コーエンのカッパ係数、スピアマンの相関係数など。 LLMのバイアス 位置バイアス: プロンプト内の特定の位置にある回答をLLMが好む傾向。 長さバイアス: 特定の長さの回答を好む傾向。 自己強化バイアス: LLMが自身で生成した回答を好む傾向。 Adversarial Robustness: 意図的にスコアを操作しようとする攻撃に対する耐性。 課題と今後の展望 LLM as a Judgeには、まだ多くの課題が残されています。 信頼性の向上: LLMは自身の応答を過大評価する傾向があります。 公平性と汎化性能: 特定のデータセットに偏ったLLMは、未知のデータに対して性能が低下する可能性があります。 堅牢性: 悪意のある攻撃に対する脆弱性があります。 これらの課題を克服するために、以下のような研究開発が期待されています。 より信頼性の高いLLM評価器の開発。 データアノテーションにおけるLLM評価器の活用。 マルチモーダルLLM評価器の開発。 LLM評価器のベンチマーク拡充。 結論 LLM as a Judgeは、様々な分野で従来の評価方法を革新する可能性を秘めています。しかし、信頼性、堅牢性、バイアスなどの課題を克服し、評価パフォーマンスを向上させるための研究開発が今後も必要とされています。 余談 NotebookLM は、論文の内容を理解し、質問に対する回答の根拠を明示してくれるため、レビュー論文の調査に非常に役立ちます。   勉強会公開の経緯については こちら をご覧ください   過去の機械学習勉強会については、 #キャディ機械学習勉強会 タグよりご覧ください。  
はじめに こんにちは、Data&Analysis部(以下、D&A)所属の宇佐見です。D&Aは弊社が展開する製造業AIデータプラットフォームCADDiに集約されたデータを解析して価値を創造することがメインの業務です。 解析にはもちろん機械学習を用いることが多く、メンバー間の知見共有が求められます。そこで始まったのが機械学習勉強会だと思われます。 思われます、と書いているのはなぜかというと、この勉強会は非常に歴史が長く、2021年の1月ごろから続いているものだからです。 私は2024年8月入社なので初めの頃の雰囲気は不明ですが、おそらくそういう課題はどこのチームにもあるものなのでそういうモチベーションがあったのではないのかなと推察しています。 さて、現在の機械学習勉強会はどのような運用をしているかというと、インフレして負担にならないよう、ゆるく機械学習に関係のあるトピックならなんでも共有しようという会になっています。 毎週金曜日、割り当てられた人が20分程度喋り、それについて話し合うみたいなスタイルでゆるりとやっています。 公開に至った経緯 そんな勉強会を5年目にしてなぜ公開するに至ったかというと、これは私がキャディの選考を受ける前に持っていたキャディに対する(特にD&A)イメージを変えたいな、と思ったからです。 皆さんがそういうイメージを持っているのかは分かりませんが、私が持っていたキャディのD&Aに対するイメージは一言で言うと「謎」です。 というのも、D&Aがやっている内容や働いている機械学習エンジニアに言及した記事があまり見つけられず、実際にカジュアル面談で話を聞くまでどんな人がいるのかが中々イメージが湧かなかったのです。とはいえ、カジュアル面談まで行くというのは少しハードルがあると思います。 機械学習勉強会を公開することでD&Aのチームがどういった技術に興味があるのかをお見せして、どんな人がいるのかということが簡単に伝わればと思っています。 公開するにあたって工夫したこと 公開すると一言で言っても、どのように公開するのかというのは課題ではありました。機械学習勉強会は原則週1回は行われているので、公開する作業に負荷がかかってしまって、業務に影響を及ぼすことはしたくありません。また、勉強会の議事録を必死に取ることも避けたかったです。そこで、以下の方法を取って簡単に記事を作成するようにしました。 勉強会の録画動画よりffmpegで音声を抜き出します 勉強会のスライドをpdfに変換します それらをNotebookLMにソースとしてアップロードします ブリーフィングドキュメント機能を使ってまとめを作ってもらいます 作成されたドキュメントをレビュー、編集して記事とします NotebookLM はGoogleが提供するAIリサーチアシスタントです。アップロードされたソースをもとにチャットで質問ができたり、英語限定ですが音声による二人の掛け合い付きのポッドキャスト的なまとめを作ってくれたりします。 ブリーフィングドキュメント機能は文字通りアップロードされたソースからまとめを作ってもらう機能ですが、これがまさにブログ記事にするのにちょうどいい長さの記事を作ってくれます。 このプロセスであれば記事のレビューも含めて、2時間程度で作成でき、持続的に公開が続けられそうです。 課題感としては、どうしてもLLMっぽい文章になってしまうのでその辺りは何かしらレビュー時に低減する方法を加えたいなと思っています。 最後に 今後は週一のペースで記事を公開していくので、CADDiのデータ解析チームがどんなことに興味を持っているかを記事から知っていただけると幸いです。 まずは一つ目は こちら より、LLM as a judgeのレビュー論文を読んだことに関する記事です。 また、そこからCADDiのD&A事業に興味が湧いてきたっていう方は、是非カジュアル面談に来ていただきもっとお話を聞いていただければと思います。もちろん、エンジニアのポジションも絶賛募集お待ちしておりますのでこちらもお願いいたします。 CADDi Tech 採用情報 カジュアル面談 ML Engineer MLOps Engineer Data Engineer
It is almost the end of 2024. This article is the final article in the CADDi's product team Advent Calendar 2024 . While the vast majority of this blog is written in Japanese, I write this in English as CADDi has offices and customers in four countries, and has employees from around the world. Looking back The beginnings of CADDi Drawer Early feedback on our prototypes The importance of domain expertise The manufacturing industry Physics complicates things Retiring employees take knowledge with them The pressures facing manufacturers today About software engineering Building engineers, to build for the future Looking back While every year in a startup generally feels faster than the last, this year in particular felt richer and more diverse than in years past. We have had phenomenal growth in our enterprise SaaS products, which has been quite the journey. But with exponential growth came exponential challenges, and this year we put significant effort in building out the management team, merged two lines of businesses into one, involved the engineering team to improve management accounting, developed a dedicated recruiting team within the VPoE office, ran governance programs for new geographies, and began hiring from overseas again. The beginnings of CADDi Drawer Early feedback on our prototypes We launched CADDi Drawer a few years ago. It started as just an idea, then came a prototype whipped up in just a few days. You could hardly call it a product, but it was enough to garner interest and get valuable feedback through customer interviews. This allowed us to better understand the potential purchasers and users of this would-be product. Often in the startup world, new products struggle to get quality feedback from industry professionals, but we were fortunate enough to have an existing customer base of manufacturing professionals, thanks to our customers of our main line of business at the time, CADDi Manufacturing. In that business, we operated as a virtual factory, building out and operating complex supply chains to deliver custom ordered mechanical parts to our customers. At its peak, we had inspection and warehousing facilities throughout Japan and Vietnam, where we conducted final inspection of parts before they got shipped off to our customers. While we have since merged that line of business into CADDi Drawer, the sweat and tears from operating a physical supply chain gave us deep insight into the issues facing manufacturers, from the difficulties of a worker on the line, all the way to challenges of business planning. Software from CADDi Manufacturing integrated into Drawer platform Back to the prototype. It was essentially a user interface akin to Google image search, but for mechanical drawings. These drawings, sometimes referred to as orthographic drawings, are a graphical representation of the mechanical part to be created, its various dimensions, and other engineering details. A typical automobile would have tens of thousands of such drawings, one for each of the parts that get assembled together. Our first demos were very limited. You would type in some keywords, such as “bracket SS304”, and you would see a grid of thumbnails of drawings that contained those keywords. What surprised us was that on every demo, we consistently heard positive feedback about how being able to see a the thumbnails was instrumental in visually “scanning through” large volumes of data. Why were customers so interested in seeing thumbnails, an experience most of us take for granted? There are plenty of software packages and services out there that give us that kind of experience. The good old file explorer in Windows has both list views grid views. Sharepoint and Google Drive allow us to search by text. The importance of domain expertise The difference, we realized, was in the details of the user experience. It was the speed of the search and rendering, and also the thumbnails being just the right size–big enough to identify the overall shape and other important details, but small enough to enable efficient visual scanning. B2B enterprise software is not known for greater user experiences, but on the ground it makes all the difference. After all, the purchasing team is different from the end user. Nonetheless, we believe in the power of scratching an itch in just the right way, but we recognize that simply scratching itches will not change the world--we must identify just the right set of itches to scratch, that will build up to make a big difference. We even have a concept that we internally call the “double loop” where small incremental gains on the ground (small loops), can be used to incentivize building up and executing large corporate strategy shifts (big loops). Looking back, after a significant amount of growth, it became clear that there is a lack of domain specific software that address issues felt on the ground in industry verticals. It is difficult for the typical software professional to understand and empathize with the pressures of being in an unrelated industry such as manufacturing. We believe it takes a combination of technical prowess and domain expertise to identify problems that can feasibly be solved with software. Thanks to our heritage in physically producing and delivering machined and sheet metal products, we are fortunate to have had the opportunity to develop a team that has both. Many of our customer facing teams include members from manufacturing powerhouses such as Toyota, Honda, and Mitsubishi, while our software teams have engineers with backgrounds in payment systems, e-commerce, mobile apps, and IoT to name a few. Screenshot of CADDi Drawer's top page What started out for us as just a simple demo, implemented as a grid of thumbnails powered by an off the shelf search tool, has now morphed into a powerful knowledge engine for some of the largest corporations in Japan and abroad. Despite expanding far beyond just drawings to other valuable data assets such as 3D CAD and documents to be a Manufacturing Intelligence platform, the CADDi Drawer product still retains an important user experience lesson from its infancy–the grid of thumbnails. The manufacturing industry Physics complicates things Manufacturing is the manifestation of ideas into concrete physical reality. It underpins everything from our smartphones, to cars, to the data centers that power our apps. Even in the digital age, we humans still live in a physical world, and we saw during the pandemic just how much of our lives depend on the global supply chain that powers the flow of goods. Perhaps things will be different when we are all hooked up to the matrix, but I’m not sure if that’s a world I would like to live in anyhow. Compared to digital goods, the laws of physics complicate everything. If I drill a hole in the wrong spot, I cannot hit Ctrl-Z and undo. If I drop and damage a part, I cannot recreate it with a copy and paste operation. If I make a design change and modify some dimensions, I cannot migrate existing physical parts to the new dimensions. If the required nuts and bolts do not arrive at the assembly line on time, all I can do is call up the supplier and inquire. All that to say, it is astronomically cheaper to correct for mistakes early in the process, ideally in the digital world. There is very little we can do to economically “fix things up” once we are in the physical world. Retiring employees take knowledge with them Businesses in manufacturing, from small job shops to the Fortune 500s, face a shortage of skilled labor, exacerbated by their aging workforce approaching retirement. The loss of knowledge and experience that has been built up over the decades have heavy consequences for everybody. No longer can you just ask that guy who's seen it all. The veteran walking encyclopedia will no longer be there to answer questions on a whim. The advent of digital technologies has allowed us to have huge advances in robotics and automation, much of that critical experience still lives in the reams of paper stored in a warehouse, and cached in the minds of the veterans. Despite all the media coverage of digital technologies and AI, we were surprised to hear that even some of the largest industrial manufacturers in the world still have countless historical drawings in boxes. It turns out, it's not meaningful to just scan them all into a file server, if you don't have a good way to use them. In the software space, all of our software and design docs tend to be in the digital space, readily accessible at the touch of a key. We benefit from the accessibility of information, the ability to reuse existing components, and build on top of them. In contrast, manufacturing has a much longer history, requiring us software professionals to step up to the plate to help leverage the past, and push the industry forward. The pressures facing manufacturers today The last decade has shown an increasingly volatile world, from chaos in the supply chain during the pandemic, to the multitude of geopolitical escalations across the globe. We consumers are demanding ever more variety, delivered faster, and straight to our doorsteps. We demand increased corporate responsibility for CO2 emissions and environmental impact. Manufacturing is inextricably intertwined with logistics, borders, economics, and politics. It supports our every day lives, and it must continue to change to adapt to the new realities. Manufacturing is changing We believe that the best way forward is to leverage the knowledge built up over the past decades, by learning from historical data, and leveraging it to build on the shoulders of giants. Much of our current efforts are focused on making sense of existing data, through investments into data engineering, domain specific AI models, and building applications that allow for data acquisition without getting in the way of everyday operations, and provide highly refined data to feed use cases critical to our customers. About software engineering Personally, as a technologist, working with web-related technologies is source of entertainment. Compared to the vendor-coupled embedded systems I used to work on, the open nature of web development is a breath of fresh air. It is a testament to human collaboration, with major corporations from across the globe collaborating on open standards, to build the foundation for what’s to come. While the high volatility of the ecosystem can be distracting, the same freedoms that allow for volatility have also pushed the boundaries of what the internet and the browser could accomplish. To name a few highlights of 2024 that come to mind, we saw the release of React 19, building upon Suspense and React Server Components. The xz utils backdoor was a strong reminder on the challenges of securing our software supply chain. ClickHouse added Iceberg support, and DuckDB hit the 1.0 milestone. We saw the next major set of changes to the Rust language in the 2024 edition, to be released as stable in just a few months. Kubernetes is everywhere and turned 10 years old. OpenAI acquired real time vector database Rockset, and major cloud providers are hard at work developing their own LLMs, while Meta released Llama3 as an openly available model. It was an eventful year in the world of web development, data engineering, and AI, and I could nerd out all day about this. However, as a professional in an industry vertical, none of that is important. We have problems to solve, and technology is a means to an end. Programming languages, frameworks and methods are all just tools in a toolbox. As much as I would like to go on about the phenomenal engineering behind the scenes, a customer could care less. What makes a great engineer different from a good engineer, is the ability to acquire, select, and leverage the best tool for the job. Not a tool that works, but the best tool. That best tool is not necessarily the one that leads to the most elegant technical architecture, but it is the one that fits the organization and the problems facing customers. And therein, I think, lies one of the challenges for myself, and the greater software community, in the coming years. We must simultaneously work towards two somewhat orthogonal objectives, the pursuit of technological advancement, and the effective application of tools to solve problems. Building engineers, to build for the future As the adage goes, when all you have is a hammer, everything looks like a nail. It is our responsibility to ourselves as software professionals to continue to grow our individual toolboxes, so that we know what tools exist in the world, and to share that knowledge with our communities. But it is our responsibility as employees to ensure that we are acting in the best interest of our employers, by leveraging the right tools for the right problem. Not because it tickles our curiosity, but because it leads to the best results for customers. At CADDi, we believe that having a large toolbox of both hard and soft skills is what allows us to build just the right solution. We want our software engineers to be have T-shaped skills–well-versed in their respective fields, but also possessing a holistic view of what it takes to build a product. We want our engineers to not just keep up with the fast paced change of technology, but also continue to develop breadth within and beyond technology. Thanks to our customers, we are facing unprecedented growth in our business and product, but with that comes the need to understand the economics of the business, the complexities of human thought, and the ability to roll with the punches as the environment changes. While much of the developed world outsourced their manufacturing overseas in the search for ever cheaper goods, Japan has remained a bastion of manufacturing. We leverage this as an engineering team in Japan, taking the best of both worlds--the manufacturing expertise in Japan, and an ever globalizing software engineering workforce. But with this advantage comes the challenges of having a highly diverse workforce in a relatively homogenous culture in Japan. The most obvious is language but differences in business customs and culture also come to mind. In the last year, we have made big strides towards this challenge. We have had a number of great folks join our management team, many of whom have experience both in Japan and abroad. Some ran businesses in the US, while others brought up development centers in Asia. We have also worked to enable a more diverse working environment by developing engineering specific HR functions to address the needs of a multi-national team with mixed languages--something a Japan-centric HR team would not be able to do. The technology division all-hands meetings are run twice, once in English and again Japanese. We have regional all-hands to account for geographic differences. We are not perfect, but we strive to continue empower our customers by leveraging the world class manufacturing expertise in Japan, combined with world class software engineering. As we try to answer our customers' appetite for better software to democratize their historical data, we are accelerating our hiring from around the world to keep up. I hope this blog post gives a bit of insight into the potential impact of our work. CADDi's mission is to "unleash the potential of manufacturing", and this next year will be a time of transformation as we rapidly scale out our team, business, and customer base around the world. It’s an exciting time, and if you’re interested in taking part, please check out our open positions in Japan and in the US, Vietnam, and Thailand .
本記事は、CADDi プロダクトチーム Advent Calendar 2024 24 日目の記事です。 adventar.org こんにちは、Tech チームの @akitok_ です。 本記事では開発者向けドキュメントの改善を半年以上続けてきた結果とその課題、今後の展望などを紹介します。 ドキュメント改善の背景 ドキュメントメンテナンスをやってみた結果 ドキュメント数の削減 ドキュメントのアクセス数の変化 仮説 ドキュメントメンテナンス活動のふりかえり 毎月のメンテナンス対象ドキュメント数の推移 チーム内でのふりかえり 現時点の開発者のドキュメント課題とは何なのか? 終わりに ドキュメント改善の背景 CADDi では組織もプロダクトも急成長しており、Platform もそれに追従しながら開発のスタンダード・基盤作りを行い、開発者向けのドキュメントを多く作成してきました。そういった中、ドキュメントに対する以下のような課題が見えてきました。 ユーザーがたどり着けない 最新化されていない 構造や粒度や質がバラバラ いくら重要で価値があるドキュメントがあっても、適切に利用されていないのでは意味がありません。そこで、2024 年 3 月からドキュメントの再構成やポリシー定義化を行い、5 月から実際にドキュメントメンテナンスを始めました。その活動の詳細は、7 月に Platform Engineering Kaigi 2024 というイベントで、お話しましたので以下の資料や動画をご覧ください。 caddi.tech speakerdeck.com ドキュメントメンテナンスをやってみた結果 現在も継続してドキュメントメンテナンスを行っていますが、ここまでの結果を紹介します。 ドキュメント数の削減 ドキュメント改善を開始した時点で管理対象ドキュメントは 199 ページありましたが、現在は 147 ページと、約 26 % もの管理ドキュメントを削減できました。具体的には 47 ページのドキュメントをアーカイブし、検索ノイズや管理対象ドキュメントは減らすことが出来ました。 ドキュメントのアクセス数の変化 各月のドキュメントアクセス総数の変化は以下の通りです。 *1 初回のドキュメントメンテナンスが完了したのが、2024 年 5 月末ですので、2024 年 6 月は有意な上昇傾向がありそうに見えますが、それ以降は単純な上昇傾向ではなくほぼ横ばいです。 また、各月の閲覧数の多いドキュメントをランキングで見ると、以下のような傾向も見られました。 当月に開発者に対して更新通知・説明がされたページのアクセス数が有意に増える。 新サービスの開発・リリースが多い時期に、アナウンスがなくとも Production Readiness Checklist や Log Format などのアクセス数が有意に増える。 ランディングページのアクセス数が相対的に低い。 仮説 収集可能な定量データから得られた情報は少ないですが、以下のような仮説が考えられます。 ドキュメントメンテナンスを実施しているが、アクセス総数が横ばいになった → 不要なページ数が減って、検索ノイズが減った エンジニアの数は毎月増えているがアクセス総数が比例していない → オンボーディングの動線が悪い可能性がある ランディングページのアクセス数が相対的に低いが、開発者が適宜必要なドキュメントを閲覧しようとしている → 多くのユーザーは検索やブックマーク・リンクなどからアクセスしており、ランディングページの認知が低い ドキュメントメンテナンス活動のふりかえり 改善自体の評価とは別に、チームのドキュメントメンテナンス活動のふりかえりも進めました。 毎月のメンテナンス対象ドキュメント数の推移 2024 年 5 月 〜 12 月の活動でチェックしたドキュメントの総数は延べ 402 ページ、修正したドキュメントの総数は、延べ 132 ページでした。ドキュメントの鮮度や有効性が低下したと見られるドキュメントに対して毎月対応した量をグラフにしてみると以下のようでした。 鮮度低下ドキュメントへの対応 有効性低下ドキュメントへの対応 やはり運用開始した序盤はドキュメント総数もかなりの量ですが、徐々に作業量も落ち着いてきています。 作業時間としても、開始当初は毎週 1 時間 × 4 人= 月 40 時間で実施していましたが、現在は隔週 1 時間 × 3 人 = 月 6 時間で済んでおり、85 %省力化が進んでいます チーム内でのふりかえり チームのふりかえりとして、ドキュメント改善活動の KPT も実施しました。 Keep メンテ対象のドキュメントが徐々に減ることで、毎月の業務を圧迫することなく安定して運用できている 初見のドキュメントを読んで、結果的にキャッチアップにもなった 鮮度低下へのアクションは今まで通り継続出来れば良さそう Problem 有効性低下へのアクションについて継続改善は出来ているが効果を測れていない ドキュメント全体のアクセス数が横ばい 閲覧されていないドキュメントはリストアップ出来ているが、もっと閲覧されるべきだが閲覧されていないドキュメントのリストアップが出来ていない そもそも誰にいつ読んでほしいドキュメントなのか、整理されていない Platform チーム管理外の開発に関わるドキュメントも多くある Tips のようなドキュメントへの対応が難しい Try Tips 資料がドキュメントメンテナンスの対象になったら、そのまま修正を行うのではなく、ドキュメントタイプや読者、ゴールの明確化を最初に実施する 改めて現時点の開発者のドキュメント課題を訊いてみる 現時点の開発者のドキュメント課題とは何なのか? 今回のドキュメントメンテナンス活動は一定の成果はあったものの、ドキュメント改善の検討をし始めた 2024 年 3 月時点とは異なる以下のような変化が起きています。 プロダクトの成長速度や開発者が増すペースが上がっている 今までよりさらにローコンテクストに伝える必要が出てきた また、英語話者も増えている 横断組織が増え、CTO Office や Product Security Team、QA Team が提供する開発者向けドキュメントも増えてきた こういった背景の中で、開発者向けにアンケートを実施しました。その結果、以下のような課題があることが分かってきました。 特にプロジェクトの開始時に何のドキュメントをどの順序で見るべきか迷う 開発に必須となるポリシーをドキュメントでカバーしきれていない オンボーディングドキュメントが最新化されていない 検索が弱いため、ドキュメントの存在を認知できない(元からある課題) 以上から、開発のライフサイクルに沿ったドキュメント体系になっていないことが大きな課題のひとつだと判断しました。この課題を解決するために、以下のような Step で対応することを検討しています。 必要な資料の整理 開発のライフサイクルにおいて、いつどのような資料が必要なのか整理します。 資料の導線設計 オンボーディングや開発着手時に参照するドキュメントのインデックスを整理し、ガイダンスを用意します。 不足している資料の作成 不足していると判明したドキュメントを、横断組織が中心となり、作成の計画を進めます。 鮮度や有効性のメンテナンス運用の水平展開 ここまでの Step で開発ライフサイクルに沿ったドキュメント体系の整理は完了しているので、ここから改めて鮮度低下や有効性低下が発生しているドキュメントを抽出し、メンテナンスする運用にしていきます。 終わりに 本記事では、Platform Engineering Kaigi 2024 で登壇した開発者向けドキュメントの改善の後日談について書かせていただきました。開発者向けのドキュメント改善に向けてはまだまだ道半ばです。ドキュメントの改善を通じた開発者体験の向上により、開発者のポテンシャル解放を推し進めていく、そんな仕組み作りをしていきたいと考えています。 *1 : 私たちはドキュメント管理システムに Confluence を用いているので、Confluence API で Page Views 数を取得しました。
本記事はキャディ株式会社のアドベントカレンダーに寄稿しています。 adventar.org こんにちは。 CADDi の Analysis PlatformチームでMLOps エンジニアを務めているcdi-amaniです。 普段は図面データ活用クラウド「CADDi Drawer」のエンジニアとして、機械学習のプラットフォームの開発を行っています。 本記事はアドベントカレンダー22日目の記事として、Pythonのログ改善をテーマに書きました。 それでは、早速本題に入りましょう。 Python開発者の皆さんはログ出力において、こんな課題ありませんか? 処理対象を特定するために、 request_id などのコンテキスト情報を全てのログメッセージに含めたいが、ログメッセージ毎に記述するのは手間で、メンテナンス性も低下してしまう。 アプリケーションの深い部分(コールスタックの奥にある関数やモジュール)では、スコープ的にこれらの情報を直接参照することができない。関数の引数として渡すのも冗長で避けたい。 これらの課題を解決するために、この記事では contextvars を活用してログを改善する方法を紹介します。 問題の背景: contextvars導入前の状態 チームが担当する一部のアプリケーションでは、共通のログモジュールを使って、ログをJSON形式で構造化して出力しています。 このログには、timestampやログを出力した関数名など、さまざまな共通の基本情報に加えて、 request_id や client_id などの処理対象に関連する情報も含めています。 以下は、contextvarsを使う前のログモジュールを簡略化したサンプルです。 # logger_setup.py import logging from logging import Logger from pythonjsonlogger import jsonlogger from datetime import UTC, datetime class CustomJsonFormatter (jsonlogger.JsonFormatter): def format (self, record: logging.LogRecord) -> str : record.func_name = record.funcName record.timestamp = datetime.now(UTC).isoformat(timespec= "milliseconds" ) record.severity = record.levelname return super ().format(record) class LoggerSetup : def __init__ (self, module_name: str , log_level: int ): self.log_level = log_level self.logger_base: Logger = logging.getLogger(module_name) self.logger_base.setLevel(self.log_level) def setup_handler (self) -> logging.Handler: """handlerの設定""" handler = logging.StreamHandler() handler.setLevel(self.log_level) return handler def setup_formatter (self, handler: logging.Handler) -> None : """handlerでJSON formatterを設定""" formatter = CustomJsonFormatter() handler.setFormatter(formatter) self.logger_base.addHandler(handler) def get_logger (self) -> Logger: handler = self.setup_handler() if handler: self.setup_formatter(handler) return self.logger_base それを各種アプリから以下のように使っていました。この記事では app1.py というアプリを例として使います。 app1.py はリクエストメッセージを受け付けたあと、それを処理するための関数を呼び出して、処理段階ごとのログメッセージを出力するものです。 app1.py で共通ログモジュールを使ってloggerをセットアップする部分: # app1.py import logging from logger_setup import LoggerSetup logger_setup = LoggerSetup(module_name=__file__, log_level=logging.INFO) logger = logger_setup.get_logger() リクエストメッセージを受け取る関数: 共通情報以外に、 request_id のようなコンテキスト情報は、各logger文で extra={"request_id": message.request_id} として明示的に渡していました。 # app1.py def receive_message (RequestMessage message): # マルチスレッド対応のメッセージ受信関数 logger.info( "receive_messageのログだよ。request_idは毎回extraで渡してくれなければわからないよ" , extra={ "request_id" : message.request_id} ) # メッセージ処理スタート process_request(message.request_data) リクエストを処理する関数: 以下の例にある process_request や deep_function のようなコールスタックの奥にある関数のスコープでは、messageに含まれていた request_id を参照できないため、そのような関数ではコンテキスト情報をログメッセージに含めていませんでした。 # app1.py def deep_function (): logger.info( "deep_functionのログだよ。request_idなどはスコープ外なので、ここではextraとして渡せない..." ) # 実際の処理コード ... def process_request (RequestData data): logger.info( "process_requestのログだよ。request_idなどはスコープ外なので、ここではextraとして渡せない..." ) # 実際の処理コード ... deep_function() ... このセットアップで出るログは以下のようなログになります。 { " message ": " receive_messageのログだよ。request_idは毎回extraで渡してくれなければわからないよ ", " request_id ": " 12345 ", " func_name ": " receive_message ", " timestamp ": " 2024-12-16T12:33:24.205+00:00 ", " severity ": " INFO " } { " message ": " process_requestのログだよ。request_idなどはスコープ外なので、ここではextraとして渡せない... ", " func_name ": " process_request ", " timestamp ": " 2024-12-16T12:33:24.205+00:00 ", " severity ": " INFO " } { " message ": " deep_functionのログだよ。request_idなどはスコープ外なので、ここではextraとして渡せない... ", " func_name ": " deep_function ", " timestamp ": " 2024-12-16T12:33:24.206+00:00 ", " severity ": " INFO " } この方法には以下のような問題がありました: 情報が参照可能なスコープの中でも、コンテキスト情報を毎回 extra={} で渡す必要がある コールスタックの奥にある関数では request_id を参照できないため、ログに記載できず、どの request_id に対応するログなのかがわからなくなってしまう。特にエラーログの場合、この点が重要な課題でした。 上記のような問題を解決するために、pythonのcontextvarsを導入しました。 解決策: contextvarsでコンテキスト情報を管理 contextvars はPython 3.7で追加されたモジュールで、スレッドローカルのように各実行スレッドごとに独立したデータを管理できます。 これを利用すると、一度だけ設定したコンテキスト情報をどこからでも参照できるようになります。 基本的な使い方は以下の通りです。 ステップ1: contextvarsの初期設定:まず、コンテキスト変数を定義します。 import contextvars # コンテキスト変数を定義 request_id_context = contextvars.ContextVar( "request_id" , default= None ) ステップ2: コンテキスト変数を設定 request_id_context.set( "12345" ) ステップ3: コンテキスト変数を参照 request_id = request_id_context.get() 以上の3ステップが基本的な使い方になります。 logger_setup.py と app1.pyへのcontextvarsの適用 先ほど説明したcontextvarsの基本的な使い方を、実際にlogger_setup.pyとapp1.pyに適用してみます。 logger_setup.pyにあるCustomJsonFormatterクラスを、contextvarsを活用して次のように改善します。 # logger_setup.py import logging from logging import Logger from pythonjsonlogger import jsonlogger from datetime import UTC, datetime import contextvars from typing import Any # コンテキスト変数を定義 request_id_context = contextvars.ContextVar( "request_id" , default= None ) client_id_context = contextvars.ContextVar( "client_id" , default= None ) class CustomJsonFormatter (jsonlogger.JsonFormatter): def format (self, record: logging.LogRecord) -> str : record.func_name = record.funcName record.timestamp = ... ... # コンテキスト情報 record.request_id = request_id_context.get() record.client_id = client_id_context.get() ... return super ().format(record) さらに、LoggerSetupというクラスの中で、context情報をsetするための関数を以下のように実装します。 def set_context_data (self, **kwargs: Any) -> None : """ContextVarにデータを設定する""" context_mapping = { "request_id" : request_id_context, "client_id" : client_id_context, } for key, value in kwargs.items(): if key in context_mapping: # 定義済みのContextVarのみ設定 context_mapping[key].set(value) else : raise KeyError (f "Unknown context key: {key}" ) 次に、app1.py側の対応をします。 リクエストメッセージを受け取る関数 receive_message : context情報の設定およびログの出力は、以下のように行います。 なお、loggerの初期化や必要なimportは、導入前の例と同じです。 def receive_message (RequestMessage message): # 受け取ったメッセージをベースに一度だけcontext情報を設定します。 logger_setup.set_context_data(request_id=message.request_id, client_id=message.client_id) #extra={}を使わなくても上記で設定した情報が自動的にログに入るようになります。 logger.info( "receive_messageのログだよ。extra使わなくてもコンテキスト情報がcontextvarsから自動的に入るよ!" ) # 処理スタート process_request(message.request_data) リクエストを処理する関数に関しては、ログメッセージの内容以外特に変更なしです。 def deep_function (): #こちらの関数のスコープ的に参照できない情報も、contextvarsとして設定すれば自動的にログメッセージに入るようになります。  logger.info( "deep_functionのログだよ。request_idがcontextvarsから自動的に入るよ" ) # 実際の処理コード   ... def process_request (RequestData data): #こちらの関数のスコープ的に参照できない情報も、contextvarsとして設定すれば自動的にログメッセージに入るようになります。  logger.info( "process_requestのログだよ。request_idがcontextvarsから自動的に入るよ" ) # 実際の処理コード ... deep_function() これで、ログメッセージ毎に extra で情報を渡さなくても、スコープ的にその情報にアクセスできなくても、以下のように全てのログにコンテキスト情報が入ります。 { " message ": " receive_messageのログだよ。extra使わなくてもコンテキスト情報がcontextvarsから自動的に入るよ! ", " func_name ": " receive_message ", " timestamp ": " 2024-12-16T12:41:40.265+00:00 ", " severity ": " INFO ", " request_id ": " 12345 ", " client_id ": " client-11 " } { " message ": " process_requestのログだよ。request_idがcontextvarsから自動的に入るよ ", " func_name ": " process_request ", " timestamp ": " 2024-12-16T12:41:40.265+00:00 ", " severity ": " INFO ", " request_id ": " 12345 ", " client_id ": " client-11 " } { " message ": " deep_functionのログだよ。request_idがcontextvarsから自動的に入るよ ", " func_name ": " deep_function ", " timestamp ": " 2024-12-16T12:41:40.265+00:00 ", " severity ": " INFO ", " request_id ": " 12345 ", " client_id ": " client-11 " } 実際の効果 contextvarsを導入することで、以下の効果が得られました: コードが簡潔に: コンテキスト情報を毎回ログメッセージで渡さなくてもよくなりました。 スコープを超えた情報共有: 深い関数でも、request_idがログメッセージに自動的に埋め込まれるようになりました。 管理の一元化: contextvarsによって、コンテキスト情報の管理がより直感的になりました。 さらに、機械学習チームでは、ログを通じて得られる情報を基に、各リクエストの処理時間や、処理ステージごとの実行状況を監視ツールで分析しやすくなりました。 これにより、モデル推論やデータ前処理のボトルネックを特定しやすくなって、運用の改善・最適化に役立っています。 まとめ contextvarsを使うことで、ログ向けのコンテキスト情報の管理を大幅に改善することができました。特に、一度設定した情報をどこでも簡単に参照可能にする点が非常に便利です。 Pythonのログ対応で同じような課題を感じている方は、ぜひcontextvarsの導入を検討してみてください。
Introduction Our names are Kim Björkman and Nicolas Vivot and we are software engineers on the Workflow Engine team at CADDi. We work to provide the internal batch processing platform on which DRAWERs ingestion pipeline runs. This article is the 21st in the advent calendar, and is about what we learned while building this platform. Background Back when the first proof-of-concept version of CADDi DRAWER was built, it included an ingestion pipeline responsible for processing drawings, orchestrating them, and applying various machine-learning models to extract data and bring information into the digital age. This pipeline, cleverly labeled “The Pipeline” internally, was a monolith written in TypeScript and hosted on Cloud Run. It was glued together by Cloud Task queues to form a “workflow-like” experience. One process could trigger others by pushing new tasks onto queues. While this solution worked decently for a while, it came with serious drawbacks. Scalability The monolithic design of The Pipeline, deployed on Cloud Run, imposed significant scalability limitations. Although Cloud Run supports horizontal scaling up to 1000 instances, it wasn’t sufficient for our growing workloads and wasn’t cost-effective. Additionally, Cloud Run’s hard limits on CPU and memory per instance capped the processing power of each instance, restricting overall throughput. Orchestration The monolithic nature of “The Pipeline” resulted in a lack of modularity and extremely high complexity. Much of the system’s design was based on tribal knowledge, a legacy of its proof-of-concept origins. Documentation was sparse, and making updates—let alone adding new processes—was a daunting task requiring access to gatekeepers of this tribal knowledge. Visibility The Pipeline offered limited visibility into the state of processing. While individual processes could be monitored, there was no way to get a comprehensive view of the entire “workflow” or the state of its subcomponents. This made debugging and optimization difficult. The Workflow Engine In 2023, a team was assembled to address these challenges and create a robust workflow engine for CADDi’s async batch-processing needs. This new platform would not only resolve the shortcomings of The Pipeline but also lay the foundation for future scalability and transparency. After evaluating several options and conducting proofs of concept, we decided to base the new platform on Kubernetes and leverage Argo Workflows for workflow orchestration. This combination provided the scalability and modularity required for a more modern workflow engine. While Kubernetes and Argo Workflows offered powerful abstractions, integrating them with our existing systems wasn’t straightforward. We quickly realized that running a workflow platform is not only about orchestrating workflows and providing compute power, we also needed to develop additional services to handle various complexities surrounding the platform, such as: Real time cluster resource monitoring & management Cross cluster routing Priority management Platform I/O standardization Today, the new workflow engine processes thousands of workflows daily, with millions of drawings per week. Key features include: Scalability The new platform provides a new level of scalability to our business. Allowing reliable concurrent customer onboarding, all while not even using 20% of the current full capacity Now it’s easier to unlock further scalability and localization by expanding service to multiple clusters across many regions. Multi-Tenancy & Isolation GCP IAM policies ensure secure tenant isolation and data separation. GKE node pool specialization combined with node selectors and taints further ensure isolation and efficient resource usage. Visibility The Argo Workflows UI provides comprehensive insights into the states of workflows and steps. Lessons Learned The importance of collaboration Design for Scalability from the Start Stay on your toes Clearly define ownership and responsibilities The importance of collaboration to build trust and adoption When we first launched our internal platform, we were excited to provide teams across the company with a tool to streamline their workflows. However, we quickly ran into a classic chicken-and-egg problem: we struggled to get users on board because our platform didn’t yet include all the features they needed. At the same time, we couldn’t prioritize or implement those features effectively without input and feedback from users. This initial challenge taught us a valuable lesson: the importance of collaboration. Instead of trying to build a perfect solution in isolation, we shifted our focus to engaging directly with our potential users. We organized feedback sessions, established clear communication channels, and invited teams to co-design the features they needed most. By making collaboration the cornerstone of our development process, we were not only able to prioritize the most impactful features but also foster trust and enthusiasm among our users. Over time, we hope this approach will pay off. As more teams begin to use the platform and seeing its value, their feedback will help us evolve it into a truly indispensable tool. The chicken-and-egg problem isn’t a roadblock but a Catalyst for creating a culture of collaboration and shared ownership. Design for scalability from the start Retrofitting scalability and multi-tenancy into an existing system is far more challenging than designing with these principles in mind from the beginning. Early design choices that account for growth, such as modular architecture and clear tenant separation, not only save significant rework later but also ensure the platform can adapt to evolving needs seamlessly. This foresight helps avoid bottlenecks and technical debt, enabling the system to scale gracefully as adoption increases. Stay on your toes Projects often start with a set of specifications and assumptions, which are almost guaranteed to change over time, especially for long-term initiatives. Optimizations made too early can lead to unforeseen challenges and require significant adjustments to accommodate new specifications, resulting in technical debt. To navigate this, it’s essential to remain flexible and open to reevaluating decisions as the project evolves. Unexpected issues are almost inevitable, and these can force teams to revisit and rethink previous choices. For example, one major challenge we faced stemmed from our initial decision to adopt a multi-tenant/multi-namespace GKE architecture with Workload Identity Federation. We later discovered hidden limitations in Google’s implementation, which prevented our architecture from scaling as intended. This unexpected roadblock required close collaboration with Google engineers and ultimately led us to transition to a multi-tenant/single-namespace strategy as a workaround. This experience reinforced the importance of anticipating changes, accommodating unforeseen complexities, and avoiding rigid assumptions early in the design process. Lack of clear specifications at the start can lead to suboptimal architectural decisions. When specifications arrive late, after key technical choices, the result is often a compromise—bending the technology to fit the requirements and accumulating technical debt. Clearly define ownership and responsibilities Integrating the platform into the existing system turned out to be more intricate than we initially expected. With limited resources, our team stepped in to take temporary ownership of the integration components. However, without a dedicated product manager or detailed specifications, we had to work with minimal guidance in the early stages. What began as a relatively small task evolved into a critical part of the system as new requirements emerged over time. The "temporary" ownership became a long-term responsibility, and this vital component struggled with insufficient resources and attention for much of the project. This taught us a key lesson: establishing clear ownership and accountability from the start is crucial. It ensures that critical components are properly managed and supported, preventing them from being deprioritized or overlooked as the project progresses. Conclusion The journey of building and refining our platform has been an ongoing process of learning and adapting. From overcoming the scalability and visibility challenges of "The Pipeline" to creating a modern workflow engine, we’ve seen firsthand the importance of modularity, collaboration, and foresight. Key takeaways from our experience include the value of engaging users early to foster adoption, the necessity of designing for scalability from the outset, and the need to remain flexible in the face of evolving requirements. Most importantly, clearly defining ownership and responsibilities ensures that every component of the system is properly supported and prioritized. We’re proud of how far our platform has come and excited for its future growth, knowing that the lessons we’ve learned will guide us in creating even more impactful solutions.
IDチームの小森です。 CADDi プロダクトチーム Advent Calendar 2024 の18日目の記事になります。 いよいよ年の瀬ですが、皆さんは年初に立てた目標などを達成できましたでしょうか。 私はずぼらなので年初の目標など立てたことはないのですが、今年ようやく5年越しの取り組みに一区切りをつけ、誓いを果たすことができました。 私事ですが、人生3冊目の著書『 [改訂新版] プロになるためのWeb技術入門 』を2024年11月28日に刊行しました。 おかげさまで 好評 をいただいております。 本エントリーでは、技術書執筆の技術的な側面について、私が取り組んできたことを紹介したいと思います。 技術書執筆に用いるマークアップ言語 大規模技術文書に必要な要件 Antoraの採用 Antoraによる執筆 Antoraによる出力の実際 複数バージョンドキュメントの生成 Antoraによるサイトジェネレート ローカル実行 GitLab pagesでの公開 draw.ioによる作図 技術書におけるサンプルコードの管理 Google Cloudでのサンプルアプリの公開 査読レビューについて 課題と反省点 AsciiDocの反省点 章を跨いだときの参照が弱い デザイナーさんに優しくない(たぶん) 自動校正 まとめ 技術書執筆に用いるマークアップ言語 技術系の文書は何らかのマークアップ言語で作成することが多いでしょう。 おそらく、以下のあたりが選択肢になると思います。 Markdown Sphynx AsciiDoc Re:VIEW TeX 今回私は AsciiDoc と Antora を採用しました。 Antoraについては、馴染みがない方も多いと思うので、後ほど軽く紹介します。 一般的に出版社を通した技術書の商業出版では、紙面のデザインは出版社側にお願いすることになるので、著者が細かい所まで作成する必要はありません。 どのような形式で出版社に原稿をお渡しするかは、出版社との取り決め次第です。 私の場合は、自分でもある程度紙面をイメージしながら執筆したいので、前著まではMicrosoft Wordで執筆し、Wordファイルを出版社に提出していました。 今回、AsciiDocを選んだ理由はこのあと述べますが、Antoraという静的サイトジェネレーターで綺麗にHTML化できることも理由の1つでした。 大規模技術文書に必要な要件 今回執筆した書籍は紙にすると528ページと、技術書の中でもかなりのボリュームになります。 経験者の方も多いと思いますが、一定規模以上の技術系文章を執筆する場合、Markdownでは厳しい面が多いです。 私個人としては、以下のような機能は必須と考えています。 見出し、図表の自動ナンバリング 相互参照 ソースコードのinclude 複雑な表組み (セル結合など) 注釈 参考文献 昨年は当社メンバーで SoftwareDesign誌に連載 を寄稿させていただきましたが、その際はハードルの低さを重視してMarkdownで執筆しました。 1回あたり6〜8ページの分量でしたが、個人的にはMarkdownで書けるのはこのくらいの分量が限界かなと思っています。 上に挙げたような機能は特別なものではなく、昔から組版の世界で使われているTeXではあたり前に備わっているものです *1 。 本書の執筆では、初期段階でたまたま試しに使ってみた AsciiDoc が肌に合ったので、そのまま採用することにしました。 AsciiDocは、Markdownとは細かい文法に若干の違いがあるものの、概ね同じ感覚で記述することができ、上に挙げたような機能も備わっています。 特に重視したのは、AsciiDocベースの静的サイトジェネレータである Antora の存在です。 Antoraの採用 Antoraは、AsciiDocで記述したドキュメントから静的サイトを構築するツールです。 主に、ソフトウェアプロダクトのドキュメントサイト構築用途だと思いますが、技術書の執筆にも十分利用できました。 私にとって特に役に立ったのは、以下のような特徴です。 複数のソースファイルに分割された大規模ドキュメントからなる静的サイトを生成できる サイドバーに目次を表示できる Gitのタグやブランチ毎に複数バージョンのサイトを生成して切り替えられる Antoraのドキュメント 自体がAntoraで出力されていますので、出力イメージはこちらを見ると良いと思います。 好みの問題かもしれませんが、私が技術書の原稿を書くときはできるだけ紙面に近いイメージを頻繁にチェックしながら作業します。 前著までMicrosoft Wordを使用していたのもこのためでした *2 。 もちろん、執筆のモチベーションが維持しやすいという理由もあります。 Antoraでは、HTMLテンプレートやCSSのカスタマイズも可能なので、細かな調整をして紙面のイメージに近づけています *3 。 前述のように、実際の紙面デザインは出版社側のプロの方が行うので、著者が細かくこだわる必要はありません。 しかし、イメージを伝える手段としては有用かなと思っています。 Antoraによる執筆 Antoraによる出力の実際 Antoraでは、本文を記述するAsciiDocファイルをはじめ、いくつかの設定ファイルや画像、サンプルコードなどを、決められたディレクトリ構成で配置しておきます。 以下は、簡略化したディレクトリ構成のイメージです。 ここでは1章分を1つのAsciiDocファイルとして表現していますが、実際はincludeができるので、コラムなどを別のファイルに分割するなどしています。 |-- antora-playbook.yml (👈サイト構築構築方法の記述ファイル) |-- antora.yml (👈Antoraの設定ファイル) `-- modules `-- ROOT |-- examples | `-- サンプルコード |-- images | `-- 画像 |-- nav.adoc (👈左サイドバーのナビゲーション) `-- pages |-- chapter-01.adoc (👈原稿本体) |-- chapter-02.adoc |-- ・・・ AntoraはDockerイメージ版が用意されているので、このディレクトリ上で次のように実行するだけで、サイトが構築できます。 docker run -u ${UID} -v .:/antora:Z --rm -t antora/antora:3.1.9 antora-playbook.yml 執筆中はローカルPC上でサイト生成して確認し、出版社の方や査読者に確認してもらうときは、あとで説明するGitLab pages経由でこのサイトを共有することで、他の人にも同じイメージを確認してもらえます。 Antoraで生成された原稿 複数バージョンドキュメントの生成 AntoraはGitとも連携しており、Git上の任意のタグやブランチ単位で複数バージョンのサイトを生成し、切り替えて閲覧することができます。 よくある利用例は、オープンソースプロダクトのドキュメントで「バージョン1.x系」と「バージョン2.x系」のドキュメントを切り替えて見られるようにしたものです。 本執筆では、Asciidocで書いた原稿をGitリポジトリ上で管理し、CIによって自動でAntoraでHTML化しつつ、編集の方や査読者にみてもらうものはGit上に打ったTag毎のバージョンで公開するのに活用しました。 こうすることで、ある時点のスナップショットを他の人にチェックしてもらうのが容易になります。 以下のように、画面左下からバージョンを選択できるようになっていて、常に最新版の main ブランチの原稿と rc-2025-06-26 のように特定タグでの原稿を切り替えられます。 査読者にとっては、査読中に原稿がアップデートされると混乱するため、タグの方で見てもらうようにしました。 バージョン毎のドキュメント Antoraによるサイトジェネレート ローカル実行 Antoraによるサイトの構築はコマンド一発で実行できる *4 とはいえ、頻繁に実行するとなると執筆のペースが乱れます。 そこでフロントエンド開発と同じような感覚で、原稿ファイルを保存したらAntoraを自動実行して生成結果をすぐに確認できるようにしたいと思い、 fswatch コマンドで原稿ファイル( *.adoc )の変更を検出し、Antoraを実行するシェルスクリプトを書きました。 このような処理はWebpack等のツールを導入すれば実現できますが、ツールチェインを増やしたくなかったこととそれほど難しくなかったので、自作しています。 また、Macではosascriptコマンドでデスクトップ通知ができます。 原稿が増えるとAntoraの処理も数十秒かかるようになるため、以下のような処理をビルドスクリプトに組み込んで、生成完了を通知するようにしていました。 /usr/bin/osascript -e "display notification \"BUILD SCUCESS\" with title \"build.sh\"" これだけの工夫で執筆体験はかなり向上しました *5 。 GitLab pagesでの公開 筆者は自宅サーバで GitLab をセルフホストして運用 *6 しており、原稿もそこで管理しています。 GitLabにも、GitLabCI というGitHub Actions相当のCI/CDをサポートする機能があるので、これを利用しました。 原稿をGitLabにpushしたら、前述のビルドスクリプトが実行され、GitLab PagesにHTMLが公開されるところまでを自動化しています。 また、原稿を出版社の方や査読者に見せるときは、GitLab上でタグを作るだけでGitLabCIが走り、そのタグのバージョンのHTMLが生成されます。 あとは、このURLを伝えて原稿をチェックしてもらいます。昔はWordの原稿をメールに添付して送っていたので、ずいぶん楽になりました。 draw.ioによる作図 本書では図とスクリーンキャプチャが250点近くあります。 技術書とはいえ、文字だけが書かれたページを読むのは読者が疲れると思うので、なるべくページのどこかに図表・ソースコード等が1点は含まれるようなバランスにして、紙面に変化が出るように心掛けています。 特に図は重要で、文章での説明を図でも視覚的にも表現することで、理解のしやすさが大きく向上すると考えています。 これらの図は、すべてデスクトップアプリ版の draw.io で作図しています。 draw.ioで作成した図を drawio.svg 形式でローカルファイルにエクスポートすると「SVG画像が埋め込まれたdraw.io」の形式となるので、これをAsicciDoc形式のファイルと一緒にGitで管理しておきます。 こうすることで、AsciiDocから drawio.svg ファイルを直接指定してAntoraでの生成時に利用でき、便利です。 実際には、これを元にデザイナーさんが綺麗な絵を書き起こしてくれるのですが、技術的なことを説明する図は原稿の段階でなるべくきちんと作っておいたほうがスムーズです。 技術書におけるサンプルコードの管理 技術書でサンプルコードを原稿にどう取りこむかは、意外と面倒なテーマです。 特に本書では、紙面で紹介しているサンプルコードを実際にGoogle Cloudでホストして読者が動作確認できるようにしています。 以前、Wordで原稿を書いていたときは、別管理しているサンプルコードから必要な箇所を原稿にコピー&ペーストしていました。 最初は良いのですが、サンプルコードを修正したときの反映が非常に手間で、ミスもたびたび発生します。 Antoraはテクニカルライティング向けのツールということもあり、サンプルコードの取り込みに関する機能も充実しています。 今回は、Antoraの標準ディレクトリ構成に則って modules/ROOT/examples 配下に個々のサンプルアプリケーションのディレクトリを作成し、そこで原稿と一緒にサンプルコードを管理しました。 概ね、以下のような構成です。 `-- modules `-- ROOT |-- examples | |-- example-01 (👈サンプルアプリ1) | | `-- main.go | |-- example-02 (👈サンプルアプリ2) | | `-- main.go | ・・・ `-- pages |-- chapter-01.adoc (👈原稿本体) |-- chapter-02.adoc |-- ・・・ AsciiDoc内でサンプルコードを参照するときは、以下のように記述することでインクルードできます。 これで、サンプルコードを修正したときも原稿に自動反映できます。 [source,go] ---- include::example$example-01/main.go[] ---- なお、実際にはソースコードのファイル全体を掲載することはまれで、必要に応じて特定関数など一部分を掲載することの方が多いでしょう。 そのようなときは、ソースコード側に以下のように tag::タグ名[] 〜 end::タグ名[] という形式のコメントを入れておきます。 ・・・ // tag::new-session[] // 新しいセッション情報を生成する。 func NewHttpSession(sessionId string , validityTime time.Duration) *HttpSession { session := &HttpSession{ SessionId: sessionId, Expires: time.Now().Add(validityTime), PageData: "" , } return session } // end::new-session[] ・・・ AsciiDoc側では、include時に [tag=タグ名] と指定することで、特定箇所だけをincludeすることもできます。 .NewHttpSession関数(session.goより抜粋) [source#list_tinytodo-user-newhttpsession,go] ---- include::example$06-07_tinytodo-user/session.go[tag=new-session] ---- これによって、実際に公開するコードと紙面に掲載するコードが乖離する問題を防ぐことができました。 特にサンプルコードの多い本書では、この機能に本当に助けられました。 Google Cloudでのサンプルアプリの公開 このように作成したサンプルアプリケーションは、 Google Cloudの無料枠 *7 を使い、Google Cloud上でホストしています。 概ね、以下のようなことをしていますが、これらについては、すでに多くの情報が公開されているので、本エントリでは割愛します。 (Terraform周りは、SoftwareDesign誌の連載『Google Cloudを軸に実践するSREプラクティス』でも紹介しています。 本ブログの転載記事 か、『 Software Design総集編【2018~2023】 』をご覧ください!) サンプルコードを GoReleaser でビルドできるようにし、GitLab CI上でビルド ArtifactRegistryの使用容量を抑えるため、すべてのサンプルアプリケーションを1つのDockerイメージにまとめ、起動引数で実行アプリケーションを切り替える仕組みにした アプリケーションはCloudRun serviceとして実行 Google Cloudのインフラ構築まわりはTerraformで管理し、GitLab CI上から実行 査読レビューについて 今回は、総勢14名の方に査読を依頼しましたが、フィードバックの受け方は悩みました。 当初は、原稿を管理しているGitLab上でPullRequest *8 の形でコメントをもらおうかと思いましたが、以下の理由でやめました。 ひととおり書き上がった大量の原稿ファイルをPullRequestの差分として作るのが難しかった AsciiDocのマークアップに対してコメントを書いてもらうのが、レビュアーにとって負担になりそうだった 結局、以下のようにPDFの注釈機能を使ってコメントをもらう方式にしました。 Antoraで生成したサイトをChromeで表示し、PDFへの出力機能でPDF化 *9 PDFをGoogleDriveでレビュアーに共有 レビュアーはGoogleDrive上でPDFを開いて原稿をチェックし、コメントを入れる このやり方なら、レビュアーがPDFの編集ツールを持っていなくも注釈を入れられるというメリットもあります。 GoogleDrive上では、コメントのやりとりなどのコラボレーション機能もあるため、重宝しました。 課題と反省点 AsciiDoc/Antoraを中心に執筆環境を構築したことは概ね良かったのですが、いくつかの課題と反省点はあります。 AsciiDocの反省点 章を跨いだときの参照が弱い 「2.4. サーバサイドの構成要素」で説明したように〜 と、別の章の見出しを参照したいケースがあります。 Antoraでは、章を跨いで見出しや図表を参照するとき、その見出し文字列の部分を自動で取り出してくれません *10 。 このため、以下のようにリンクテキストも含めて直接指定せざるを得ないのですが、推敲段階で見出し番号や節タイトルが変わることも多いので、不正確になってしまいます。 被参照側 [#consists-of-serverside] == サーバサイドの構成要素 (・・・・文章・・・・) 参照側 「xref:02-web_system_overview.adoc#consists-of-serverside[2.4 サーバサイドの構成要素]」で説明したように〜 これについては、AsciiDocを簡易パースして見出しや図表の一覧を作り、クロス参照している箇所の見出しが正しいかをチェックするツールをGoで作ることで対処しました。 脱稿後に少し時間の余裕ができてから作ったのですが、早い段階で作っておけば良かったと思っています。 デザイナーさんに優しくない(たぶん) 書籍の版下は、Adobe社のInDesignなどで作られることが多いようです。 ここから先は私の想像なのですが、InDesignへのテキストの流し込みは手作業になってしまい、AsciiDocに限らずマークアップ記述がこの作業の邪魔になるようです。 たとえば、私の原稿上でURLを紹介する箇所は次のようにマークアップしていました。 [.url]*https://worldwideweb.cern.ch/* これがデザインされるとき、 https://worldwideweb.cern.ch/* のように、最後のアスタリスクが消し忘れられてしまうといったことがよくありました。 デザイナーさんにとっては https://worldwideweb.cern.ch/ のようなプレーンなテキストのほうが、作業しやすいはずです。 次の機会には、原稿提出用にAsciiDocを加工するツールを作ってみようかとも思っています。 自動校正 textlint や RedPen のようなツールを使った自動校正に、興味を持っている方もいるかもしれません。 私も一時期使ってみたのですが、肌に合わずやめてしまいました。 当初は、VSCodeにtextlintのプラグインを入れ、執筆中に表記揺れや言い回しなどを指摘してくれるようにしていました。 しかし、執筆中に指摘が出るとその指摘を潰す方に意識が向いてしまい、かえって集中できないという結果になり、途中でやめました。 著者の執筆スタイルにもよるかもしれませんが、私の場合は推敲・校正段階でなんども読み直して文章を直していくので、読みにくさや言い回しはその段階で大抵直ります。 また、出版社側でのチェック・修正もしてもらえます。 強いて言えばCIでこれらのツールを掛けられれば良かったのですが、素直に出版社に頼ることとして、著者としての仕事に注力することにしました。 とはいえ、自動チェックを併用できるに越したことはありません。 CI上でチェックしてPull Requestを自動作成するといったことができれば、著者・出版社にとって多くのメリットが得られるかもしれません。 これについては、次に挑戦してみたいと思っています。 まとめ AsciiDocとAntoraを中心に、大規模技術書の執筆時の工夫ポイントを紹介しました。 本来の用途通り、OSSプロダクト等のドキュメントやAPIドキュメントなどの公開にも十分力を発揮できると思います。 また書籍の出版はとても多くの労力が必要ですが、自分の文章が本という形になり書店に並ぶのは、別格の達成感があります。 ぜひ、機会を捕まえてチャレンジしてみてください。 *1 : 余談ですが、この対極で、TeXはドナルド・クヌース氏が自著の執筆にあたり、組版レベルまで自分で制御したいという必要性から開発されたものです。紙面イメージまで自分で作るなら最強で、学生時代の卒論執筆などではお世話になりましたが、なかなかハードルが高い面があると感じます。 *2 : 当時は執筆に耐えるマークアップ系の技術はTeXくらいしかなかったというのもありますが *3 : いささか邪道ですが、生のHTMLを組み込むこともできます。書籍のサポートサイト(( https://support.webtech.littleforest.jp/ )も、Antoraで作ってしまいました *4 : VSCodeではAsciiDocのプラグインを導入することで、ある程度のリアルタイムプレビューはできるのですが、Antoraとは違うので限定的です。 *5 : ここではさらっと書きましたが、実際には監視対象ファイルのフィルタリングや、ビルド頻度の調整、ローカル実行時はカレントブランチだけのビルドに限定するなどの工夫を入れたので、300行近いビルドスクリプトになってしまいました *6 : 昔はGitHubとかが出てくる前はSubversionを運用していたし、GitHubもしばらくはプライベートリポジトリが3つしか作れないという制限があったので。 *7 : 執筆はキャディとは関係ない個人活動ですので、できるかぎりコストを抑えたいのです *8 : 正確には、GitLabではMergeRequestと呼びます。 *9 : PDF化を手作業でやるのは面倒だったので、 chromedp を使ってChromeを操作し、PDF出力を自動化する簡単なツールをGoで作成しました。なお、よく利用されているAsciiDocのプロセッサであるAsciidoctorにはPDF出力機能がありますが、Antoraはサイトジェネレーターであるため、HTML出力しかできません。色々試した結果、ChromeのPDF化機能を使うのが一番確実だという結論になりました。 *10 : ひょっとしたら、私がやり方を知らないだけかもしれませんが、色々試してもダメでした。
この記事は dbt Advent Calendar 2024 の15日目の記事です。 Data Management チームの森岡です。 スプレッドシート便利ですよね。スプレッドシートをデータソースとして、BigQuery に取り込んでいるケースも多いと思います。ただ、スプレッドシートを外部テーブルとして BigQuery に取り組むとき、Google Cloud のコンソールで操作するの面倒じゃないですか。 シートの範囲を間違えたり、カラム名がイマイチで作り直したら、今度は「あれ?ヘッダー行のスキップ指定、忘れてた...」となってやり直しになること、ありますよね? 今回は、そういった煩わしさを解決するために、スプレッドシートを dbt の source としてコード管理する方法について紹介します。 スプレッドシートをsourceとしてコード管理する方法 結論から言えば、 dbt_external_tables を使いましょうということです。 手順はめっちゃ簡単です。 1. dbt_external_tables を install packages.yml に dbt_external_tables を追加して、 dbt deps を実行します。 packages : - package : dbt-labs/dbt_external_tables version : 0.10.0 2. sourceの定義を記述する 適当な dbt の source に exernal という プロパティを追加し、外部テーブルとして取り込みたい スプレッドシートの定義を記述します。 下記に例を示します。 version : 2 sources : - name : hoge tables : - name : fuga external : location : https://docs.google.com/spreadsheets/d/fuga # スプレッドシートのURL options : format : google_sheets sheet_range : "fuga_sheet!A:C" # シート名と範囲 # ここより下はオプション。利用可能なオプションはBigQueryのドキュメントを参照してください。 # https://cloud.google.com/bigquery/docs/reference/standard-sql/data-definition-language#external_table_option_list max_bad_records : 0 skip_leading_rows : 1 columns : # カラム定義 - name : foo description : "foo" data_type : STRING - name : bar description : "bar" data_type : NUMERIC 3. macroを実行し、外部テーブルを作成する 以下のコマンドを実行すると、外部テーブルが作成されます。 $ dbt run-operation stage_external_sources --args "select: {DBT_MODEL_NAME}" ちなみに、このmacroには外部テーブルを置き換えはせず、メタデータのみを更新する① create if missing, refresh metadata と置き換えを行う② create or replace の2つのモードがり、デフォルトだと①のモードで実行されます。②のモードで実行したい場合は、以下のような引数を渡します。 $ dbt run-operation stage_external_sources --args "select: {DBT_MODEL_NAME}" --vars "ext_full_refresh: true" 以上です。 これで、dbt で スプレッドシートをsourceとしてコード管理できるだけでなく、ちょっと変更したいときはコマンド一発で再構築ができるようになります。便利ですね。 おまけ 実は、dbt-external-tables というパッケージの存在は数年前から知っていたのですが、スプレッドシートにも利用できると知ったのは最近です。というのも、 dbt-external-tables の GitHub のページのどこにもスプレッドシートについて書いていないんですよね。 ページ内を spreadsheet で検索すると、以下のような issue もでてくるので、サポートしていないのだと思っていました。 Support for BigQuery external tables on Google Sheets using python · Issue #212 · dbt-labs/dbt-external-tables · GitHub 最近になって、ないなら作るかーと思い立ち、 dbt-external-tablesを参考にしようとコードを読んでいたら、「あれ?これできるじゃん…」となった次第です。ちゃんとコード読むって大事ですね。 まとめ 今回の記事では、データの入口として利用されるスプレッドシートを、dbt sourceとしてコード管理する方法をご紹介しました。 スプレッドシートは便利なツールですが、適切に管理しなければ、(データエンジニアにとっての)地獄を生み出す要因になりかねません。データ基盤の透明性を高め、依存関係を俯瞰的に把握するためにも、コード管理は非常に有効な手法です。本記事がみなさまの参考になれば幸いです! ちなみに、データの出口として利用されるスプレッドシートをdbt exposureとして管理する方法については、以下の記事で詳しく紹介されています。我々のチームでも非常に参考にさせていただいております。この場をお借りして、感謝申し上げます。 dbtのモデルとConnected Sheetsの依存関係をexposureで表現して、データ管理を効率的に行なおう - yasuhisa's blog 最後に宣伝です。 CADDi でもアドベントカレンダー を実施しているのですが、その中で4日目の記事も担当させていただきました。 手前味噌ではありますが、こちらも実用的な内容になっていると思いますので、ぜひご覧ください! caddi.tech
本記事は キャディ株式会社のアドベントカレンダー に寄稿しています。 こんにちは。キャディ株式会社の Analysis Platform Group で MLOps エンジニアを務めている廣岡です。普段はキャディの図面活用クラウドサービス CADDi Drawer のエンジニアとして、図面に対する機械学習解析のインフラやバックエンドの開発を行っています。 今回は KEDA を使って、Google Cloud Pub/Sub のメッセージ数に応じて Kubernetes ワークロードのスケーリングを行う方法を紹介します。 背景 Drawer では図面データの解析や格納など、サービスの様々な箇所で非同期処理が使われています。特に機械学習による図面解析は時間がかかるものが多いため、適したパフォーマンスで解析を実行するために一部で非同期実行を扱っています。 Google Cloud で非同期処理を実現するマネージドサービスの一つとしては、Cloud Pub/Sub があります。Pub/Sub を使うことでメッセージの送信側と受信・処理側を分離することができ、図面のアップロードに応じた非同期の機械学習解析の実行などにも利用できます。 非同期処理を運用する上では、動的に変化するメッセージ数に応じて適切に処理をスケーリングさせる必要があります。そこで今回は Pub/Sub などのイベントに応じたスケーリングを管理するツールとして KEDA および使い方のサンプルを紹介します。 KEDA KEDA (Kubernetes Event-driven Autoscaling) は Kubernetes ワークロードに対してイベント駆動型のスケーリングを可能にするオープンソースツールです。 今回紹介する Google Cloud Pub/Sub だけでなく、Azure, AWS などの代表的なパブリッククラウドや、Apache Kafka, PostgreSQL などさまざまなイベントソースに対応しています。また KEDA のスケーリングは k8s の HPA (Horizontal Pod Autoscaling) を拡張する形で動作するため追加で必要な知識が少なく済むことや、対象の pod を最小ゼロに設定できるなどのメリットもあります。 Drawer ではいくつかの非同期処理のスケーリングにすでに KEDA を導入しており、図面に基づく処理の効率的な実行に貢献しています。 KEDA によるスケーリングの制御 Pub/Sub ベースの KEDA によるスケーリングは、Google Cloud からすでにドキュメントとサンプルが公開されています。ただしいくつかつまづいた点があったので、こちらを踏襲・補足しながら導入を試してみます。実行の際は適宜課金や各種 API が有効な Google Cloud プロジェクトを用意してください。 cloud.google.com github.com セットアップ クラスタ構築とクラスタへの KEDA のインストールはドキュメント通りに実行できます。サンプルでは GKE 用 Workload Identity Federation を有効にしており、これによってサービスアカウントキーを発行せずに IAM による権限付与と利用ができます。私が実行した際の各環境は以下でした。 GKE クラスタバージョン: 1.30.5-gke.1443001 keda (helm chart): 2.16.0 クラスタに KEDA をインストールすると、keda-operator という pod および k8s サービスアカウントが作成されます。これによって外部のイベントに基づいた Deployments のスケール管理が可能になります。 サンプルでは Workload Identity 連携を通じて直接 keda-operator の k8s サービスアカウントに権限付与していますが、私の場合はこれだと今後の Pub/Sub メッセージ数のスケーリング(おそらくメトリクスの参照部分)がうまくいかなかったので、Google Cloud サービスアカウントと k8s サービスアカウントを紐つける形で権限を付与します。これによって、keda-operator が Pub/Sub メッセージ数を含めた Google Cloud のメトリクスを参照できるようになります。 # サンプルの↓だとうまくいかなかった # gcloud projects add-iam-policy-binding projects/${PROJECT_ID} \ # --role roles/monitoring.viewer \ # --member=principal://iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/${PROJECT_ID}.svc.id.goog/subject/ns/keda/sa/keda-operator # KSA に紐つける GSA を作り権限付与する gcloud iam service-accounts create keda-operator \ --project= ${PROJECT_ID} gcloud projects add-iam-policy-binding ${PROJECT_ID} \ --member "serviceAccount:keda-operator@ ${PROJECT_ID} .iam.gserviceaccount.com" \ --role "roles/monitoring.viewer" gcloud iam service-accounts add-iam-policy-binding keda-operator@ ${PROJECT_ID} .iam.gserviceaccount.com \ --role roles/iam.workloadIdentityUser \ --member "serviceAccount: ${PROJECT_ID} .svc.id.goog[keda/keda-operator]" kubectl annotate serviceaccount keda-operator \ --namespace keda \ iam.gke.io/gcp-service-account=keda-operator@ ${PROJECT_ID} .iam.gserviceaccount.com ワークロードとスケーリングの構成 Pub/Sub ワークロードのデプロイ Pub/Sub トピックとサブスクリプション、ワークロードのサービスアカウントへの権限付与はドキュメント通りに実行します。マニフェストの適用については、サンプルに記載されている Docker イメージのプルに失敗するようなので、別途メッセージをサブスクライブするだけのコンテナイメージを作成しておきます。 # main.py from google.cloud import pubsub_v1 import os import logging logger = logging.getLogger(name=__name__) PROJECT_ID = os.getenv( 'PROJECT_ID' ) SUBSCRIPTION_ID = os.getenv( 'SUBSCRIPTION_ID' ) def callback (message): logger.info(f "Received message: {message.data}" ) message.ack() def main (): subscriber = pubsub_v1.SubscriberClient() subscription_path = subscriber.subscription_path(PROJECT_ID, SUBSCRIPTION_ID) streaming_pull_future = subscriber.subscribe(subscription_path, callback=callback) logger.info(f "Listening for messages on {subscription_path}..." ) try : streaming_pull_future.result() except KeyboardInterrupt : streaming_pull_future.cancel() if __name__ == "__main__" : main() # requirements.txt google-cloud-pubsub==2.26.1 # Dockerfile FROM python:3.11-slim WORKDIR /app COPY main.py /app COPY requirements.txt /app RUN pip install --no-cache-dir -r requirements.txt CMD ["python", "main.py"] コンテナイメージのビルドとプッシュ。コンテナレジストリは適宜作成してください。 docker build --platform linux/amd64 -t asia-northeast1-docker.pkg.dev/ ${PROJECT_ID} / < repository > /keda-pubsub-sample:latest . gcloud auth configure-docker asia-northeast1-docker.pkg.dev docker push asia-northeast1-docker.pkg.dev/ ${PROJECT_ID} / < repository > /keda-pubsub-sample:latest マニフェストを適用することで、ワークロードをデプロイします。Google Cloud のプロジェクト ID や Docker イメージの指定は適宜修正してください。 apiVersion : v1 kind : Namespace metadata : name : keda-pubsub --- apiVersion : v1 kind : ServiceAccount metadata : namespace : keda-pubsub name : keda-pubsub-sa --- apiVersion : apps/v1 kind : Deployment metadata : name : keda-pubsub namespace : keda-pubsub spec : selector : matchLabels : app : keda-pubsub template : metadata : labels : app : keda-pubsub spec : serviceAccountName : keda-pubsub-sa containers : - name : subscriber image : asia-northeast1-docker.pkg.dev/<PROJECT_ID>/<repository>/keda-pubsub-sample:latest env : - name : PROJECT_ID value : <PROJECT_ID> - name : SUBSCRIPTION_ID value : "keda-echo-read" --- スケーリングの設定 KEDA の ScaledObject をデプロイすることで、Pub/Sub メッセージ数に基づいたスケーリングを構成します。下記マニフェストは Google Cloud のサンプルを元に、最小レプリカ数や Pub/Sub に関するスケーリング設定を明示的に記載しています。 apiVersion : keda.sh/v1alpha1 kind : TriggerAuthentication metadata : name : keda-auth namespace : keda-pubsub spec : podIdentity : provider : gcp --- apiVersion : keda.sh/v1alpha1 kind : ScaledObject metadata : name : keda-pubsub namespace : keda-pubsub spec : pollingInterval : 5 cooldownPeriod : 10 minReplicaCount : 0 maxReplicaCount : 5 scaleTargetRef : name : keda-pubsub triggers : - type : gcp-pubsub authenticationRef : name : keda-auth metadata : mode : "SubscriptionSize" value : "3" activationValue : "0" subscriptionName : "keda-echo-read" --- ちなみに type:gcp-pubsub でなく、 type:gcp-stackdriver として下記のように設定しても同様にスケーリングを構成できます。この設定の場合は Pub/Sub 以外にも多くのメトリクスに基づいたスケーリングができます。 ... - type : gcp-stackdriver # also OK metadata : projectId : <PROJECT_ID> filter : 'metric.type="pubsub.googleapis.com/subscription/num_undelivered_messages" AND resource.type="pubsub_subscription" AND resource.label."subscription_id"="keda-echo-read"' targetValue : "3" スケーリングを構成すると、自動的に Deployment に対する HPA が構成されます。最初にスケーリングを構成する際は、HPA のステータスが✅になるまでに数分程度時間がかかるようなので少し待ちましょう。 スケーリングを構成した状態で kubectl get hpa,scaledobject -n keda-pubsub コマンドを実行すると、作成された HPA と ScaledObject リソースを確認できます。Pub/Sub メッセージがない間は ScaledObject によって Pod のレプリカ数がゼロになります。 サンプルリクエストによる動作確認 試しに Pub/Sub にメッセージを送信して、スケーリングの振る舞いを確認してみます。 for num in { 1 .. 20 } do gcloud pubsub topics publish keda-echo --project= ${PROJECT_ID} --message= "Test" done watch "kubectl get pod -n keda-pubsub" コマンドを実行すると、Pod の数が増減する様子を確認できます。また Google Cloud コンソールの Deployment のイベントからも、スケーリングが制御されていることを確認できます。 KEDA によって Pod の数が増減している様子 おまけ Google Cloud のサンプルでは、KEDA-HTTP アドオンを使った LLM のスケーリングも紹介されています。HTTP アドオンを使うことで、Cloud Run のようにリクエストをバッファリングすることができ、最小レプリカ数を0としたスケーリングができます。 GitHub - kedacore/http-add-on: Add-on for KEDA to scale HTTP workloads · GitHub KEDA を使用してゼロにスケーリングする  |  Kubernetes Engine  |  Google Cloud Documentation kubernetes-engine-samples/cost-optimization/gke-keda/ollama at main · GoogleCloudPlatform/kubernetes-engine-samples · GitHub このサンプルも概ねドキュメント通りの手順で動作しますが、 otwld/ollama-helm の仕様に合わせて下記フォーマットでの記載が必要でした。試す際はご注意ください。 # helm-values-ollama.yaml ollama : ... models : pull : # この行の追加が必要 - gemma:7b - llama2:7b HTTP リクエストに関しては Cloud Run でも似たようなスケーリングができますが、2024/12 の時点では Cloud Run での GPU 利用は限定的なリージョンでのみプレビュー提供されており、日本リージョンで一般提供されるのはまだ先のようです。 サービスの GPU サポート  |  Cloud Run  |  Google Cloud Documentation KEDA-HTTP アドオンを用いることで GPU ワークロードのゼロスケーリングが可能になるので、こういった用途が必要な際は検討するのも良いかもしれません。 クリーンアップ 検証が終わったら不要なリソースは消しましょう。特に LLM のサンプルで利用する GPU ノードプールは高価になりやすいため、不要なコストが発生しないように注意してください。 まとめ この記事では K8s ワークロードのスケーリングに KEDA を導入するサンプルを検証しました。導入時にはドキュメントなどを通じて仕様を理解する必要がありますが、スケーリングに特化したツールのため比較的キャッチアップしやすいようには感じます。イベント駆動の柔軟なスケーリングを考える際にはぜひ検討してみてください。 最後に、キャディ株式会社では「製造業AIデータプラットフォーム」という構想へ向けて、ソフトウェア開発に取り組む仲間を募集しています。もちろん機械学習開発チームも絶賛募集中ですので、ご興味がある方はお気軽にご連絡ください。 CADDi Tech 採用情報 エンジニア向け会社紹介資料 - Speaker Deck Senior Software Engineer, Backend - Analysis Platform - / キャディ株式会社 Machine Learning Engineer / キャディ株式会社 なぜキャディはCTO経験者を1年間で3名も採用できたのか?優秀なエンジニアを惹きつける企業の条件 - Findy Engineer Lab www.youtube.com
本記事は、CADDi プロダクトチーム Advent Calendar 2024 10日目の記事です。 CADDi プロダクトチーム Advent Calendar 2024 - Adventar はじめに CTO室の西名( @mikesorae )です。最近はもっぱら予算策定や生産性改善の施策を担当しています。 ソフトウェア開発とビジネス、インフラ構成と予算管理、生産性と投資対効果等、事業組織でソフトウェアを開発する上ではどうしてもお金の話がついて回ります。 本記事では、ソフトウェア開発者がステークホルダーと建設的に議論し、より大きなインパクトを生み出すために抑えておくべき会計の用語やポイントをご紹介します。 Why 会計知識 ゲームのルールを理解する 「今このチームにこれ以上の人員は追加できない」「なぜこのツールが必要なのかを説明してほしい」「その技術導入による事業インパクトを説明してほしい」 皆さんも経営者や事業責任者(あるいは中間のマネージャ)との議論の中で上記のようなことを言われ、やきもきした経験が少なからずあると思います。 ソフトウェア開発者と経営者、事業責任者とのコミュニケーション摩擦の原因の一つは、それぞれがプレイしているゲームのルールが異なることだと考えています。 営利企業で働く以上、我々は市場や法制度のルールに従う必要があり、会計はその中でも特に重要なものの一つです。 経営者や投資家、金融機関は会計を共通言語として会話し、その基盤の上に経営戦略や事業戦略のレイヤーが存在しています。 経営者や事業責任者も漏れなくこのルールや力学に従っているので、彼らがどういうルールの上でゲームをプレイしているのかを理解することで、より建設的で有意義な議論ができるようになると考えています。 本記事のゴール 今回は特に損益計算書(Profit and Loss statement: PL)にフォーカスし、以下を目指したいと思います。 PLレベルでの事業構造の解像度を上げる ソフトウェア開発の施策の事業的価値をPL上の言葉で理解する 財務会計と管理会計の話は長くなりそうなので、今回は触れずに進みます。 財務三表と損益計算書(PL) 財務三表とは「貸借対照表(Balance Sheet: BS)」、「損益計算書(Profit and Loss statement: PL)」、「キャッシュフロー計算書(Cash Flow statement: CF)」の3つで、それぞれ会社の資産状況、収益状況、お金の流れを表す資料です。 特に上場企業であれば有価証券報告書として上記を含んだ財務諸表を提出する必要がありますし、会社法ではすべての会社が事業年度ごとの貸借対照表や損益計算書を作成することを義務付けられています。 経営者や投資家はこれらの資料を使って会社の資産や事業、財務状況の健全性を把握し、今後の方針を判断します。 今回は特に「事業の健全性を把握するためのツールとしてのPL」と「ソフトウェア開発」の関係について説明してみたいと思います。 PLの基本構造 下記の図は、一般的なPLの内訳をわかりやすく表現したものです。 PLの内訳 具体的にイメージしやすくするために、簡単なPLのサンプルを用意しました。 サンプルPL 一番上に売上高があり、そこから売上原価や一般管理費等を引いたり営業外収益を足したりしていって、最終的に残ったものが純利益となります。 PL上の売上高や原価率、営業利益率等を見ることによって、事業の収益性の健全さを知ることができます。 (余談ですが、売上目標文脈でよく出てくる「トップライン」という言葉は、PLの一番上の行が売上高であることに由来するそうです) ここからはPL上の各項目について、事業上の意味やソフトウェア開発における関係を掘り下げていきたいと思います。 売上高 (Revenue) 売上高はその名の通り、その会社や事業が製品やサービスの販売によって得た売上の合計です。 売上高が大きいということは、単純に良い製品やサービスを提供していることや、優れたブランド認知、マーケティングチャネル、セールス部門等、売上に影響するなんらかの強い要素を持っていることの間接的な証明になります。 一般的に、売上高はその事業が成長し続けているかどうかを測るための最重要指標として利用されます。 売上原価 (Cost of goods sold: COGS) 売上原価は、製品やサービスを提供するために必要なコストです。 例えばパン屋さんであれば以下のようなものが含まれます。 パンを作る材料費 (直接材料費) パンを作る職人さんの賃金 (直接労務費) パンを焼く設備費 (直接経費) パンを作る上で必要な、オイルやクッキングシート等の消耗品 (間接材料費) パンを販売する人の賃金 (間接労務費) パンを焼く設備の修繕費 (間接経費) (厳密には売上原価には当期に販売された製品の製造原価が含まれます) 何を原価として計上するかは会社のポリシーにもよりますが、ソフトウェア開発では以下のようなものが売上原価として考えられます。 AWS/Google Cloud/Cloudflare等のインフラ費用 サービス提供に必要なソフトウェアやライセンス費用 (地図APIや決済API等) 運用・監視・保守に必要なソフトウェアやライセンス費用 オンコール対応や待機における人件費の一部 サポートやカスタマーサクセスの人件費の一部 サービス維持に必要なセキュリティパッチや不具合修正対応の人件費 売上総利益 (粗利益、Gross profit) 売上高から売上原価を引いたものが売上総利益(一般的に粗利と呼ばれる)ものになります。 粗利には事業維持のための一般管理費や営業活動費が含まれておらず、サービスそのものの純粋な収益性を判断する重要指標として利用されます。 例えば全体の利益が赤字になっていても、粗利が黒字かつ売上高が成長していればサービス自体の収益力としては健全と判断され、投資家や金融機関からの融資を受けやすくなります。 特にスタートアップでは、Jカーブと呼ばれるように短期的には赤字を出しながらも最終的には急角度で成長していく売上モデルを目指すことが一般的です。 www.freee.co.jp 販売費および一般管理費 (販管費、Selling, General and Administrative expenses: SG&A) 販売費および一般管理費(一般的に販管費と呼ばれる)は、事業活動の維持に必要な費用や製品の営業・販促に必要な費用をまとめたものです。 日本で一般的に使われるJ-GAAPやアメリカのUS-GAAPなどの会計基準では、これらの費用は販売費および一般管理費として一つのカテゴリにまとめられることが多いですが、国際財務報告基準(International Financial Reporting Standards: IFRS)ではこれらを性質別(人件費、減価償却費、原材料費等)または機能別(販売費、一般管理費等)で分類することが義務付けられるようになりました。 昨今ではIFRS対応のため、販売費および一般管理費を更に販売・マーケティング費(Sales & Markething: S&M)や一般管理費 General and Administrative: G&A)、研究開発費(Research & Development: R&D)のように分類する企業も増えています。 販売・マーケティング費 (Sales & Marketing expenses: S&M) COGSが現在のサービスを提供・維持する目的なのに対して、S&MやR&Dコストは未来の売上高成長や利益率改善に対する投資です。 そのため、経営層や投資家に合理的な説明ができる限り(かつ財務状況が許す限り)赤字であっても投資するという判断が有りえます。 例えばSales&Marketingコストには営業の人件費や出張費、会食費、広告宣伝費等が含まれますが、これらは未来の売上獲得のための投資と考えることができます。 ソフトウェア開発者が直接的に営業やマーケティングの活動に関わることはあまり多くないかもしれませんが、MA(Marketing Automation)ツールの導入による人件費の抑制やEFO(Entry Form Optimization)によるコンバージョンレートの改善等、間接的にこれらのコスト効率に関わる機会は多くありえます。 研究開発費 (Research & Development expenses: R&D) R&Dには短期の売上向上や利益率向上のための投資や、中長期の基礎研究開発的な投資が含まれます。 例えば、向こう半年から1年程度の機能提供ロードマップに含まれる開発は短期のR&D、顧客への提供価値があるかわからないものや技術的な実現方法自体がわからないものに対する開発は中長期のR&Dと考えることができます。 COGSやS&Mに含まれない開発はほぼR&Dコストなので具体例は枚挙にいとまがありませんが、例えば以下のようなものが挙げられます。 新機能の開発 UIの大規模なアップデート メンテナンス性向上のためのリファクタリング テスト自動化 各種機械学習のPoC IaC導入 S&MやR&D費用の基本的な考え方は、売上総利益(粗利)から一般管理費(G&A)を引いた残りの利益を事業に再投資し、未来の売上や利益を伸ばすことです。 中長期のR&Dを行う前提としては、ある程度の財務健全性が担保されていることや、事業計画とアラインしていることが求められます。 一般管理費 (General and Administrative expenses: G&A) COGSにもS&MにもR&Dにも含まれない、会社運営に必要な業務はG&Aに分類されます。 例えば人事評価、全社会議への出席、各種トレーニングの受講、月次の経費精算等にかかる人件費や有給休暇、看護・介護休暇等はG&Aに含まれるのが一般的です。 G&Aコストは多すぎないことが望ましいですが、例えばチームビルディングやリーダー・マネージャーのトレーニング等が今後のチームの生産性に大きく影響すると判断した場合は、短期的にG&Aコストを投資するという戦略が考えられます。 営業利益 (Operating profit) 売上総利益からSG&A(販管費)を引いたものが営業利益です。 ここから営業外損益や特別損益、法人税を抜いたものが当期純利益となり、その会計期の会社全体の利益になります。 営業外損益には為替差損/差益や有価証券売却益、利息の支払いなどが含まれますが、一旦は営業利益まで意識しておいてもらえればOKです。 Jカーブで一定の成長期に入ったスタートアップや一般的な企業は、売上高や営業利益率を如何に高めるかが基本的なゲームになります。 ソフトウェア開発とPL PLの構造について理解を深めたところで、改めてソフトウェア開発者が意識するべきポイントを考えてみたいと思います。 事業インパクト よく聞く言葉ではありますが、事業インパクトとは何でしょう。定性的なもの、定量的なもの様々が考えられますが、わかりやすい表現の一つとしてPLの項目が利用できます。 例えばプロダクトや機能の開発によってどのくらいの売上が増加したかは、収益性分析によってある程度定量化することが可能です。 CSやOpsの作業効率の改善はCOGSや粗利率として表現することができます。 もちろんPL以外にもKPIを使った定量表現や、セキュリティの強度や採用優位性等の定性的な表現もありえますが、経営者・事業責任者と会話するうえで一番シンプルで説得力が高いのは会計の用語を使うことだと僕は考えています。 *1 もしも採用強化や離職率の低減といったインパクトを盛り込みたい場合は、採用にかかる成功報酬や人件費、オンボーディングコスト、関係構築コストといったものをG&Aコスト観点で織り込むといった方法も一つのアイデアだと思います。 また、事業インパクトとしては、これらのPL上の項目に対してどの程度の割合で影響があるかも重要なポイントです。 例えば「インフラ構成の改善によって500万円/月の改善が見込める」は効果の大きな改善だと思いますが、これがCOGSの1%なのか10%なのかでもインパクトは変わりますし、事業として人件費COGSが課題なのか、システムCOGSが課題なのかによっても大きく優先度判断が変わり得ます。 経営者や事業責任者と事業課題の認識を揃えることで、より大きな事業インパクトのある施策を生み出しやすくなるのではないかと思います。 (それはそれとしてコスト改善はやれるならやった方が良いですが) 投資対効果 (Return on Investment: ROI) 他にも、施策のインパクトを語る上で重要なキーワードとしてROIがあります。 ROIはその名の通り、「投資」に対してどれだけの「リターン(利益)」があったかを表す用語です。最終的な結果指標は利益ですが、利益が出るロジックとしては「COGSが減る」「R&Dコストが減る」等が考えられます。 例えば最近であればChatGPTやCopilotによって設計やコーディング、テストにかかる工数が削減され、同じものを作った場合でもAIツールを利用した方が開発工数が短くなることが期待できます。 これはつまり、ChatGPTやCopilotへの投資によってR&Dコストが低く抑えられるようになったと表現できます。 *2 具体的にどのくらいの効果が得られるかはPoCやベンチマーク、リサーチ会社のレポート活用等で工夫する必要がありますが、このように「いくらの投資で」「どういった見返りが得られるのか」をPLベースで説明できると、より説得力のある提案ができるようになるのではないかと思います。 インフラコスト 突然ですが、Development環境やStaging環境のインフラコストはCOGSとR&Dどちらに計上するのが正しいでしょうか? また、A事業部とB事業部と横断プラットフォーム部がある場合のインフラコストはそれぞれどの事業部のコストとして計上するのが正しいでしょうか? これらに決まった答えはなく、ビジネスモデルや会社の方針によって変わります。 恐らくインフラ寄りの方であれば、「今月の予算実績を集計するために、各事業(または各サービス)ごとのインフラコストを出してほしい」というお願いをされたことがあると思います。 会社や事業のフェイズによっては、それぞれの事業単位で収益の健全性を見たいという要求があります。そのため、インフラ構成の要件として環境やサービス単位でコスト集計ができることが重要です。 同一k8sクラスタで動いているサービスや、プラットフォームサービスのコスト等、どうしてもルールベースで按分するしかないものもありますが、GCPプロジェクトやAWSアカウントの分離、タグやラベルを活用することで、サービスや環境に基づいた柔軟なコスト集計を実現することができます。 会計要求に合わせてインフラ構成を後から変更するのは基本的に現実的ではないので、インフラの初期構築時にはぜひこういった会計処理のことを意識していただけると、会計エンジニアが泣いて喜びます。 単純にROIが説明しづらい施策はどうすればいいの? 施策の効果説明としてPLを用いたアプローチをいくつかご紹介しましたが、依然としてソフトウェア開発にはPLだけでは効果が表現しづらい技術的チャレンジが存在します。 例えば「新しいプログラミング言語の導入」や「レガシーシステムのリアーキテクチャ」といったものが最たる例ではないでしょうか。 特に大きな改修を必要とするものは開発期間も長く、投資と回収が複数の会計期をまたがることになる上、成功の不確実性も高いです。 このような施策をうまく進めるためには、ROIの説明ももちろんですが、コミットメントや信頼といったものが大切です。本当にその施策が事業価値に繋がると信じるのであれば、丁寧にステークホルダーと会話を続けながら課題の言語化や実現方法の模索を進めることが、結果として近道になるのではないかと思います。 まとめ 少し長くなりましたが、PL上の項目の意味合いとソフトウェア開発の関係について、少しでも理解を深めていただけたら幸いです。 実際の現場で、KPIベースで話すべきか、PLベースで話すべきか、ミッションやバリューベースで話すべきかは組織のフェイズ、財務状況、ステークホルダーの好み、会社の文化によってそれぞれ異なりますが、PLをベースに会話できることはソフトウェア開発者としても一つの重要な武器になるのではないかと思います。 もし上長やステークホルダーとの議論が噛み合わないと思ったときは、まず事業上の課題の認識のすり合わせや問題提起から始めてみることをおすすめします。 We are Hiring CADDiは製造業全体を前進させ、世の中を変えていくためにもグローバルな企業を目指しています。 そのため、グローバルで共に戦える仲間を随時募集しています。 ご興味がある方はぜひ下記リンクからご応募ください。 caddi.com *1 : もちろん経営者や事業責任者の性格や好みにも依存します *2 : 開発工数だけではなく、テストコードの拡充による品質の向上、インシデント数の低下によるCOGSの低下、顧客満足度の向上等様々な効果が期待できますが、複合的な提案にするかシンプルなコスト訴求にするかは組織のフェイズやステークホルダーの専門性によって都度考える必要があります
はじめに Kaggle の 5-day 生成AI 集中コースとは 感想 Day1: Foundational Models & Prompt Engineering Day2: Embeddings and Vector Stores/Databases RAG の構築 類似度・分類モデルの作成 Day3: Generative AI Agents Gemini で関数呼び出しをする方法 LangGraph を用いた AI エージェントの作り方 Day4: Domain-Specific LLMs Day5: MLOps for Generative AI まとめ この記事は CADDi プロダクトチーム Advent Calendar 2024 7 日目の記事です。 adventar.org はじめに こんにちは、CADDi の Analysisチームで図面解析をしている Osujo ( @kaeru_nantoka ) です。 みなさん LLM 使っていますか? 私は使っています。 CADDiでは、画像を含むリクエストに対応できるようになったことで、既存の機械学習モデルに加え、LLMを用いた解析タスクの検証を本格的に開始しました。 9月までは既存の機械学習モデルの安定運用のための基盤作成に携わっていました。 LLM については社内の勉強会や Kaggle コンペの Solution などでかろうじて追いかけていたくらいでした。 10月からはうってかわってガッツリ LLM の実用性検証を業務で行うことになりました。 ドキュメントを読んだり、実際に手を動かして検証を進める中で、 Kaggle's 5-Day Gen AI Intensive Course がよかったと X で話題になっていたのを見つけました。 今回は、この Kaggle's 5-Day Gen AI Intensive Course の内容をキャッチアップしたので簡単に紹介します。 Kaggle の 5-day 生成AI 集中コースとは 2024年11月11日~15日に Kaggle 上で開催された、生成AI についての課題と講義からなるプログラムです。Kaggle と Google が共同で作成しました。 www.youtube.com 5日間、その日のお題に関するホワイトペーパーとハンズオン資料が共有されました。本来は事前登録者のみが参加できるプログラムでしたが、参加者有志が Kaggle フォーラムに講義資料とハンズオン資料を共有してくれたおかげで、後からでも学習することができました。 「コースに参加しました!」ではなく「キャッチアップしました」としたのは、私がこの有志の方々が共有してくれた資料を使って学習したためです。 ハンズオン資料は Kaggle Notebook で作成されており、インタラクティブに実行可能です。Google Cloud で API Key を発行し、Kaggle Notebook の Secret Manager に登録すれば、すべて実行できました。 感想 各プログラムについて、簡単に感想を書いていきます。 Day1: Foundational Models & Prompt Engineering LLM の概要とプロンプトエンジニアリングについてでした。LLM の概要は、書籍や論文で既知の内容でした。プロンプトエンジニアリングの部分は、Gemini を使いながら、以下の内容を一通り体験できました。 学んだこと ハイパーパラメータの扱い プロンプトエンジニアリングの基礎 出力形式の制限方法(Json モード、Enum モード) zero-shot、few-shot Chain of Thought (CoT)、Reasoning and Acting (ReAct) コード生成と実行 (code_execution) これらはドキュメントを読んで試してはいましたが、Kaggle Notebook でまとめて復習できたのは非常に有益でした。 Day2: Embeddings and Vector Stores/Databases RAG の構築と埋め込みと類似度、分類モデルの作成についてでした。 RAG の構築 概念として知っていましたが、自分自身で実装したことがなかった部分でした。 この章では、 ChromaDB を用いてデータベースを作成し、実際に RAG を構築しました。 類似度・分類モデルの作成 これは別の章に分けられていましたが、Gemini で埋め込みベクトルを作成した後は、Kaggle で経験済みの内容でした。 学んだこと 埋め込みベクトル用のモデル ( models/embedding-001 , models/text-embedding-004 ) 埋め込みベクトルのタスクタイプ ( retrieval_document , retrieval_query , semantic_similarity ) ChromaDB:ベクトル保存と検索のための OSS ベクトルデータベース。初めて知りました。 RAG の自作実装 Day3: Generative AI Agents Gemini で関数呼び出しをする方法と LangGraphを用いた AI エージェントの作り方についてでした。 Gemini で関数呼び出しをする方法 この章では、ユーザーの自然言語による質問に対して、データベースに問い合わせを行い、その結果を用いて回答するチャットボットを作成しました。「DBにある商品のうち、最安値の商品は何ですか?いくらですか?」といった会話が可能です。 チャットボットのアーキテクチャ (Day3 の Kaggle Notebook より引用) LangGraph を用いた AI エージェントの作り方 この章では、LangGraph を用いた AI エージェントを作成しました。 学んだこと 関数をツールとして渡すと、LLM はそれを回答生成に使用できる。SQL などの外部ツールへの問い合わせも可能。 データベースの理解はスキーマ、操作は関数として実装される。 LangGraph アプリの基本的な概念である State アプリ内のすべてのノードと遷移の間で渡される。 Python 辞書(ここでは TypedDict)として定義できる。 State はノードに渡されると新しい State になって返ってくる。 @tools アノテーションで Python 関数をツールとしてアノテーションできる。 Day4: Domain-Specific LLMs Gemini API の finetuning の方法についてでした。 学んだこと finetuning 用のモデル ( models/gemini-1.0-pro-001 , models/gemini-1.5-flash-001-tuning ) genai.create_tuned_model() で簡単に finetuning できる。 DataFrame と input_key 、 output_key を渡す。 finetuning はチューニングジョブとしてキューに渡され、非同期で実行される。 ジョブの実行状況を State として取得でき、ACTIVE になると finetuning 済みモデルが使用できる。 モデルの finetuning は無料だが、finetuning 済みモデルの使用にはコストがかかる。 Day5: MLOps for Generative AI MLOps for LLM についてでした。 Kaggle Notebook によるハンズオンはありませんでしたが、Google Cloud 上で LLM を用いたアプリケーションを作る場合のインフラ込みのアーキテクチャの実装例が GitHub で共有 されていました。 ロギング、モニタリング、CI/CD、IaC まで含まれており、LLM に限らず一般的な ML アプリケーションのアーキテクチャとして参考になるものでした。 アーキテクチャ実装例 (Day5 で紹介されていた GitHub リンクより引用) まとめ 知識としては知っていたプロンプトエンジニアリングの各手法や RAG、LangGraph などを自分の手で実際に動かすとても良い機会になりました。また、Day5 で紹介されていたインフラや監視といった部分は for LLM でなくとも参考にできそうでした。 ちなみに、本エントリは Gemini-1.5-flash に推敲してもらいました。 世の中、大LLM時代になってきました。CADDi でも LLM の活用に向けた様々な検証を日々行なっています。一緒に MLモデルや開発基盤の作成をしたり LLM活用の検証に取り組む仲間も大募集中です。 少しでも興味をお持ちいただけましたら、以下の採用ページよりお問い合わせください。 カジュアル面談もお待ちしております! https://recruit.caddi.tech/