一休.com Developers Blog

一休のエンジニア、デザイナー、ディレクターが情報を発信していきます

永続データプログラミングと永続データ構造

この記事は 一休.com Advent Calendar 2024 の3日目の記事です。

昨今は我々一休のような予約システム開発においても、関数型プログラミング由来のプラクティスを取り入れる機会が増えています。

例えば、値はイミュータブルである方が扱いやすい、関数は副作用のない純粋関数にする方がテスタビリティなども含め何かと都合がよい、そういう場面では積極的に不変な値を使い、関数が冪等になるよう意識的に実装します。ドメインロジックを純粋関数として記述できると、堅牢で責務分離もしやすく、テストやデバッグもしやすいシステムになっていきます。

ところで「関数型プログラミングとはなんぞや」というのに明確な定義はないそうです。ですが突き詰めていくと、計算をなるべく「文」ではなく「式」で宣言することが一つの目標だということに気がつきます。

文と式の違いは何でしょうか?

for 文、代入文、if 文などの文は、基本的には値を返しません。値を返さないということは、文は直接結果を受け取るものではなく、命令になっていると言えます。文は計算機への命令です。

一方の式は、必ず返値を伴いますから、その主な目的は返値を得る、つまり式を評価して計算の結果を得ることだと考えることができます。

customer.archive()

と、文によって暗黙的に customer オブジェクトの内部状態を変更するのではなく

const archivedCustomer = archiveCustomer(customer)

と、引数で与えられた customer オブジェクトを直接変更することなしに、アーカイブ状態に変更されたコピーとしての archivedCustomer オブジェクトを返値として返す、これが式です。この関数は純粋関数として実装し、customer オブジェクトは不変、つまりイミュータブルなものとして扱うと良いでしょう。

式によるイミュータブルなオブジェクトの更新は TypeScript なら

export const archiveCustomer = (customer: Customer): Customer => ({
  ...customer,
  archived: true
})

と、スプレッド構文を使うことで customer オブジェクトのコピーを作りつつ、変更したいプロパティを新たな値に設定したものを返すように実装します。

このように、引数で与えたオブジェクトは直接変更せず、状態を変更した別のオブジェクトを返すような関数の連なりによって計算を定義していくのが関数型プログラミングです。

このあたりの考え方については、過去の発表スライドがありますので参考にしてください。

実際、我々の一部プロダクトのバックエンドでは TypeScript による関数型スタイルでの開発を実践しています。以下はプロダクトのコードの一例で、Customer オブジェクトに新しいメールアドレスの値を追加するための addEmail 関数です。先の実装に同じく、スプレッド構文を使って元のオブジェクトを破壊せずに、メールアドレスが追加されたオブジェクトを返します。

const addEmail =
  (address: EmailAddress) =>
  (customer: Customer): Customer => {
    const newAddress: CustomerEmail = {
      id: generateCustomerEmailId(),
      address,
    }
    return {
      ...customer,
      emails: [...customer.emails, newAddress],
    }
  }

ドメインオブジェクトの状態遷移はすべて、この式による状態遷移のモデルで実装しています。

永続データプログラミング

さて、本記事のテーマは「永続データ」です。永続データとは何でしょうか?

式を意識的に使い、かつ値をイミュータブルに扱うことを基本としてやっていくと、何気なく書いたプログラムの中に特徴的な様子が現れることになります。

以下、リスト操作のプログラムを見てみましょう。リストの先頭や末尾に値を追加したり、適当な値を削除する TypeScript のプログラムです。リストをイミュータブルに扱うべく、値の追加や削除などデータ構造の変更にはスプレッド構文を使い、非破壊的にそれを行うようにします。

// as1: 元のリスト
const as1 = [1, 2, 3, 4, 5];

// as2: 新しいリスト (先頭に 100 を追加)
const as2 = [100, ...as1];

// as3: 新しいリスト (末尾に 500 を追加)
const as3 = [...as2, 500];

// as4: 新しいリスト (値 3 を削除)
const as4 = as3.filter(x => x !== 3);

console.log("as1:", as1); // [1, 2, 3, 4, 5]
console.log("as2:", as2); // [100, 1, 2, 3, 4, 5]
console.log("as3:", as3); // [100, 1, 2, 3, 4, 5, 500]
console.log("as4:", as4); // [100, 1, 2, 4, 5, 500]

更新をしても元のリストは不変なので、as1 を参照しても更新済みの結果は得られません。リスト操作の返り値を as2 as3 as4 とその都度変数にキャプチャし、そのキャプチャした変数に対して次のリスト操作を行います。こうしてデータ構造は不変でありつつも一連の、連続したリスト操作を表現します。

データ構造を不変にした結果、リストが更新される過程の状態すべてが残りました。リストを何度か更新したにも関わらず、変更前の状態を参照することができています。as1 を参照すれば初期状態を、as2as3 で途中の状態を参照することができます。このように値の変更後もそれ以前の状態が残るさまを「永続データ」と呼びます。そして永続データを用いたプログラミングを「永続データプログラミング」と呼びます。

値をイミュータブルに扱うと必然的にそれは永続データになるので、永続データプログラミングはそれ自体、何か特別なテクニックというわけではありません。一方で、値が永続データであることをはっきりさせたい文脈上では「永続データプログラミング」という言葉でプログラミングスタイルを表現すると、その意図が明確になることも多いでしょう。

以下の山本和彦さんの記事では、関数型プログラミングすなわち「永続データプログラミング」であり、永続データを駆使して問題を解くことこそが関数型プログラミングだ、と述べられています。

筆者の関数プログラミングの定義、すなわちこの特集での定義は、「⁠永続データプログラミング」です。永続データとは、破壊できないデータ、つまり再代入できないデータのことです。そして、永続データを駆使して問題を解くのが永続データプログラミングです。

また関数型言語とは、永続データプログラミングを奨励し支援している言語のことです。関数型言語では、再代入の機能がないか、再代入の使用は限定されています。筆者の定義はかなり厳しいほうだと言えます。

第1章 関数プログラミングは難しくない!―初めて学ぶ人にも、挫折した人にもきちんとわかる | gihyo.jp

命令型プログラミングにおいては変更にあたり値を直接破壊的に変更します。変更前のデータ構造の状態を参照することはできません。リストの破壊的変更は、基本的に (式ではなく) 文によって行われるでしょう。文を主体としたプログラミング··· 命令型プログラミングでは、永続ではないデータ、つまり短命データを基本にしていると言えます。一方、式によってプログラムを構成する関数型プログラミングでは、関数の冪等性を確保すべくイミュータブルに値を扱うことになるので、永続データが基本になります。

イミュータブルな値によるプログラミングをする際、そこにある値は不変であるだけでなく、同時に永続データなのだということを認識できると、プログラミングスタイルに対するよりよいメンタルモデルが構築できると思います。

Haskell と永続データプログラミング

やや唐突ですが、イミュータブルといえば純粋関数型言語の Haskell です。先の TypeScript によるリスト操作のプログラムを、Haskell で実装してみます。

main :: IO ()
main = do
  let as1 = [1, 2, 3, 4, 5]
      as2 = 100 : as1
      as3 = as2 ++ [500]
      as4 = delete 3 as3

  print as1 -- [1,2,3,4,5] 
  print as2 -- [100,1,2,3,4,5]
  print as3 -- [100,1,2,3,4,5,500]
  print as4 -- [100,1,2,4,5,500]

Haskell はリストはもちろん、基本的に値がそもそもがイミュータブルです。リスト操作の API はすべて非破壊的になるよう実装されているので、変更にあたり TypeScript のようにスプレッド構文でデータを明示的にコピーしたりする必要はありません。裏を返せば、変更は永続データ的に表現せざるを得ず、式によってプログラムを構成することが必須となります。結果、Haskell による実装は自然と永続データプログラミングになります。

関数型プログラミングすなわち永続データプログラミングだ、というのは、この必然性から来ています。

永続データの特性を利用した問題解決

永続データプログラミングは不変な値を使うことですから、それを実践することで記事冒頭で挙げたようなプログラムの堅牢性など様々なメリットを享受できるわけですが、「変更前の過去の状態を参照できる」という、値が不変であるというよりは、まさに「永続」データの特性が部分が活きるケースがあります。

わかりやすい題材として、競技プログラミングの問題を例に挙げます。

atcoder.jp

問題文を読むのが面倒な方のために、これがどんな問題か簡単に解説します。入力の指示に従ってリストを更新しつつ、任意のタイミングでそのリストの現在の状態を保存する。また任意のタイミングで復元できるようするという、データ構造の保存と復元を題材にした問題です。

ADD 3
SAVE 1
ADD 4
SAVE 2
LOAD 1
DELETE
DELETE
LOAD 2
SAVE 1
LOAD 3
LOAD 1

こういうクエリが入力として与えられる。

  • 空のリストが最初にある
  • クエリを上から順番に解釈して、ADD 3 のときはリスト末尾に  3 を追加する
  • DELETE なら末尾の値を削除
  • SAVE 1 のときは、今使っているリストを ID 番号  1 の領域に保存、LOAD 1 なら ID 番号  1 の領域からリストを復元する
  • クエリのたび、その時点でのリストの末尾の要素を出力する

という問題になっています。

この問題を永続データなしで解こうとすると、リストを更新しても以前の状況に戻れるような木のデータ構造を自分で構築する必要がありなかなか面倒です。一方、永続データを前提にすると、何の苦労もなく解けてしまいます。

以下は Haskell で実装した例です。やっていることは、クエリの内容に合わせてリストに値を追加・削除、保存と復元のときは辞書 (IntMap) に、その時点のリストを格納しているだけです。問題文の通りにシミュレーションしているだけ、とも言えます。

main :: IO ()
main = do
  q <- readLn @Int
  qs <- map words <$> replicateM q getLine

  let qs' = [if null args then (command, -1) else (command, stringToInt (head args)) | command : args <- qs]

  let res = scanl' f ([], IM.empty) qs'
        where
          f (xs, s) query = case query of
            ("ADD", x) -> (x : xs, s) -- リストに値を追加
            ("DELETE", _) -> (drop1 xs, s) -- リストから値を削除
            ("SAVE", y) -> (xs, IM.insert y xs s) -- 辞書にこの時点のリストを保存
            ("LOAD", z) -> (IM.findWithDefault [] z s, s) -- 辞書から保存したリストを復元
            _ -> error "!?"

  printList [headDef (-1) xs | (xs, _) <- tail res] -- 各クエリのタイミングでのリストの先頭要素を得て、出力

Haskell のリストは永続データですから、値を変更しても変更以前の値が残ります。その値が暗黙的に他で書き換えられる事はありません。よって素直にリストを辞書に保存しておけばよいのです。一方、命令型プログラミングにおいてリストがミュータブルな場合は、ある時点の参照を辞書に保存したとしても、どこかで書き換えが発生すると、辞書に保存された参照の先のデータが書き換わるためうまくいきません。

永続データ構造

さて、ここからが本題です。TypeScript でリストを永続データとして扱うにあたり、スプレッド構文によるコピーを使いました。

// as2: 新しいリスト (先頭に 100 を追加)
const as2 = [100, ...as1];

// as3: 新しいリスト (末尾に 500 を追加)
const as3 = [...as2, 500];

すでにお気づきの方も多いと思いますが、値の更新にあたり、リスト全体のコピーが走ってしまっています。一つ値を追加、削除、更新するだけでもリストの要素  n 件に対し  n 件のコピーが走る。つまり  O(n) の計算量が必要になってしまいます。永続データプログラミングは良いものですが、ナイーブに実装するとデータコピーによる計算量の増大を招きがちです。

Haskell など、イミュータブルが前提のプログラミング言語はこの問題をどうしているのでしょうか?

結論、データ構造全体をコピーするのではなく「変更されるノードとそのノードへ直接的・間接的に参照を持つノードだけをコピーする」ことによって計算量を抑え、不変でありながらも効率的なデータ更新が可能になるようにリストその他のデータ構造が実装されています。つまり同じ「リスト」でも、命令型プログラミングのそれと、不変なデータ構造のそれは実装自体が異なるのです。抽象は同じ「リスト」でも具体が違うと言えるでしょう。

変更あったところだけをコピーし、それ以外は元の値と共有を行うこのデータ構造の実装手法は Structural Sharing と呼ばれることもあります。Structural Sharing により不変でありながら効率的に更新が可能な永続データのデータ構造を「永続データ構造」と呼びます。

永続データ構造については、以下の書籍にその実装方法含め詳しく記載されています。

純粋関数型データ構造 - アスキードワンゴ

もとい、例えば Haskell のリストは先頭の値を操作する場合は  O(1) です。先頭要素だけがコピーされていて、それ以降の要素が更新前後の二つのリストで共有されるからです。

同じく、先の実装でも利用した Data.IntMap という辞書、こちらも永続データ構造ですが、内部的にはパトリシア木で実装されていて、値の挿入やキーの探索は、整数のビット長程度の計算量 ···  n をデータサイズ、 W をビット長としたとき  O(min(n, W)) に収まります。

Haskell で利用する標準的なデータ構造 ··· List、Map、Set、Sequence、Heap は、すべてイミュータブルでありながら、値の探索や変更が  O(1) O(\log n) 程度の計算量で行える永続データ構造になっています。(なお、誤解の無いよう補足すると、ミュータブルなデータ構造もあります。ミュータブルなデータ構造は手続き的プログラミングで変更することになります)

永続データ構造を利用することによって、永続データプログラミング時にもパフォーマンスをそれほど犠牲にせず、大量のデータを扱うことが可能になります。裏を返せば、永続データプログラミングをより広範囲に実践していくには、永続データ構造が必要不可欠であるとも言えます。関数型プログラミングは値が不変であることをよしとしますが、そのためには永続データ構造が必要かつ重要なパーツなのです。

TypeScript その他のプログラミング言語で永続データプログラミングを実践するとき、純粋関数型言語とは異なり、素の状態では永続データ構造の支援がないということは念頭に置いておくべきでしょう。

TypeScript や Python で永続データ構造を利用するには?

TypeScript の Array、Map、Set などの標準的なデータ構造はすべて命令型データ構造、つまりミュータブルです。命令型のプログラミング言語においては、どの言語も同様でしょう。一方、プログラミング言語によっては List、Map、Set などの永続データ構造バージョンを提供するサードパーティライブラリがあります。

これらのライブラリを導入することで、TypeScript や Python で永続データ構造を利用することができます。しかし、実際のところこれらの永続データ構造の実装が、広く普及しているようには思えません。

永続データ構造は業務システム開発にも必須か?

結論からいうと、命令型のプログラミング言語で業務システム開発をする場合には、必須ではないでしょう。

永続データプログラミング自体は良い作法ですが、業務システムにおいては、大量データのナイーブなコピーが走るような実装をする場面が少ないから、というのが理由だと思います。

Haskell のような関数型言語を使っているのであれば、永続データ構造は標準的に提供されていて、そもそも必須かどうかすら気にする必要がありません。永続データ構造のメカニズムを全く知らなくても、自然にそれを使ったプログラムを書くように導かれます。

命令型言語を使いつつも、永続データプログラミングを実践するケースではどうでしょうか? 速度が必要な多くの場面では、いったん永続データを諦め、単に命令型データ構造を利用すれば事足りるので、わざわざ永続データ構造を持ち出す必要はないでしょう。ドメインオブジェクトの変更をイミュータブルに表現するためコピーする場合も、せいぜい 10 か 20 程度のプロパティをコピーする程度で、コピー 1回にあたり数万件といったオーダーのコピーが発生するようなことは希でしょう。

よって業務システム開発において Immutable.js や pyrsistent のようなサードパーティライブラリを積極的に使いたい場面は、先に解いた競技プログラミング問題のように、永続データ構造の永続である特性そのものが機能要件として必要になるケースに限られるのではないか? と思います。

Immutable.js の開発が停滞しているのは、フロントエンドで永続データ構造の需要が乏しいからでしょう。このようなデータ構造自体は非常に重要な概念で、多くのプログラミング言語に存在します。我々フロントエンドエンジニアが依存するブラウザの内部でも、効率的なデータ処理のために多用されているはずです。しかし、フロントエンドエンジニアがイミュータブルに求めているのは処理速度ではなく設計の改善です。だからこそ、Immutable.js に代わって Immer が隆盛したのでしょう。

Immutable.jsとImmer、ちゃんと使い分けていますか?

一方、純粋関数型言語で競技プログラミングのような大きなデータを扱うプログラミングを行う場合、永続データ構造は必須ですし、また永続データ構造を利用していることを意識することでよりよい実装が可能になると思っています。個人的にはこの「永続データ構造によって、より良い実装が可能になる」点こそが本質的だと思っています。

先の競技プログラミングの実装を改めてみてみます。

main :: IO ()
main = do
  q <- readLn @Int
  qs <- map words <$> replicateM q getLine

  let qs' = [if null args then (command, -1) else (command, stringToInt (head args)) | command : args <- qs]

  let res = scanl' f ([], IM.empty) qs'
        where
          f (xs, s) query = case query of
            ("ADD", x) -> (x : xs, s) -- リストに値を追加
            ("DELETE", _) -> (drop1 xs, s) -- リストから値を削除
            ("SAVE", y) -> (xs, IM.insert y xs s) -- 辞書にこの時点のリストを保存
            ("LOAD", z) -> (IM.findWithDefault [] z s, s) -- 辞書から保存したリストを復元
            _ -> error "!?"

  printList [headDef (-1) xs | (xs, _) <- tail res] -- 各クエリのタイミングでのリストの先頭要素を得て、出力 (※)

このプログラムでは、クエリのたびに、その時点でのリストの値を出力する必要があります。が、上記のプログラムでは (クエリのたびに都度出力を得ているのではなく) クエリを全部処理し終えてから、最終的な出力、つまりプレゼンテーションを組み立てています。(※) の実装です。

データ構造が命令型データ構造の場合、こうはいきません。ある時点のデータ構造の状態はその時点にしか参照できないため、プレゼンテーションをそのタイミングで得る必要があります。

一方、永続データ構造の場合、各々時点のデータ構造の状態を後からでも参照できますし、メモリ上にデータ構造を保持しておいても Structural Sharing によりそれが肥大化することもありません。このプログラムのように、中核になる計算 ··· つまりドメインロジックをすべて処理し終えてから、改めてプレゼンテーションに変換することが可能です。プレゼンテーション・ドメイン分離の観点において、永続データ構造が重要な役割を果たしています。この考え方は、実装スタイルに大きな影響を与えます。

この点に関する詳細は、競技プログラミング文脈を絡めて話す必要もあり長くなりそうなので改めて別の記事にしようと思います。

さて、業務システム開発には必須とは言えないと私見は述べましたが、命令型プログラミング言語でも値を不変に扱うとき、このナイーブなコピーが走る問題を意識できているかどうかは重要でしょう。多くの関数型言語においてはこの課題を永続データ構造によって解消しているということは、知っておいて損はありません。

永続データ構造の実装例

「永続データ構造」というと字面から何かすごそうなものを思い浮かべるかもしれませんが、その実装方法を知っておくともう少し身近なものに感じられると思います。永続データ構造の中でも比較的実装が簡単な、永続スタックと永続配列の実装を紹介して終わりにしたいと思います。実装の詳細については解説しませんが、雰囲気だけみてもらって「何か特別なことをしなくても普通に実装できるんだな」という雰囲気を掴んでもらえたらと思います。

永続スタック

Haskell で実装した永続スタックの一例です。再帰データ型でリストのようなデータ構造を宣言し、API として head tail (++) など基本的な関数を実装します。

代数的データ型でリンクリスト構造を宣言し、先頭要素への参照を返すように実装します。先頭要素を参照したいとき (head) は、先頭要素への参照からそれを取り出し値を得るだけ。先頭以外の要素を得る、つまり分解したいとき (tail) は次の要素への参照を返す。これだけで永続スタックが実装できます。

二つのスタックを結合する ((++)) ときはどうしても  O(n) かかってしまいますが、その際も双方のリストをコピーするのではなく古いリストの一方だけをコピーし、のこりの一つは新しいリストで共有されるように実装しています。

import Prelude hiding ((++))

data Stack a = Nil | Cons a (Stack a) deriving (Show, Eq)

empty :: Stack a
empty = Nil

isEmpty :: Stack a -> Bool
isEmpty Nil = True
isEmpty _ = False

cons :: a -> Stack a -> Stack a
cons = Cons

head :: Stack a -> a
head Nil = error "EMPTY"
head (Cons x _) = x

tail :: Stack a -> Stack a
tail Nil = error "EMPTY"
tail (Cons _ xs) = xs

(++) :: Stack a -> Stack a -> Stack a
Nil ++ ys = ys
Cons x xs ++ ys = Cons x (xs ++ ys)

main :: IO ()
main = do
  let s0 :: Stack Int
      s0 = empty
      s1 = cons (1 :: Int) s0
      s2 = cons (2 :: Int) s1
      s3 = cons (3 :: Int) s1

      s4 = s1 ++ s3

  print s0
  print s1
  print s2
  print s3
  print s4

出力結果は以下です。

Nil
Cons_1_Nil
Cons_2_(Cons_1_Nil)
Cons_3_(Cons_1_Nil)
Cons_1_(Cons_3_(Cons_1_Nil))

永続配列

永続配列は、配列といっても命令型の配列のように連続した領域を索引で参照できるようにするモデルではなく、完全二分木で表現します。

値は葉に持たせて、インデックスによる参照時には根から二分木を辿って目的の葉を特定します。そのため、参照時の計算量は  O(1) ではなく  O(\log n) となります。

二分木による配列の表現

更新時には「変更されるノードとそのノードへ直接的・間接的に参照を持つノードだけをコピーする」という考えに従い、根から更新対象の葉までを辿る経路上のノードをコピーする経路コピーという手法を使います。経路をコピーするといっても、木の高さ程度ですから更新も結局  O(\log n) になります。

経路コピーについては Path Copying による永続データ構造 - Speaker Deck のスライドがわかりやすいと思います。

{-# LANGUAGE DeriveFunctor #-}

import Prelude hiding (read)

data Tree a = Leaf a | Node (Tree a) (Tree a) deriving (Show, Functor)

fromList :: [a] -> Tree a
fromList [] = error "Cannot build tree from empty list"
fromList [x] = Leaf x
fromList xs =
  let mid = length xs `div` 2
   in Node (fromList (take mid xs)) (fromList (drop mid xs))

read :: Int -> Tree a -> a
read _ (Leaf x) = x
read i (Node left right)
  | i < size left = read i left
  | otherwise = read (i - size left) right

write :: Int -> a -> Tree a -> Tree a
write _ v (Leaf _) = Leaf v
write i v (Node left right)
  | i < size left = Node (write i v left) right
  | otherwise = Node left (write (i - size left) v right)

size :: Tree a -> Int
size (Leaf _) = 1
size (Node left right) = size left + size right

main :: IO ()
main = do
  let arr = fromList [1 .. 8 :: Int]
  print arr

  print $ read 3 arr

  let arr' = write 3 42 arr

  print $ read 3 arr'
  print $ read 3 arr

永続スタック、永続配列の実装を簡単ですが紹介しました。

何か特殊な技法を使うというものではなくスタック、配列などの抽象が要求する操作を考え、その抽象に適した具体的で効率的なデータ構造を用意する、というのが永続データ構造の実装です。

まとめ

永続データプログラミングと永続データ構造について解説しました。

  • 不変な値を使い、式でプログラムを宣言すると永続データプログラミングになる
  • 永続データプログラミングでは、変更前の値を破壊しない。変更後も変更前の値を参照できるという特徴を持つ
  • 関数型プログラミングすなわち永続データプログラミングである、とも考えられる
  • 永続データプログラミングにおけるデータコピーを最小限に留め効率的な変更を可能にする不変データ構造が「永続データ構造」
  • 業務システム開発において、永続データ構造は必須とは言えない。パフォーマンスが必要な場面で、永続データ構造を持ち出す以外の解決方法がある
  • 大量データを扱うことが基本で、かつ値を不変に扱いたいなら永続データ構造は必須
  • 一般のシステム開発においても機能要件として「永続」データが必要になるなら、Immutable.js とかを利用しても良いかも
  • 関数型プログラミングが、不変でありながらも値の変更をどのように実現しているかは永続データ構造に着目するとよく理解できる

というお話でした。

途中少し振れた、永続データ構造を前提にした計算の分離については別途あらためて記事にしたいと思います。

一休.com Developers Blogの執筆環境2024

この記事は一休.com Advent Calendar 2024の1日目の記事です。

kymmtです。

当ブログ「一休.com Developers Blog」は、以前からはてなブログで運用しています。そして、今年からは執筆環境を少し改善しました。具体的には、GitHubを用いて記事の作成や公開ができるようにしました。

この記事では、当ブログの執筆環境をどのように改善し、ふだん運用しているかについて紹介します。

HatenaBlog Workflows Boilerplateの導入

従来は、執筆者が記事をローカルやブログ管理画面のエディタ上で書き、なんらかの方法でレビューを受け、公開するというフローでした。このフローで一番ネックになりやすいのはレビューで、Slack上でレビューが展開されがちになり、議論を追いづらいという問題がありました。

そこで、執筆環境の改善のために、はてなさんがβ版として公開しているHatenaBlog Workflows Boilerplateを利用して、記事執筆用のリポジトリを整備しました。

github.com

これは、GitHub Actionsのはてなブログ用reusable workflow集であるhatenablog-workflowsを用いた、はてなブログ記事執筆用のリポジトリテンプレートです。

リポジトリを整備した結果、GitHub上ではてなブログの記事をMarkdownファイルとして管理したり、GitHub Actionsで原稿の同期や公開などの操作を実行できるようになりました。

現在は、記事執筆のフローは次のようになっています。

  1. 下書き用pull request (PR)作成actionを実行
  2. 手元にブランチを持ってきて執筆
  3. ときどきコミットをpushしてはてなブログに同期し、プレビュー画面で確認
  4. PR上でレビュー
  5. 公開できる状態にしてPRをmainにマージし、自動で記事公開

普段開発に携わっているメンバーはGitHubに慣れています。そのようなメンバーがPR上でブログ記事執筆やレビューができるようにすることでの執筆体験向上を図りました。とくに、レビューをPRで実施することで、あるコメントがどの文章に対するものなのか分かりやすくなる点は便利だと感じています。社内のメンバーからも、

ブログが GitHub で管理できるようになったのは、レビューの観点でホントありがたい

という感想をもらっています。

記事のネタ管理

記事のネタはGitHubのissueとして管理しています。どの執筆PRでネタが記事になったのかや、ネタに関する細かいメモも記録しています。情報の一元化の観点では、スプレッドシートなどで別管理するよりは好ましいと思います。

校正

校正はtextlintを利用しています。pull_requestトリガーでtextlintによる校正を実行するGitHub Actionsのワークフローを設定しています。ワークフローは.github/workflows/textlint.yamlに次のようなものを置いています。

name: textlint

on:
  pull_request:
    paths:
      - "draft_entries/*.md"
      - "entries/*.md"

jobs:
  run:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
      - run: npm ci
      - name: Add textlint problem matcher
        run: echo "::add-matcher::.github/textlint-unix.json"
      - name: Lint files added/modified in the PR
        run: |
          git diff --diff-filter=AM origin/main...HEAD --name-only -- '*.md' | xargs npx textlint --format unix

.github/textlint-unix.jsonは次のとおりです。

{
  "problemMatcher": [
    {
      "owner": "textlint-unix",
      "pattern": [
        {
          "regexp": "^(.+):(\\d+):(\\d+):\\s(.*)$",
          "file": 1,
          "line": 2,
          "column": 3,
          "message": 4
        }
      ]
    }
  ]
}

これでPR画面上にtextlintによる校正結果が表示されるようになります。

problem matcherによるtextlintの校正結果の表示

textlintで現在使っているルールは次のとおり最低限のものです。技術記事もテクニカルライティングの一種であるとは思いますが、Web上で気軽に読んでもらう類のものでもあるため、文体にある程度個性が出るのは問題ないと考え、そこまで厳しいルールにはしていません。

レビュアー

記事ファイルが配置されるディレクトリのコードオーナーに技術広報担当者を設定して、担当者のレビューが通れば記事を公開してOK、というフローにしました。現在は2名の担当者で回しています。

HatenaBlog Workflows Boilerplateを利用しているという前提で、.github/CODEOWNERSを次のように設定しています。ここで@orgにはGitHub orgの名前が、teamには技術広報担当者からなるteamの名前が入ります。

/draft_entries/ @org/team
/entries/ @org/team

この設定に加えて、リポジトリのbranch protection rulesとしてmainブランチでコードオーナーのレビューを必須にすることで、必ず技術広報担当者が目を通せる仕組みとしています。

とはいえ、各記事の内容については執筆者のチームメンバーのほうが深く理解していることも多いです。ですので、内容の正確性という観点ではチームメンバーどうしでレビューしてもらい、技術広報担当者は会社の名前で社外に公開する記事としてふさわしいかという観点でのチェックをすることが多いです。

余談: HatenaBlog Workflows Boilerplateへのフィーチャーリクエスト

上記のBoilerplateを用いてリポジトリを運用しているうちに、ほしい機能が1つ生まれたので、該当リポジトリのissueを通じて機能をリクエストしました。

github.com

具体的には、下書きPRを作成したとき、そのPRはdraft状態になっていてほしいというものです。GitHubの仕様上、PRがdraftからready for reviewになるとき、コードオーナーへレビュー依頼の通知が送られるようになっています。ですので、記事を書き始めるときのPRとしてはdraftとするのが自然と考えた、というものです。

結果、機能として無事取り込んでいただけました。ありがとうございました1

staff.hatenablog.com

おわりに

この記事では2024年の当ブログの執筆環境について紹介しました。GitHubを起点とする執筆環境や自動化を取り入れることで、執筆体験を向上できました。この記事も紹介した仕組みで書きました。引き続き、このブログで一休での開発における興味深いトピックをお届けしていければと思います!


  1. 機能リリース後に、draft機能がGitHubの有料プランの機能であることに伴うフォローアップもしていただきました。ありがとうございました

一休はRust.Tokyo 2024にゴールドスポンサーとして協賛します

kymmtです。

11/30に開催されるRust.Tokyo 2024に一休はゴールドスポンサーとして協賛します。

rust.tokyo

一休でのRustの活用

一休では一休.comレストランにおいてRustの活用を進めています。昨年に当ブログで活用の様子を紹介した際は、当時開発が進んでいたレストラン予約サービスWeb UIのバックエンドにおけるユースケースだけに触れていました。

user-first.ikyu.co.jp

それから1年弱経過した現在では、Rust活用の場はさらに広がっており、Rustを書いているメンバーも増えてきています。

Rust.Tokyoのゴールドスポンサー

2024年はRustでWebサービスを開発するのがますます現実的な選択肢として挙がるようになった年だったのではないかと思います。たとえば、RustでWebアプリケーションを開発するための本が複数発売されるなど1、学習リソースは着実に揃ってきています。このような環境のなかで、一休もRust採用企業として活用事例の共有などを通じてコミュニティを活性化させることが重要だと考えています。

以上のような状況に基づいて、一休はこのたびRust.Tokyo 2024にゴールドスポンサーとして協賛させていただくことになりました。当日は会場で

  • スポンサーセッション登壇
  • ブースの出展

をやります。

スポンサーセッションでは、トラックAで15:25から「総会員数1,500万人のレストランWeb予約サービスにおけるRustの活用」と題してkymmtが発表します。2024年現在の一休.comレストランにおけるRust活用の様子のスナップショットとして、システムの設計や技術的なトピックについて紹介する予定です。

また、会場ではブースを出展する予定です。技術広報チームを中心に、一休.comレストランのエンジニアとしてkymmtが、ほかにも宿泊予約サービスの一休.comのエンジニアとEMがブースに参加する予定です。一休のサービス開発の様子について興味があるかたはぜひお越しください。

おわりに

11/30に一休はRust.Tokyo 2024にゴールドスポンサーとして協賛します。参加者のかたは当日会場でお会いしましょう!

TSKaigi Kansai 2024とJSConf JP 2024に一休のエンジニアが登壇します

kymmtです。

今月は

と、JavaScript/TypeScriptに関するカンファレンスが2つ開催されます。今年は、一休.comレストランのフロントエンドアーキテクトを務めるエンジニア恩田 (@takashi_onda)がこれらのカンファレンス両方に登壇します。

1週違いで開催されるそれぞれのカンファレンスでは、一休.comレストランのフロントエンド開発をきっかけとする内容のトークテーマを携えて登壇します。この記事では、現在絶賛発表準備中の本人からのコメントも交えつつ、発表内容について紹介します!

TSKaigi Kansai 2024: 構造的型付けとserialize境界

kansai.tskaigi.org

TSKaigi Kansai 2024は、TypeScriptをテーマとして2024年5月に東京で開催されたTSKaigi 2024から派生した地域型カンファレンスです。

本カンファレンスでは、16:00-16:30に「カミナシ堂」会場で「構造的型付けとserialize境界」というタイトルで恩田が発表します。

以下、恩田からのコメントです。

京都在住なので、地元でお話しできるのが楽しみです。一休では僕のように関西やその他の地域に在住しているエンジニアも複数います。発表を聞いていただき興味を持たれた方は、お気軽にお声がけください。

JSConf JP 2024: Reactへの依存を最小にするフロントエンドの設計

jsconf.jp

JSConf JP 2024は、Japan Node.js Association様により運営されている、JavaScriptをテーマとする大規模カンファレンスです。

本カンファレンスでは、11:10-11:40にトラックBで「Reactへの依存を最小にするフロントエンドの設計」というタイトルで発表します。この発表は、本ブログで以前公開した記事「React / Remixへの依存を最小にするフロントエンド設計」をもとに、今回あらためてJSConfでお話しするものです。

user-first.ikyu.co.jp

以下、恩田からのコメントです。

一休レストランのフロントエンド開発では、疎結合な設計を心がけていて、ほとんどのロジックは Vanilla JS だけで完結しています。そのような書き方を可能とする、バックエンド開発で培われた知見をフロントエンドに適用する手法についてお話ししたいと思います。

おわりに

この記事では、TSKaigi Kansai 2024とJSConf JP 2024でのエンジニア恩田 (@takashi_onda)の発表内容について紹介しました。

参加者の方は、それぞれの会場でぜひ発表を聞きに来ていただければと思います!

Vue Fes Japan 2024に登壇 & ランチスポンサーをしました

CTO室プラットフォーム開発チームのいがにんこと山口(@igayamaguchi)です。

先日、Vue Fes Japan 2024が開催され、一休は登壇とスポンサーをしました。その紹介をします。

Vue Fes Japan 2024が開催

10月19日(土)に日本最大級の Vue.js カンファレンス、Vue Fes Japan 2024 が開催されました。一休は当カンファレンスでプロポーザル採択による発表と、ランチスポンサーとしてランチセッションでの発表を行いました。この記事ではその発表の概要を紹介します。

一休で行った発表は2つです。

  • Vue.js、Nuxtの機能を使い、 大量のコピペコードをリファクタリングする
  • Nuxtベースの「WXT」でChrome拡張を作成する

Vue.js、Nuxtの機能を使い、 大量のコピペコードをリファクタリングする

1つ目の発表は自分がプロポーザルを送り採択された発表です。一休.com、Yahoo!トラベルで発生したコピペ問題の原因と解決策の話です。

speakerdeck.com

一休.com、Yahoo!トラベルでは大量のコピペコードが発生していました。それにより何かを変更するときに作業がN倍になってしまい、開発スピードが落ちていました。 今回の発表ではこの問題の原因を紐解き解決していった話をしました。 コピペ問題といっても原因は様々です。ワークフローに問題があったり、コピペが発生せざるを得ない技術的背景があったり。それぞれに対応しています。 内容をざっくり抜き出すと、以下のようになります。

  • 開発ワークフローに問題がありUIのコピペが横行していたので、ワークフローの見直し、UIコンポーネントの作成
  • デザイン差分を小さくしてコードの統一
  • Option APIでロジックが共通化できていなかったので、Composition APIを導入して共通化、さらには責務わけのためのコンポーネント分割

詳細は発表資料をご覧ください。

Nuxtベースの「WXT」でChrome拡張を作成する

2つ目は新規プロダクト開発部の星野によるランチセッションでの発表です。「WXT」というOSSを使用し、Chrome拡張を作成してみた話です。

speakerdeck.com

何かを便利にしようとChrome拡張を使おうと思った時、自分の求めるChrome拡張が存在しなかったり、信用できない開発者のChrome拡張を使用したくないということがあります。この問題を解決するために「WXT」というOSSを使用してChrome拡張を開発した話をしました。

WXTはNuxtベースで開発できる拡張機能開発フレームワークです。Chrome拡張を開発するにはいくつか設定ファイルを作成したりChrome拡張特有の作業が必要でとても面倒なのですが、WXTを使用することでこの作業がとても簡単になります。しかもNuxtを使用している人には分かりやすいインターフェースになっており、Vue.js/Nuxtを触っている開発者にはとても開発しやすいものになっています。さらにはVue.jsだけでなくReactなど他のUIフレームワークを使用して開発することもできます。

自分でChrome拡張を作りたいという方はこの発表を参考にWXTを試してみてください。

おわりに

Vue Fes Japan 2024での一休の発表を紹介させていただきました。

Vue Fesですが、発表者、かつ一視聴者として参加してみて、すごく熱があるカンファレンスでした。参加者も多く、Vue.jsだけでなくViteの話も聞けて、さらにRolldownやoxcの話を開発者から聞けるという場は国内ではなかなかないと思います。そのような場で発表できたこと、スポンサーをできたことはとてもありがたかったです。

2025年も開催されるとのことなのでまた楽しみです。


一休では、ともに良いサービスをつくっていく仲間を募集中です。

hrmos.co

カジュアル面談も実施しているので、お気軽にご応募ください。

www.ikyu.co.jp

「一休×AEON 事業会社のサービスを支える基盤開発トーク」を開催しました

一休×AEONイベントの様子

はじめに

kymmtです。

先日2024年9月18日に、「事業会社のサービスを支える基盤開発トーク」と題してイオンスマートテクノロジー(以下AST)さんと合同で技術イベントを実施しました。

ikyu.connpass.com

イベントでは、各会社の事業を支える基盤プロダクトの開発や運用における苦労や工夫について登壇者の方々にお話しいただきました。

この記事では、このイベントの様子や発表の内容を紹介します。なお、X(旧Twitter)でも#ikyu_aeonというハッシュタグで当日の様子がご覧になれます。

会場

会場は、一休のオフィスも入っている東京ガーデンテラス紀尾井町内のLODGEでした。

当日は、一休/ASTのメンバーも合わせて計30人程度の方にご来場いただきました。また、イベント開始前に、ASTのもりはやさんからトップバリュ製品やイオンで販売中のお菓子を来場者向けに提供していただきました。

発表

『歴史あるプロダクトにマイクロサービスを導入するプロセス』

1つ目の発表は、一休の菊地さんによる『歴史あるプロダクトにマイクロサービスを導入するプロセス』でした。

一休のプロダクトを横断して利用されるマイクロサービス群(社内では一休プラットフォームと呼んでいます)を開発するチームでは、個別のプロダクトへのマイクロサービス導入も進めています。長い歴史を持ちコードベースが複雑なプロダクトにマイクロサービスを導入するには、技術課題の解決にとどまらず、積み重なった仕様を解きほぐして関係者と調整を進める必要もあります。

この発表では、そのような課題に直面した一休.comレストランへのポイント管理マイクロサービス導入の事例を紹介いただきました。発表のなかで、デッドコード削除や仕様の調整のような地道な取り組みから、プロダクトとマイクロサービスを疎結合に保ちつつ連携する仕組みを解説していただきました。

『1000万DLを超えたiAEONアプリ:完全停止を防ぐ耐障害性と信頼性の設計』

2つ目の発表は、ASTの范さんによる『1000万DLを超えたiAEONアプリ:完全停止を防ぐ耐障害性と信頼性の設計』でした。

iAEONアプリは実店舗のレジ前で会員バーコードを表示するようなユースケースを想定しており、障害が発生して利用不可になることを可能な限り避けたいという要件があります。一方で、複雑なバックエンド側の一部で発生した障害がアプリに伝播することで、アプリが利用不可になることも過去あったとのことでした。

この発表では、上述したような特性を持つiAEONアプリの耐障害性を高めるための取り組みについてお話しいただきました。とくに、デバイス上のキャッシュなどを活用してフロントエンドであるiAEONアプリ上のエラーをできるだけ局所化する手法について詳しく解説いただきました。App StoreやGoogle Play StoreにおけるiAEONアプリの評価が結果的に向上したとのことで素晴らしいと感じました。

『宿泊予約サイトにおける検索と料金計算の両立』

3つ目の発表は、一休の鍛治さんから『宿泊予約サイトにおける検索と料金計算の両立』でした。

宿泊予約サイトである一休.comやYahoo!トラベルでの料金に関する業務ルールは複雑です。また、ユーザーが宿泊したい部屋をさまざまな条件に基づいて検索するときに、その複雑な業務ルールで算出される料金の検索サーバであるSolrで計算する必要があります。

この発表では、そのような一休.comの検索要件の複雑さの解説や、要件を満たすためにSolrのプラグイン機構を活用していることについて解説いただきました。SolrのプラグインはJavaで開発することができ、Javaの機能を活用できるので、Solrクエリをメンテナブルに保ちつつ、複雑な料金計算を検索時に実行できているとのことでした。

『SRE改善サイクルはチームを超えて - ダッシュボードを眺める会の取り組み』

4つ目の発表は、ASTのもりはやさんから『SRE改善サイクルはチームを超えて - ダッシュボードを眺める会の取り組み』でした。

New Relicなどを活用したシステムメトリクスを概観できるダッシュボードは、非常時に障害の原因を探るためのものとして非常に便利です。一方で、定点観測することで、日々のシステムの状態や、障害になり得る変化を見つけることができます。

この発表では、ASTのSREチームを中心にダッシュボードを定期的に眺める会を設けることで、メトリクスの変化や改善ポイントを発見して深掘りする機会を意図的に作り出す取り組みについてお話しいただきました。システムの信頼性を高める機会を作り出すのはもちろん、会を通じて「ザイオンス効果(単純接触効果)」でチームビルディングにも寄与している点が素晴らしいと感じました。

おわりに

「事業会社のサービスを支える基盤開発トーク」の様子について紹介しました。

各社のサービスの基盤プロダクト開発について社内外のエンジニアの工夫や知見が聞ける、とてもおもしろいイベントになりました。ご来場いただいたみなさま、ありがとうございました!


一休では、ともに良いサービスをつくっていく仲間を募集中です。

hrmos.co

カジュアル面談も実施しているので、お気軽にご応募ください。

www.ikyu.co.jp

情シスカンファレンス「Business Technology Conference Japan 2024」にブロンズスポンサーとして協賛します

コーポレート本部 社内情報システム部 兼 CISO室 id:rotom です。

10/12(土) にハイブリッド形式で情シス向けのテックカンファレンス「Business Technology Conference Japan 2024(BTCONJP 2024)」が開催されます。

btcon.jp

btajp.connpass.com

昨年オンライン開催された BTCONJP 2023 では私が登壇者として 本社を東京ガーデンテラス紀尾井町へ移転し、オフィスファシリティ・コーポレートIT を刷新した話 というテーマで発表させていただきましたが、今年は core staff として運営に参画しており、所属企業である一休はブロンズスポンサーとして協賛しております。

Identity, DX & AI, Device Management, Zero Trust, Cyber Security, Business Technology の6つのテーマに沿った12のセッションや、イオン / イオンスマートテクノロジー CTO の 山﨑 賢氏による基調講演を用意しています。 詳細は以下のプレスリリースをご覧ください。

prtimes.jp

オフライン会場は一休 本社も入居する、東京ガーデンテラス紀尾井町 紀尾井タワーのLINEヤフー株式会社 本社です。

map.yahoo.co.jp

LODGE での懇親会も予定しておりますので、ぜひ現地でご参加ください!

プロンプトエンジニアリングをしよう - 一休.comでの検索システム改善事例

はじめに

こんにちは。宿泊プロダクト開発部の宮崎です。

みなさん、生成 AI 使ってますか?

近年、AI の進歩はめざましく、文章生成や画像生成はもちろん、動画生成も実用的なレベルで出来るようになっています。

ChatGPT が話題になったのが 2022 年の 11 月なので、たった 2 年足らずでここまで来ているという事実に少し恐ろしくもありますね。AGI(汎用人工知能)の実現もそう遠くないのかもしれません。

一休でも AI 技術は注目していて今年の 6 月に、まさに生成 AI を使ってホテル検索システムの改善を行いました。

この記事では、その時に学んだプロンプトエンジニアリングの重要性について書いていこうと思います。

生成 AI を使ったホテル検索システム

今回我々が実装したのはフリーワード・文章でもホテルを検索できるシステムです。

以下のようなユーザーの自由な入力に対して、適切なホテル・旅館を返したいというのが目的でした。

  • 福井でおいしいご飯が食べられる宿
  • 沖縄 子供と楽しめる宿
  • 静岡で犬と泊まれる宿

今までは、このような入力の場合、形態素解析をして検索を行っていました。しかしその場合、「子供と楽しめる」だと「子供」「楽しめる」に分割して検索するので、本来の意味「子供と楽しめる」とは少し結果が違っていました。

よって文章の入力に対しては意味を考慮して検索ワードを切り出す必要があります。

私たちが行ったのは、AI にユーザーの入力を解析させ意味毎に区切り、検索条件 area, condition に分解させるというものです。以下に具体的な例を示します。

例: ユーザーの入力:「銀座の夜景が楽しめる」

↓ これを AI で解析して分解する。

求める出力: { area:銀座, condition:[夜景が人気] }

従来は「銀座」「夜景」「楽しめる」で分解されていたのが、「銀座」「夜景が人気」に分解する。 このようにして json 形式で返してくれれば、既存のシステムで検索できます。この分解を生成 AI にやってもらおうと考えました。

実際の検索結果

具体的にどんな検索になったのか、実際にリリースしたものを使ってみましょう。

まず、行きたい施設について文章を入力し、検索ボタンを押す。

今回は「東京近郊でサウナがあって禁煙」と入力してみました。

以下の結果が表示されます。 東京近郊の都市で、サウナが人気、そして禁煙で検索されていますね。 このように、ユーザーの入力ワードをシステムで検索できるよう AI で分解するというのが今回行った改善です。

実装における課題

ただ、この仕組みを実装する上で、1 つ大きな課題を抱えていました。

それは単純な話で、コストの問題です。

当時、私たちはこの分解に GPT-4 を使っていました。 理由は他の AI と比較して GPT-4 が一番結果が良かったからです。(このプロジェクトは去年の秋ごろに始まったため、選択肢があまりなかったというのもあります。)

しかし、GPT-4 だとコストが高く、1 回の検索に 10 円以上かかることが分かりました。

どうしてそれほどコストがかかるのかというと、プロンプトが長いからです。

一休では、例えば近畿というエリアは「近畿(大阪以外)」と「大阪」という風に分けられています。 このような一休独自のエリアの分け方や、用語を AI が解釈できるようにプロンプトに詰め込んでいるので、どうしてもその分プロンプトが長くなってしまうのです。

プロンプト例:

扱うエリア名は以下です。
- 北海道
  - 札幌
  - 函館・湯の川・大沼
  - 旭川・富良野・稚内
  - ...
  - ...

<<以下、地名がずらりと後に続く…>>

解決案

GPT-4 ではコストがかかる。プロンプトを短くするのも難しい。 そこで考えたのは、GPT-3.5 Turbo を使うことでした。 これを使えば GPT-4 の 1/60 の値段になるので、コストの問題は簡単に解決できます。

ただし、これが最初からできていれば苦労はしません。

例えば「大阪でサウナと温泉が楽しめる」なら、GPT-4 は

{ area:大阪, condition:[サウナ,温泉] }

と出力されるのに対して、GPT-3.5 Turbo は以下の様に間違うことが多かったです。

  1. いくつかのキーワードが欠けている

    例:{area: 大阪,condition: [サウナ] }

  2. 入力がそのまま入ってしまっている

    例:{area: 大阪,condition: [サウナと温泉が楽しめる] }

こういった間違いに対して、最初はファインチューニングで精度を上げようとしました。 ファインチューニングは既存のモデルに追加でデータを学習させて、微調整するというものです。

参考:https://platform.openai.com/docs/guides/fine-tuning

しかし、これも上手くいきませんでした。

例えば condition の解釈精度をチューニングすると area 解釈精度が悪化したり、逆もまた然りであちらをたてればこちらがたたず状態に…。

結果的にプロンプトを 2 つに分けて area 用と condition 用、のようにしてみましたが、精度の割にはコストが 2 倍に増えてしまって断念しました。

※補足:精度の検証には新しくデータセットを作成しました。入力キーワードと、どのように分解して欲しいかの回答のセットです。これらを用いて生成された回答の一致率を測定しています。

プロンプトエンジニアリングをしよう

頼みの綱のファインチューニングもうまくいかず、頭を悩ませていた時のことです。

私は OpenAI 社のファインチューニングのドキュメントを読んでいました。チューニング精度を上げるための秘訣が書かれていればと思ったのです。

するとその時、以下の一文が目に留まりました。

Fine-tuning OpenAI text generation models can make them better for specific applications, but it requires a careful investment of time and effort. We recommend first attempting to get good results with prompt engineering, prompt chaining (breaking complex tasks into multiple prompts), and function calling,

引用:Fine-tuning (https://platform.openai.com/docs/guides/fine-tuning/fine-tuning)

簡単に言うと、ファインチューニングの前にまずはプロンプトエンジニアリングやプロンプトチェイニング(タスクを複数に分割する)をしましょう、ということです。

私はこの時まで、プロンプトエンジニアリングを軽視していました。プロンプトの修正で出力が改善されれば苦労しないだろうと。

ですが、このドキュメントを見ながら自分の今のプロンプトを見返すと、推奨されている書き方をほとんどしていないことが分かりました。

よって、半信半疑でありながらも、とりあえずドキュメントに従ってプロンプトエンジニアリングを行いました。

すると驚くべきことに、GPT-3.5 Turbo でも GPT-4 と同等の精度を出すことができたのです。

難しいことは何もしていません。ただ、プロンプトを修正しただけです。

それだけでコストが 1/60 になり、今年の 6 月にこの機能をリリースすることができました。 まず最初にやるべきはプロンプトエンジニアリングだったというわけです。

※補足:現在は GPT-3.5 Turbo ではなく、 GPT-4o mini を使っています。

プロンプトエンジニアリングについて

ここからは具体的にどういったプロンプトをどう修正したのかについて書きます。

OpenAI のプロンプトエンジニアリングのガイドラインを参考に以下の修正を行いました。 参考:https://platform.openai.com/docs/guides/prompt-engineering

はっきりと簡潔な指示を出す


簡潔な言葉で何をしてほしいかを指示しました。

★ 修正前のプロンプト

現在、日本のホテルを検索するシステムを開発しています。システムは以下の機能を持っています。

<<システムの説明 ※ここでは省略。実際は 50 行くらい使ってホテルや宿泊条件など、機能の詳細な説明をしていた。>>

ここで、以下のユーザーのホテル検索クエリに対して、上記の機能を使って絞り込むのが適切だと判断したのなら、その条件を教えてください。

★ 修正後のプロンプト

あなたは優秀なクエリ分析システムです。
ユーザーのホテル検索クエリが与えられますので、それを分析して、どんな条件で検索すればいいのかをjson形式で回答してください。回答には、三重引用符で提供されたデータのみを使って答えてください。

このヒントは以下に書かれていました。 https://platform.openai.com/docs/guides/prompt-engineering/tactic-include-details-in-your-query-to-get-more-relevant-answers

ステップバイステップで考えさせる


以前は 1 つの指示+複数のルールで指示していましたが、これをステップバイステップの指示に切り替えました。

★ 修正前のプロンプト

ここで、以下のユーザーのホテル検索クエリに対して、上記の機能を使って絞り込むのが適切だと判断したのなら、その条件を教えてください。
その際、以下のルールを守ってください。

1. JSON形式で教えてください。
2. たとえば、ユーザーのホテル検索クエリ「ペットと泊まれる 新潟」の場合、
  {area: ["新潟"], condition: ["ペット"]}
  というJSONを期待します。
3. ...
4. ...

★ 修正後のプロンプト

以下の思考フローを用いて、ステップバイステップで絞り込んでいくことを想定してください。
ステップ1:ユーザーのクエリを確認し、エリアに該当するものを抽出してください。
ステップ2:ステップ1で抽出したワードを出力のareaのキーに追加してください。
ステップ3:...
ステップ4:...

このヒントは以下に書かれていました。 https://platform.openai.com/docs/guides/prompt-engineering/tactic-specify-the-steps-required-to-complete-a-task

セクションを区別する


エリア名称や、用語一覧を渡しているのですが、ここを引用符などで区切りました。

★ 修正前のプロンプト

以下にシステムで扱える用語一覧を示します。
* サウナ
* 禁煙
* 喫煙
* 室内プール
* ...
* ...

★ 修正後のプロンプト

以下にシステムで扱える用語一覧を示します。
"""
* サウナ
* 禁煙
* 喫煙
* 室内プール
* ...
* ...
"""

このヒントは以下に書かれていました。 https://platform.openai.com/docs/guides/prompt-engineering/tactic-use-delimiters-to-clearly-indicate-distinct-parts-of-the-input

例を付ける


出力の例を付けました。上述したように、以前はルールを使って回答を制御していて、1 つか 2 つしか例を付けていませんでした。

それを以下の様に例を増やしました。

★ 修正後のプロンプト

以下は、出力のjsonの例です。

例1:「絶景と温泉」というユーザーのクエリの場合、以下の条件を出力してください。
{ condition : ["絶景","温泉"] }

例2:「浜松でうなぎ料理を楽しめる旅館」というユーザーのクエリの場合、以下の条件を出力してください。
{ area:["浜松"] condition: ["うなぎ料理"] }

例3:...

例4:...

このヒントは以下に書かれていました。 https://platform.openai.com/docs/guides/prompt-engineering/tactic-provide-examples

以上がメインとなるプロンプトの修正です。これらは本当にガイドラインに従って足りていないものを書き換えただけです。

それだけで約 35% の精度改善となったので、同じような悩みを抱えている方はぜひ試していただきたいです。

おわりに

今回、プロンプトエンジニアリングの効果を十分に理解することができました。

ただ、考えてみれば、人が人に仕事を任せる時は当然相手の立場やスキルを考えて指示を出します。 これをどうして AI でやらないのか。 AI も AI の理解しやすい命令の構造があり、それをプロンプトエンジニアリングで最適化する。

人の気持ちを考えるように AI の気持ちを考える。

いい仕事をするためには人も、 AI も関係なく、相手を思い遣る心が大切なのだと思いました。