every Tech Blog

株式会社エブリーのTech Blogです。

Nuxt Bridge を使用して Nuxt 2 のアプリケーションへサーバーエンジン Nitro を導入した話

Nuxt Bridge を使用して Nuxt 2 のアプリケーションへサーバーエンジン Nitro を導入した話

はじめに

株式会社エブリーでソフトウェアエンジニアをしている桝村です。

本記事では、Nuxt 3 へのアップデートに向けて、Nuxt Bridge を使用して Nuxt 2 のアプリケーションへサーバーエンジン Nitro を導入したので、実施内容やそれによって得られた知見について紹介します。

この記事のゴールは、以下を想定しています。

  • Nitro の概要や、Nuxt 2 への Nitro 導入のメリットを把握する
  • Nuxt 2 への Nitro 導入における変更点や考慮すべきポイントを把握する

Nuxt 3 へのアップデートに関連して、Vuex の Pinia への移行については、以下の記事で詳しく紹介しています。

tech.every.tv

サーバーエンジン Nitro とは

サーバーエンジン Nitro とは、様々な環境で軽量な Web サーバーを構築できるライブラリのことです。

Vue や Nuxt 開発メンバーが中心のプロジェクト unjs が開発・メンテナンスしており、Nuxt 3 へデフォルトで組み込まれています。

UnJS は Unified JavaScript Tools の略で、JavaScript の開発をより効率的かつ柔軟に行うために設計された、一連のオープンソースライブラリおよびツールを提供しているプロジェクトです。

同様のライブラリとして、Nuxt 2 との互換性がある Express.js や Koa.js, Fastify などがあります。

詳しくは、以下のリンクをご参照ください。

nitro.unjs.io

Nuxt での Nitro の採用

ここでは、Nuxt での Nitro の採用について、概要やメリットを整理します。

Nuxt での Server の構成や役割

Nitro が採用された Nuxt の Server の構成は以下のようになっています。

Nuxt Server Structure
Nuxt の Server 構成

nuxt.com

  • Server Engine: アプリケーションのサーバーを動作させるための基盤となる技術
  • Nuxt: Vue.js や SSR、状態管理 などの機能を提供する高レベルのフレームワーク
  • Nitro: 軽量でポータブルな出力を生成するライブラリ
  • h3: 軽量で高速な HTTP サーバーのライブラリ。Nitro の基盤技術

また、Nuxt の Server 側では以下をはじめとした責務を担っています。

  • サーバーのビルド・起動設定
  • API のルーティング初期化
  • HTTP リクエストの処理
  • 初期 HTML のレンダリング
  • 静的なサーバーサイドコンテンツの生成 (ex. サイトマップ)
  • etc...

上記を踏まえると、Nuxt での Nitro の採用は、大きな変更であることが想定できます。

Nuxt への Nitro 導入のメリット

Nuxt への Nitro 導入には以下のようなメリットがあります。

  • 高速なサーバーレスポンス
  • ハイブリッドレンダリングのサポート
  • ホットリロードが高速

詳しくは、以下のリンクをご参照ください。

nuxt.com

実際の Nitro 導入による効果については、後述の結果と振り返りで紹介しております。

Nuxt 2 への Nitro の導入における変更点

前提

今回は、以下の技術スタックを持つ本番運用中のアプリケーションへの導入を想定しております。

  • Node.js v20.14.0
  • nuxt v2.17.2
  • @nuxt/bridge v3.0.1
  • express v4.17.1

本アプリケーションは、Nuxt Bridge を利用して Nuxt 2 の状態で Nuxt 3 への移行を進めており、今回は移行の一つであるサーバーエンジン Nitro の導入を行いました。

Nuxt Bridge とは、Nuxt 3 と上方互換性があり、Nuxt 3 の機能の一部を Nuxt 2 で利用できるようにするためのライブラリです。

nuxt.com

以降の内容は、公式の移行ガイドを参考にしながらも、実際に対応を進める中でハマったポイントや気づきを中心に紹介していきます。

開発サーバーの起動

Nuxt が Nitro を利用して開発サーバーを起動するには、CLI コマンド nuxi のインストール・利用が必要です。

前提として、nuxi のインストール・利用には、Node.js のバージョン 18.0.0 以上が必要そうでした。

WARN Current version of Node. js (16.18.0) is unsupported and might cause issues.
Please upgrade to a compatible version >= 18.0.0.

その上で、基本的には、以下のリンクを参考に進めていくことができます。

nuxt.com

また、開発サーバーの起動設定について、コマンド nuxt では server オプションを利用していましたが、コマンド nuxi では devServer オプションの利用が必要なのもポイントでした。

export default defineNuxtConfig({
- server: {
+ devServer: {
    port: 3002,
  }
})

nuxt.com

加えて、コマンド nuxt ではファイルの内容をバッファとして読み込んで渡す仕様でしたが、コマンド nuxi ではファイルのパスを文字列として直接渡す仕様に変更になっていました。より設定が簡潔になったと言えるでしょう。

export default defineNuxtConfig({
  devServer: {
    port: 3002,
    https: {
-     key: fs.readFileSync(path.resolve(__dirname, 'server.key')),
-     cert: fs.readFileSync(path.resolve(__dirname, 'server.crt'))
+     key: './server.key',
+     cert: './server.crt'
    }
  }
})

デプロイメント

nuxi では build コマンドを実行することで、.output ディレクトリにアプリケーションのビルド成果物を出力します。この成果物がサーバーを起動するためのエントリーポイントとなります。

また、デフォルトではポート 3000 でサーバーが起動するのと、上述の devServer オプションを参照しないので カスタマイズでポートを変更したい場合は、以下のように環境変数を利用してポートを指定する必要がありました。

PORT=3002 node .output/server/index.mjs

nuxt.com

エンドポイント・ミドルウェアの設定

前提として、Nuxt 2 では express を利用してエンドポイントやミドルウェアを設定していたので、Nitro の導入にあたっては Nitro (h3) の API を利用するように書き換えることが基本的な方針でした。

Express.js から Nitro (h3) へ書き換えする場合

Nitro では h3 の defineEventHandler によりアプリケーションロジックを定義します。

コンテキストにあたる event インスタンスを受け取って、ロジックを実行する関数を定義することができます。

Express.js
// server/api/test.ts
export default (req, res, next) => {
  // ... Do whatever you want here
  next();
}
Nitro (h3)
// server/api/test.ts
import { defineEventHandler } from "h3";

export default defineEventHandler(async (event) => {
  // ... Do whatever you want here
});

nuxt.com

以下は、具体的な書き換え例になります。

Express.js
import urlParse from "url-parse";

export default (req, res, next) => {
  const host = req.headers.host;
  const parsedUrl = new URL(`https://${host}${req.originalUrl}`);
  const pathname = parsedUrl.pathname;

  if (pathname.match(/.+\/$/)) {
    parsedUrl.pathname = pathname.replace(/\/$/, "");
    res.writeHead(301, { Location: urlParse(parsedUrl).href });
    res.end();
  } else {
    next();
  }
};
Nitro (h3)
import { defineEventHandler, sendRedirect, getRequestURL, getRequestHost } from "h3";

export default defineEventHandler((event) => {
  const host = getRequestHost(event);
  const parsedUrl = new URL(getRequestURL(event), `https://${host}`);
  const pathname = parsedUrl.pathname;

  if (pathname.match(/.+\/$/)) {
    parsedUrl.pathname = pathname.replace(/\/$/, "");
    return sendRedirect(event, `https://${host}${parsedUrl.pathname}`, 301);
  }
});

express では、リクエストオブジェクトから直接情報を取得しているのに対して、h3 では getRequestHostgetRequestURL のような関数を使用してリクエストから情報を取得しています。

それにより、関数の抽象化を通じてコードの可読性と保守性を向上させることに重きを置いていたり、Nuxt 3 で設計を刷新しようとしていることが垣間見えます。

Express.js のコードをそのまま利用する場合

express のコードでも、fromNodeMiddleware() で変換することで Nitro (h3) でそのまま利用することができました。

Express.js
// server/api/index.js
import express from "express";
const app = express();

app.get("/api/test", (req, res) => {
  res.send("Hello World!");
});

export default app;
Nitro (h3)
// server-middleware/api/index.js
import express from "express";
import { fromNodeMiddleware } from "h3";

app.get("/api/test", (req, res) => {
  res.send("Hello World!");
});

export default fromNodeMiddleware(app);

これにより、徐々に express から h3 ベースへコードの書き換えを進めることが可能です。

ただし、Nuxt として推奨されている機能ではないことは留意しておくと良さそうです。

nuxt.com

結果と振り返り

結果

Nuxt 3 への Nitro 導入した結果、体感の部分もありますが、今回のアプリケーションでは以下のような効果が得られました。

#・ホットリロード: 約 20% 高速化
#・開発サーバーの起動時間:約 30% 高速化
#・サーバーのレスポンスタイム: 約 5% 高速化

サーバーのレスポンスタイムについて、今回は開発スピードを優先して主に express のコードをそのまま利用する方針で進めたので、モジュールの変換に伴うオーバーヘッドが影響している可能性があります。

なので、Nitro (h3) へ完全移行できるとさらに改善が見込めるかもしれません。

振り返り

サーバーエンジン Nitro の導入に際して、Nuxt のサーバーの基盤技術の刷新でもあり、変更点が多くありました。

一方で、開発サーバーの起動時間やホットリロードの高速化など、開発効率の向上が期待できることもわかりました。

今後は、Nitro (h3) への完全移行を進めることで、更なるパフォーマンス向上や開発効率の向上を図っていきたいと考えています。

おわりに

今回は、Nuxt 3 へのアップデートに向けて、Nuxt Bridge を使用して Nuxt 2 のアプリケーションへサーバーエンジン Nitro を導入したので、実施内容やそれによって得られた知見について紹介しました。

これから Nuxt Bridge を使用して Nuxt 2 のアプリケーションへ Nitro の導入を検討している方にとって、参考になれば幸いです。

Databricks GenieではじめるText-to-SQL

はじめに

こんにちは。
株式会社エブリーの開発本部データ&AIチーム(DAI)でデータエンジニアをしている吉田です。

今回は、Text-to-SQLを実現するDatabricks Genieを紹介します。

Databricks Genie

Databricks Genieは、自然言語を利用してデータ分析が行えるサービスです。
あらかじめデータ、サンプルクエリ、Genieへの指示を登録しておくことで、Genieに対して自然言語でクエリを投げることができます。
これにより、SQLに詳しくない人でもデータ分析を行うことができます。

AI/BI Genie Space とは何ですか?

Genieを利用する

Genieを利用するためには、以下の手順が必要です。

  1. 利用するデータをUnity Catalogに登録する
  2. Genie Spaceを作成する
  3. Genieをチューニングする

今回は弊社が提供しているレシピ動画サービス、DELISH KITCHENのデータを模したサンプルデータを用意し、Genieを利用してみます。
サンプルデータはランダムに生成したもので、実際のデータとは異なります。

サンプルデータをUnity Catalogに登録する

サンプルデータは、以下のような構造です。

テーブル名 カラム 説明
user_master id
age
gender
recipe_master id
recipe_name
is_premium プレミアムレシピかどうか
viewed_video event_date
user_id
recipe_id
seconds 動画視聴時間
referrer_screen 直前に見た画面

Unity Catalogへの登録

Genieではカタログのメタデータを利用してクエリを生成するため、テーブル/カラムのコメントを登録しておく必要があります。
登録にはAI Generate機能を利用することでデータの内容から適切なコメントを生成できるため、利用すると便利です。

コメントの自動生成

Genie Spaceを作成する

Genie Spaceは、Genieの利用者がデータ分析を行うためのスペースです。
使用するテーブルやサンプルクエリ、使用するコンピュートリソースなどを指定して作成します。

Genie Spaceの初期設定

Genieをチューニングする

Genieに対して、クエリの生成精度を向上させるためのチューニングを行えます。
ドメイン知識を追加したり、回答形式を指定する、あらかじめ質問とSQLをセットで登録し学習させるなど、Genieの精度を向上させる方法があります。

チューニング

Genieでデータ分析をする

実際にGenieに対して、日本語で質問を投げてみます。
クエリの実行後、Show Generated Codeをクリックすると、Genieが生成したクエリを確認できます。

最初はシンプルな質問を投げてみます。

回答

よさそうです。
次はテーブルのJoinが発生する質問を投げてみます。

回答

よさそうです。
さらに複数のJoinが発生する質問を投げてみます。

回答

こちらも良さそうです。
では簡単な変換を伴う質問を投げてみます。

回答

うまくいきませんでした。
このように質問が正確に理解されない、または誤ったクエリを生成することがあります。
そういった場合はチューニングを行うことで精度を向上できます。
例えば以下のようなクエリを質問とセットで登録することで、Genieに正しいクエリを生成するよう学習させられます。

クエリと質問の登録によるチューニング

この状態で同様の質問をしてみます。

回答

今度は正常にクエリが生成されました。
このようにチューニングを行うことで、Genieの精度が向上します。

まとめ

今回はDatabricks Genieを利用して、自然言語でデータ分析を行う方法を紹介しました。
Databricks Genieを利用することで、SQLに詳しくない人でも質問を入力するだけでデータ分析を行うことができます。
これにより、データ分析の敷居が下がりデータ活用が進むことが期待されます。

Amazon BedrockのAdvanced parsing optionsの挙動を確認する

はじめに

こんにちは。DELISH KITCHEN開発部の村上です。

直近は社内でAmazon Bedrockを使った RAG基盤の構築をしています。その中でちょうど先月AWSから発表された advanced RAG機能 の中のAdvanced parsing optionsを検証も兼ねて使用する機会があったので紹介します。

Advanced parsing optionsとは

Knowledge baseではS3や他のデータコネクターを指定し、データソースを作成、同期することによってOpensearch ServerlessといったベクトルDBにデータを格納しています。データソースはさまざまなファイル形式をサポートしていますが、今まではサポートしている形式であってもその解析精度に課題が残るものもありました。

今回のアップデートで追加されたAdvanced parsing optionsは有効化することによって、今まで課題であったPDFファイルのテーブルや表、グラフなど非テキスト情報も基盤モデルを使って解析してベクトルDBに埋め込むことができるようになります。

設定可能な値は二つのみでほぼ有効化のみですぐに試すことができます。

  • 使用する基盤モデル
    • 『Claude 3 Sonnet v1』 or 『Claude 3 Haiku v1 』
  • parserの指示プロンプト
    • 英語のデフォルトプロンプトが設定済み

長いので折り畳みますが、デフォルトプロンプトはこのように記述されています。

プロンプト内容

Transcribe the text content from an image page and output in Markdown syntax (not code blocks). Follow these steps:

1. Examine the provided page carefully.

2. Identify all elements present in the page, including headers, body text, footnotes, tables, visualizations, captions, and page numbers, etc.

3. Use markdown syntax to format your output:
    - Headings: # for main, ## for sections, ### for subsections, etc.
    - Lists: * or - for bulleted, 1. 2. 3. for numbered
    - Do not repeat yourself

4. If the element is a visualization
    - Provide a detailed description in natural language
    - Do not transcribe text in the visualization after providing the description

5. If the element is a table
    - Create a markdown table, ensuring every row has the same number of columns
    - Maintain cell alignment as closely as possible
    - Do not split a table into multiple tables
    - If a merged cell spans multiple rows or columns, place the text in the top-left cell and output ' ' for other
    - Use | for column separators, |-|-| for header row separators
    - If a cell has multiple items, list them in separate rows
    - If the table contains sub-headers, separate the sub-headers from the headers in another row

6. If the element is a paragraph
    - Transcribe each text element precisely as it appears

7. If the element is a header, footer, footnote, page number
    - Transcribe each text element precisely as it appears

Output Example:

A bar chart showing annual sales figures, with the y-axis labeled "Sales ($Million)" and the x-axis labeled "Year". The chart has bars for 2018 ($12M), 2019 ($18M), 2020 ($8M), and 2021 ($22M).
Figure 3: This chart shows annual sales in millions. The year 2020 was significantly down due to the COVID-19 pandemic.

# Annual Report

## Financial Highlights

* Revenue: $40M
* Profit: $12M
* EPS: $1.25


| | Year Ended December 31, | |
| | 2021 | 2022 |
|-|-|-|
| Cash provided by (used in): | | |
| Operating activities | $ 46,327 | $ 46,752 |
| Investing activities | (58,154) | (37,601) |
| Financing activities | 6,291 | 9,718 |

Here is the image.

これまでとの挙動の比較

実際にデフォルトの有効化されていない状態と比較しながら、Advanced parsingがどのようにPDFを解析しているのかを確認していきます。 今回はサンプルデータとして情報通信白書令和5年版のPDFをS3に入れて解析を行っています。

以下は使用している設定値です。

  • 基盤モデル: Claude 3 Sonnet v1
  • parserの指示プロンプト: デフォルト

グラフの解析

まず、p9の棒グラフを解析してみます。

PDFの中のグラフは、デフォルトの設定だと以下のように解析されました。デフォルトでも大きく崩れてはいませんが、それぞれの文字同士のつながりがわかりにくく、ひとつのグラフとして解釈するのは難しいかもしれません。

違法・有害情報センターへの相談件数の推移   0   1,000   2,000   3,000   4,000   5,000   6,000   7,000   平成22 平成23 平成24 平成25 平成26 平成27 平成28 平成29 平成30 令和元 令和2 令和4令和3 (年度)   (件)   1,337 1,560 2,386   2,927 3,400   5,200 5,251 5,598 5,085 5,198 5,407   6,329 5,745   (出典)総務省「令和4年度インターネット上の違法・有害情報対応相談業務等請負業務報告書(概要版)」

Advanced parsingだとこのグラフはマークダウン形式で解析され、それぞれの年と数値の関係がわかりやすく表現されています。(※これ以降ではわかりやすく改行コードで改行を入れていますが、実際には一行にまとまっています。)

## 違法・有害情報センターへの相談件数の推移\n
| 年度 | 件数 |\n
|-|-|\n
| 平成22 | 1,337 |\n
| 平成23 | 1,560 |\n
| 平成24 | 2,386 |\n
| 平成25 | 2,927 |\n
| 平成26 | 3,400 |\n
| 平成27 | 5,200 |\n
| 平成28 | 5,251 |\n
| 平成29 | 5,598 |\n
| 平成30 | 5,085 |\n
| 令和元 | 5,198 |\n
| 令和2 | 5,407 |\n
| 令和3 | 6,329 |\n
| 令和4 | 5,745 |\n
(出典) 総務省「令和4年度インターネット上の違法・有害情報対応相談業務等請負業務報告書(概要版)」\n

他にシンプルな円グラフでも同じような検証を行いましたが、同じ結果でAdvanced parsingの方がより構造化されて解釈がしやすくなっていました。では、もう少し複雑なものだとどうでしょうか。

こちらはp7にある似たような折れ線グラフですが、先ほどの棒グラフと違い、細かい各年度の数値が書かれていません。

これをデフォルト設定で読み込むと、先ほどと同じくテキストは読み込みますが、大事な中身のグラフに対する内容が抜け落ちてしまっています。

主要プラットフォーマーの売上高の推移   0   100   200   300   400   500   600 (10億ドル)   Google Amazon Meta Apple   Microsoft Baidu Alibaba Tencent Holdings   2011 2012 2013 2014 2015 2016 2017 2018 2019 2020 2021 2022(年)   (出典)Statistaデータを基に作成

一方のAdvanced parsingだとそれぞれの数値が詳細に出されていないことを判断して、無駄なテキスト情報を省き、グラフが示唆する内容を簡単にまとめて解説しています。惜しいのは内容の抜粋になってしまっているので、RAGとして質問した時にそれ以上の回答はできなそうです。ただ、現状はデフォルトプロンプトを使っているので、カスタマイズすることによって精度向上は期待できるかもしれません。

## 主要プラットフォーマーの売上高の推移\n
この画像は、主要プラットフォーマー企業の過去10年間の売上高の推移を示すグラフです。縦軸は売上高(10億ドル)、横軸は年を表しています。グラフには、Google、Amazon、Meta、Apple、Microsoft、Baidu、Alibaba、Tencent Holdingsの売上高の推移が示されています。全体的に右肩上がりの傾向が見られ、特にGoogleとAmazonの売上高の伸びが顕著です。\n
(出典) Statistaデータを基に作成\n

次にp9にあるよりテキスト情報が多く、それぞれの対応関係を正しく把握しないといけないようなものを見てみます。

デフォルト設定では同じように構造化されていない文字の抽出だけで複雑になった分だけよりわかりにくくなっています。

インターネット上の偽・誤情報への接触頻度   毎日、またはほぼ毎日 最低週1回 月に数回 ほとんどない 頻度はわからない   一度も見たことがない そもそも何がフェイクニュースなのかがわからない   19.1 12.0 16.6 18.6 20.2   2.9   10.7   19.5 19.5 21.7 10.7 22.2 5.0   1.4   令和3年度インターネット上のメディア (SNSやブログなど)   令和3年度まとめサイト

Advanced parsingだと先ほどのようにマークダウン形式で解析され、一見すると綺麗に整ったように感じられます。 しかし、よく見るとそれぞれに対応する数値が違っていたり、欠損が目立っており正しく解析はできていないようで一部はハルシネーションにつながりそうな結果となりました。

## インターネット上の偽・誤情報への接触頻度\n
| | 令和3年度インターネット上のメディア(SNSやブログなど) | 令和3年度まとめサイト |\n
|-|-|-|\n
| 毎日、またはほぼ毎日 | 19.1% | 10.7% |\n
| 最低週1回 | 12.0% | 19.5% |\n
| 月に数回 | 16.6% | 19.5% |\n
| ほとんどない | 18.6% | 21.7% |\n
| 頻度はわからない | 20.2% | 10.7% |\n
| 一度も見たことがない | 2.9% | 22.2% |\n
| そもそも何がフェイクニュースなのかがわからない | | 5.0% |\n
| | | 1.4% |\n
(出典) 総務省「令和3年版 国内外における偽情報に関する意識調査」

画像の解析

p39の埋め込まれた画像を解析してみます。

デフォルト設定では画像は完全な非テキスト情報となり、その部分だけが情報として抜け落ちてしまいました。

図表2-1-4-1 校務・学習データの可視化(Microsoft)   (出典)Microsoft

一方でAdvanced parsingでは、画像内で表現されていることを解析して、説明が加えられています。内容も大きく異なることを言っているわけではなく、かなりいい精度で解析ができています。

## 図表2-1-4-1 校務・学習データの可視化(Microsoft) この図は、Microsoftによる校務・学習データの可視化の概要を示しています。左側には、Microsoft Teamsやアンケート・出欠管理システムなどの学校で利用されるシステムが列挙されています。中央には、これらのシステムから収集されたデータが蓄積されていることが示されています。右側には、蓄積されたデータを可視化し、学校全体、クラス、児童・生徒個人のレベルで分析できることが示されています。

表の解析

p7の表を解析してみます。

デフォルト設定の結果は今までのグラフと同じく、文字の抽出のみです。

プラットフォーマーが取得するデータ項目   データ項目 プラットフォーム   Google Facebook Amazon Apple 名前 〇 〇 〇 〇   ユーザー名 - - 〇 -   IPアドレス 〇 〇 〇 〇   検索ワード 〇 - 〇 〇   コンテンツの内容 - 〇 - -   コンテンツと広告表示の対応関係 〇 〇 - -   アクティビティの時間や頻度、期間 〇 〇 - 〇   購買活動 〇 - 〇 -   コミュニケーションを行った相手 〇 〇 - -   サードパーティーアプリ等でのアクティビティ 〇 - - -   閲覧履歴 購買活動 〇 - 〇 -   コミュニケーションを行った相手 〇 〇 - -   サードパーティーアプリ等でのアクティビティ 〇 - - -   閲覧履歴 〇 - 〇 -   (出典)Security.org「The Data Big Tech Companies Have On You」 より、一部抜粋して作成

Advanced parsingでは、マークダウンでそのまま表形式を表現することでPDFと同じ構造を保てています。

## プラットフォーマーが取得するデータ項目\n
| データ項目 | Google | Facebook | Amazon | Apple |\n
|-|-|-|-|-|\n
| 名前 | ◯ | ◯ | ◯ | ◯ |\n
| ユーザー名 | - | - | ◯ | - |\n
| IPアドレス | ◯ | ◯ | ◯ | ◯ |\n
| 検索ワード | ◯ | - | ◯ | ◯ |\n
| コンテンツの内容 | - | ◯ | - | - |\n
| コンテンツと広告表示の対応関係 | ◯ | ◯ | - | - |\n
| アクティビティの時間や頻度、期間 | ◯ | ◯ | - | ◯ |\n
| 購買活動 | ◯ | - | ◯ | - |\n
| コミュニケーションを行った相手 | ◯ | ◯ | - | - |\n
| サードパーティーアプリ等でのアクティビティ | ◯ | - | - | - |\n
| 閲覧履歴 | ◯ | - | ◯ | - |\n
(出典) Security.org「The Data Big Tech Companies Have On You」より、一部抜粋して作成\n

テキストの解析

基本的にAdvanced parsingはこれまで述べてきた非テキスト情報の解析が大きな特徴になっていますが、テキスト情報でもどのような影響があるのかを最後にこちらの目次を解析した結果で確認します。

デフォルト設定では、チャンキングの設定により途中で切れてしまっていますが、表示されている通りに抽出を行っているため無駄にトークンを消費していそうです。

第1章 データ流通の進展 第1節 データ流通を支える通信インフラの高度化・・・・2   ■1  固定通信・・・・・・・・・・・・・・・・・・・・・・・・・・・・・・・・・・・・・・・・・・・・2 ■2  移動通信・・・・・・・・・・・・・・・・・・・・・・・・・・・・・・・・・・・・・・・・・・・・2 第2節 データ流通とデジタルサービスの進展・・・・・・・・5   ■1  片方向のデータ発信・ (Web1.0時代:1990年代~2000年代前半)・・・・・5   ■2

一方のAdvanced parsingでは書かれている内容を解析して、その意味を失わない範囲でマークダウンに整形しており、無駄がありません。 また、マークダウンで構造も表現できているため、文章の関係もこちらの方がわかりやすいです。

### 第1章 データ流通の進展 #### 第1節 データ流通を支える通信インフラの高度化 * 1  固定通信 * 2  移動通信 #### 第2節 データ流通とデジタルサービスの進展 * 1  片方向のデータ発信 * (Web1.0時代:1990年代~2000年代前半) * 2  双方向のデータ共有 * (Web2.0時代:2000年代後半~)

運用上の注意点

以上のようにAdvanced parsing optionを有効化することによって完璧ではないものの全体の精度の向上が見込めそうなことがわかりました。一方で使っていく中で以下のような注意点があります。

  • 基盤モデルで前処理を行う都合上、デフォルト設定よりモデルの利用コストが増加する
  • ベクトルDBへの同期処理が遅いため、リアルタイムに文書を処理したい場合に適していない
  • デフォルトプロンプトが英語だからなのか、そのままデフォルトで使うと一部の解析結果が英語になってしまう可能性がある
  • データサイズ、読み込みファイル数に制限がある

特にフルにこの機能をRAG基盤で活用するにあたって大きな障壁に感じたのは、読み込みファイル数の制限です。現状では解析できるファイルの最大数は100ファイルで申請による調整もできません。工夫したとしてもナレッジベースあたりのデータソース数は5つなので、分割して全て使ったとしても500ファイル程度で上限に達してしまいます。もう一つの解決策としてファイルサイズの上限まで複数のファイルを繋げて、ファイル数を削減することですが、その手間を考えるとAWS側での今後の引き上げを期待したいところです。

まとめ

今回はここ1ヶ月ほどで出たAdvanced parsing optionsを活用したKnowledge baseの精度向上に関して、その挙動を見ていきながら機能を紹介しました。

リリースではこの他にもチャンキング戦略のアップデートやクエリ分割の機能など多くのアップデートがあり、Amazon Bedrockの改善のスピード感とAWSがかなり力を入れていることを日々感じています。現在同じようにRAG基盤を構築されている方はぜひ追加された機能も試してみてください。

Goroutine間での通信方法あれこれ

概要

TIMELINE開発部の内原です。

本日は、改めてGo言語におけるgoroutine間での通信方法について整理してみました。

Go言語ではgoroutineを用いて簡単に並行処理を記述することができます。またその際、goroutine間で通信を行い、情報のやり取りをしたり互いに協調しつつ動作することもできます。

ただ、通信する手段自体は複数あり、それぞれ特徴がありますので、どのようなことを実現したいのかによって適切な方法を採用する必要があります。

いきなり結論

先に結論を書いておくと、以下のような切り分け方になりそうです。

やりたいこと 実装
goroutine間で値を共有したい sync.Mutex を使う
goroutineから任意のタイミングで通知したい(通知のみでよい場合、かつ一度きり) sync.WaitGroup を使う
goroutineから任意のタイミングで通知したい(通知のみでよい場合、かつ複数回) sync.Cond を使う
goroutineから任意のタイミングで通知したい(なんらか値を返却したい場合) channel を使う
特定のタイミングでgoroutineを終了させたい context.Context を使う

それぞれの実装を記載します。

それぞれの実装方法

goroutine間で値を共有したい

sync.Mutex は排他制御を実現します。 Lock されている間は他の Lock をブロックします。ブロックを解除するには Unlock を呼び出します。なお sync.WaitGroup については後述します。

func increment(wg *sync.WaitGroup, mtx *sync.Mutex, cnt *int) {
    mtx.Lock()
    defer mtx.Unlock()
    *cnt++
    wg.Done()
}

func sampleMutex() {
    wg := sync.WaitGroup{}
    mtx := sync.Mutex{}

    cnt := 0
    for i := 0; i < 10000; i++ {
        wg.Add(1)
        go increment(&wg, &mtx, &cnt)
    }
    fmt.Printf("waiting for goroutine complete...\n")
    wg.Wait()
    fmt.Printf("completed, cnt: %d\n", cnt)
}

実行結果は以下です。

並行実行されていても正しく排他制御が行われ、想定した値に更新されていることが分かります。

waiting for goroutine complete...
completed, cnt: 10000

もしmutexを使わないで実行した場合、以下のように想定した値に更新されないことがあります。

この場合いわゆる競合状態になっていることが分かります。このような状態だと場合によってはプログラムがクラッシュすることもあるため、gouroutine間で共有するリソースにアクセスする場合は排他制御が必要です。

waiting for goroutine complete...
completed, cnt: 9382

なお、 sync.RWMutex というものもあり、こちらは書き込み用ロック Lock と読み込み用ロック RLock とが分かれており、読み込みロック同士ならば並行で実行できるという違いがあります。

goroutineから任意のタイミングで通知したい(通知のみでよい場合、かつ一度きり)

sync.WaitGroupAdd された数と同数の Done が行われるまで Wait で待機します。これにより、呼び出されたgoroutine側の適当なタイミングで通知を行うことができます。

つまりこの機能における通知とは一方向かつ一度きりと言えます。このため、一般的には呼び出したgoroutineが終了したことを呼び出し元に伝える用途で使われることが多いと思います。

func procGoroutine(wg *sync.WaitGroup, n int) {
    fmt.Printf("goroutine: %d\n", n)
    time.Sleep(1 * time.Second)
    wg.Done()
}

func sampleWaitGroup() {
    var wg sync.WaitGroup
    for i := 0; i < 3; i++ {
        wg.Add(1)
        go procGoroutine(&wg, i)
    }
    fmt.Printf("waiting for goroutine to complete...\n")
    wg.Wait()
    fmt.Printf("completed\n")
}

実行結果は以下です。

呼び出したgoroutineが終了するまで呼び出し元が待機していることが分かります。

waiting for goroutine to complete...
goroutine: 0
goroutine: 1
goroutine: 2
completed

goroutineから任意のタイミングで通知したい(通知のみでよい場合、かつ複数回)

sync.Cond はいわゆる条件変数で、goroutine間でなんらかのタイミングで通知のみを複数回行いたい場合に利用できます。

Wait した側は Signal または Broadcast されるまで待機することができますが、その際値の受け渡しはできません。

値の受け渡しが必要な場合、別途共有リソースを用意して値をやり取りするか、後述する chan を用いることになります。

以下はいわゆるProducer/Consumerの機構を実装したものです。

producerはデータを生産しますが、一定量になったらconsumerが消費するのを待ちます。

consumerはデータを消費しますが、存在しない場合はproducerが生産するのを待ちます。

func produce(cond *sync.Cond, messages *[]string, msg string) {
    cond.L.Lock()
    for len(*messages) == 5 {
        fmt.Printf("produce: messages full, msg: %s\n", msg)
        cond.Wait()
    }
    *messages = append(*messages, msg)
    cond.Signal()
    cond.L.Unlock()
}

func consume(cond *sync.Cond, messages *[]string) {
    cond.L.Lock()
    for len(*messages) == 0 {
        fmt.Printf("consume: messages empty\n")
        cond.Wait()
    }
    msg := (*messages)[0]
    fmt.Printf("consume: msg: %s\n", msg)
    *messages = (*messages)[1:]
    cond.Signal()
    cond.L.Unlock()
}

func sampleCond() {
    wg := sync.WaitGroup{}
    mutex := sync.Mutex{}
    cond := sync.NewCond(&mutex)
    messages := make([]string, 0)

    wg.Add(2)
    go func() {
        for i := 0; i < 10; i++ {
            produce(cond, &messages, fmt.Sprintf("msg %d", i))
        }
        wg.Done()
    }()
    go func() {
        for i := 0; i < 10; i++ {
            consume(cond, &messages)
        }
        wg.Done()
    }()
    wg.Wait()
}

実行結果は以下です。

producerとconsumerとがそれぞれ協調しつつ動作していることが分かります。

consume: messages empty
produce: messages full, msg: msg 5
consume: msg: msg 0
consume: msg: msg 1
consume: msg: msg 2
consume: msg: msg 3
consume: msg: msg 4
consume: messages empty
consume: msg: msg 5
consume: msg: msg 6
consume: msg: msg 7
consume: msg: msg 8
consume: msg: msg 9

goroutineから任意のタイミングで通知したい(なんらか値を返却したい場合)

channelは、送信元と送信先とで任意のデータをやり取りすることができる機構です。その際、channelに読み込めるデータが存在するかどうかをあらかじめチェックすることも可能なので、さまざまな用途に利用することができます。

先ほどのProduder/Consumerをchannelで実装し直したものが以下です。

func produce(messages chan<- string, msg string) {
    for {
        select {
        case messages <- msg:
            return
        default:
            fmt.Printf("produce: messages full, msg: %s\n", msg)
            time.Sleep(100 * time.Millisecond)
        }
    }
}

func consume(messages <-chan string) {
    for {
        select {
        case msg, ok := <-messages:
            if !ok {
                return
            }
            fmt.Printf("consume: msg: %s\n", msg)
        default:
            fmt.Printf("consume: messages empty\n")
            time.Sleep(100 * time.Millisecond)
        }
    }
}

func sampleChannel() {
    messages := make(chan string, 5)

    go func() {
        for i := 0; i < 10; i++ {
            produce(messages, fmt.Sprintf("msg %d", i))
        }
        close(messages)
    }()
    consume(messages)
}

実行結果は以下です。

sync.Cond の時と同様の結果になっていることが分かります。

consume: messages empty
produce: messages full, msg: msg 5
consume: msg: msg 0
consume: msg: msg 1
consume: msg: msg 2
consume: msg: msg 3
consume: msg: msg 4
consume: messages empty
produce: messages full, msg: msg 5
consume: msg: msg 5
consume: msg: msg 6
consume: msg: msg 7
consume: msg: msg 8
consume: msg: msg 9

特定のタイミングでgoroutineを終了させたい

context.Context を使うことで、呼び出し元での状態変化を呼び出し先に通知することが可能です。

一般的にはエラーハンドンリング時に用いられることが多いと思われます。goroutineの呼び出し元でなんらか異常やタイムアウトが発生した場合などに、呼び出し先でも同様に終了してほしいといったケースで利用できます。

例によってProducer/Consumerでcontextを組み込みます。

func produce(messages chan<- string, msg string) {
    for {
        select {
        case messages <- msg:
            return
        default:
            fmt.Printf("produce: messages full, msg: %s\n", msg)
            time.Sleep(100 * time.Millisecond)
        }
    }
}

func consume(ctx context.Context, messages <-chan string) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("consume: context cancelled\n")
            return
        case msg, ok := <-messages:
            if !ok {
                return
            }
            fmt.Printf("consume: msg: %s\n", msg)
            time.Sleep(100 * time.Millisecond)
        default:
            fmt.Printf("consume: messages empty\n")
            time.Sleep(100 * time.Millisecond)
        }
    }
}

func sampleContext() {
    messages := make(chan string, 5)
    ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
    defer cancel()

    go func() {
        for i := 0; i < 10; i++ {
            produce(messages, fmt.Sprintf("msg %d", i))
        }
        close(messages)
    }()
    consume(ctx, messages)
}

実行結果は以下です。

consumeは時間のかかる処理になっていますが、途中でcontextの終了を検知して処理を中断していることが分かります。

consume: messages empty
produce: messages full, msg: msg 5
produce: messages full, msg: msg 5
consume: msg: msg 0
consume: msg: msg 1
produce: messages full, msg: msg 7
produce: messages full, msg: msg 7
consume: msg: msg 2
consume: context cancelled

まとめ

本日はGo言語の並行処理において、goroutine間で情報をやり取りする方法についてまとめました。

これらの機能は便利かつ簡単に使えてしまうのであまり深く考えずに使っていたりしたのですが、記事を書くにあたって改めて学び直すことでより理解を深めることができました。

Swift ~Copyableの導入

参考

~Copyableの導入

Swift 5.9でCopyableと~Copyableが導入されました。 全ての型が暗黙的にCopyableに準拠するので、今まで通りCopyableを前提にするなら特にCopyableと~Copyableを意識しなくても問題はありませんが、一方、~Copyableを使うと一意なリソースを表現でき、それによってより効率的なコードを書けたり、論理的に正しいコードを書くのに役立つ場合があります。

この記事では、~Copyable導入の利点についての調査と、弊社プロダクトへの導入の検討状況をご紹介します。

CopyableなStructとの比較

Structは値型です。値のコピーが複数作られるので、一意なリソースを表現するのには適していません。 また、値のコピーはメモリを占有しコピー作業に処理時間がかかるので、~CopyableなStructを使うことでコンピューティング資源の消費が少ない効率的なコードを書ける可能性があります。

Classとの比較

Classは参照型です。一意なリソースを表現することができます。 複数の箇所から同時に参照、変更が可能です。そのため、非同期的な処理でデータ競合の問題を起こしたり、参照元の管理のミスでメモリリークを発生させる恐れがあるなどコードを複雑化させる要因になります。 ~CopyableなStructを使うことでよりシンプルで安全なコードを書ける可能性があります。

所有権

~Copyableな型の値の所有者を明確にするために、 所有権(ownership) という概念が導入されています。 値の受け渡しをするとき、値をコピーする代わりに所有権の移動もしくは共有が行われます。 所有権の取り扱い方には以下の3つの種類があります。

  • consume
    • 値の所有権を移動する
    • 所有権が移動した後、元の値は無効になる
  • borrow
    • 値の所有権を共有する
    • 元々の所有者から値の所有権が剥奪されることはない
    • 借りた側は値を参照だけできる。値を変更することはできない
  • mutating (or inout)
    • 値の所有権を一時的に移動する
    • 操作が終わるまでの間、値の参照、変更ができる
    • 操作終わったら元の所有者に所有権が戻る

~Copyableを使ったコードの記述例

~Copyableな型の宣言

struct FloppyDisk: ~Copyable {}

所有権の移動

値の代入操作をすると、値がコピーされる代わりに所有権が移動します。 system が持っていた所有権は消費され、使用できなくなります。

func copyFloppy() {
  let system = FloppyDisk()    // error: 'system' used after consume
  let backup = system          // consumed here
  load(system)                 // used here
  // ...
}

func load(_ disk: borrowing FloppyDisk) {}

関数の引数として~Copyableな型の値を渡す場合

関数の引数として~Copyableな型の値を渡す場合、 consuming, borrowing, inout のいずれかを指定する必要があります。

consuming

関数を呼んだ時点で値の所有権が移動します。 値は関数の中で消費される必要があります。 この例では、関数の呼び出し元ではすでに所有権を失っているのに値を消費する操作をしようとしているため、コンパイルエラーになります。

struct FloppyDisk: ~Copyable { }

func newDisk() -> FloppyDisk {
  let result = FloppyDisk()    // error: 'result' consumed more than once
  format(result)    // consumed here
  return result    // consumed again here
}

func format(_ disk: consuming FloppyDisk) {
  // ...
}

borrowing

関数内では引数として受け取った値を変更できません。 この例では関数内で値を消費する操作をしようとしているため、コンパイルエラーになります。

struct FloppyDisk: ~Copyable { }

func newDisk() -> FloppyDisk {
  let result = FloppyDisk()
  format(result)
  return result
}

func format(_ disk: borrowing FloppyDisk) {    // error: 'disk' is borrowed and cannot be consumed
  var tempDisk = disk    // consumed here
  // ...
}

inout

関数内では一時的に所有権を持つため値を変更可能です。 関数の終了後は呼び出し元に所有権が戻ります。 この例では、format関数内で値を変更し、関数の終了後に変更された値を利用できています。

struct FloppyDisk: ~Copyable { }

func newDisk() -> FloppyDisk {
  var result = FloppyDisk()
  format(&result)
  return result
}

func format(_ disk: inout FloppyDisk) {
  var tempDisk = disk
  // ...
  disk = tempDisk
}

~Copyableな型のインスタンスメソッド

~Copyableな型のインスタンスメソッドには、 borrowing, consuming, mutating のいずれかを付与します。 borrowing がデフォルト値のため、明示しない場合は borrowing として扱われます。

~Copyableの利用例

WWDCのセッション (https://developer.apple.com/jp/videos/play/wwdc2024/10170/) では、~Copyableな型を使って一意なリソースを表現することで、静的に論理的に正しいコードを書く例を紹介しています。

BankTransfer(銀行振込)を題材にしています。

~Copyableを使わない例

class BankTransfer {
  func run() {
    // .. do it ..
  }
}

func schedule(_ transfer: BankTransfer,
              _ delay: Duration) async throws {

  if delay < .seconds(1) {
    transfer.run()
    // A
  }

  try await Task.sleep(for: delay)
  transfer.run()
}

~Copyableを使わない記述例です。

このコードにはバグがあります。Aの箇所に本来returnが必要なところ、書き忘れています。これによってdelayが1秒未満の場合 transfer.run() が2回実行されてしまいます。

振込の実行(run)は一つのBankTransferに対して複数回行われてはいけないのですが、この例ではそのような制約を実装していないため、呼び出し側の誤った実装によってこのような問題が発生してしまいます。

BankTransferの状態管理を追加した例

class BankTransfer {
  var complete = false

  func run() {
    assert(!complete)
    // .. do it ..
    complete = true
  }

  deinit {
    if !complete { cancel() }
  }

  func cancel() { /* ... */ }
}

func schedule(_ transfer: BankTransfer,
              _ delay: Duration) async throws {

  if delay < .seconds(1) {
    transfer.run()
  }

  try await Task.sleep(for: delay)
  transfer.run()
}

この例ではBankTransferの状態管理を追加し、run()の複数回実行を防いでいます。 ただし、assertによる動的な検証のため実行時にしか問題を検出できません。 適切なテストを書かない限り見つけられず(この場合、delayが1未満の条件のテストが必要)もしテストで見つけられないと、本番でクラッシュしてしまいます。

~Copyableを使った例

struct BankTransfer: ~Copyable {
  consuming func run() {
    // .. do it ..
    discard self
  }

  deinit {
    // .. do the cancellation ..
  }
}

~CopyableなBankTransferを定義しています。 インスタンスメソッドに付与した consuming キーワードによって、run()が複数回実行されることを防いでいます。

func schedule(_ transfer: consuming BankTransfer,
              _ delay: Duration) async throws {    // error: 'transfer' consumed more than once

  if delay < .seconds(1) {
    transfer.run()    // consumed here
  }

  try await Task.sleep(for: delay)
  transfer.run()    // consumed again here
}

バグのあるschedule関数です。 transfer.run() が複数回実行される可能性があることをコンパイル時に検出できています。

func schedule(_ transfer: consuming BankTransfer,
              _ delay: Duration) async throws {

  if delay < .seconds(1) {
    transfer.run()
    return
  }

  try await Task.sleep(for: delay)
  transfer.run()
}

returnを追加し、バグを修正できました。 このように、~Copyableな型を使って一意なリソースを表現することで、論理的な問題点をコンパイル時に検出できるようになりました。 問題を早期に発見でき、コードの品質向上に役立ちそうです。

弊社プロダクトで~Copyableを適用する

弊社プロダクトではまだ~Copyableを導入していませんが、以下のような箇所で導入することを検討中です。

  • ネットワーク接続
    • ネットワーク接続を一意なリソースとして表現する
  • PDF出力
    • PDF出力ではサイズの大きいデータを扱うため、~Copyableを使うことで不用意なメモリの消費を防止する
  • 広告表示
    • 広告を一意なリソースとして表現する
    • 広告リクエスト、インプレッション、クリックなどの一連の処理を~Copyableな型のインスタンスとして表現する