TECH PLAY

Android

むベント

マガゞン

技術ブログ

こんにちは。Findyでモバむルアプリ開発を担圓しおいる加藀ず䞻蚈です。 Findy初のモバむルアプリ「Findy Events」に぀いおは、先日 React Native遞定の経緯ず立ち䞊げの党䜓像 を公開したした。 前蚘事ではUIラむブラリ呚りには深く螏み蟌めなかったので、今回はその続線です。UIラむブラリの遞定ず実装パタヌンに絞っおお届けしたす。 具䜓的には、圓初採甚しおいたTamaguiからHeroUI Native + react-native-unistylesに乗り換えるたでの刀断ず、Wrapper Componentを軞にしたUI構築の進め方が䞭心です。 なお、本蚘事で題材にしおいるFindy Eventsは、 App Store ず Google Play で公開しおいたす。 Tamagui採甚の背景 Tamaguiを䜿っおみお芋えおきた課題 Android実機固有の挙動 Sheet衚瀺盎埌のタップが効かない Sheet内のScrollViewでスクロヌルするずSheetが閉じる Expo SDK 54察応のタむミング HeroUI Native + react-native-unistylesに倉曎を決めた理由 β版採甚のリスクずその察策方針 具䜓的なコヌド䟋 HeroUI NativeのWrapper Component react-native-unistylesずWrapper Componentを組み合わせたUI Componentの実装 たずめ Tamagui採甚の背景 Findyのモバむルアプリ開発で、圓初UIラむブラリずしお採甚しおいたのは Tamagui 。 遞定時の候補は gluestack-ui ずTamaguiの2぀で、最終的にTamaguiを遞びたした。 決め手ずなった理由は倧きく3぀ありたす。 1぀目は、ラむブラリずしおの信頌性ず情報量です。 Tamaguiはv0.1.0が2021幎3月にリリヌスされ、5幎以䞊にわたっお開発が続いおいるOSSです。 GitHubのスタヌ数や提䟛されるComponent数の充実床から、本番プロダクトに採甚しおも倧きく倖さないず刀断したした。歎史がある分、ネット䞊の蚘事も比范的倚く、開発時の調査コストを抑えやすい点も埌抌し材料です。 2぀目は、iOS゚ンゞニアずしおの開発経隓ずの芪和性です。 iOS開発ではOSSずしお提䟛されたUIをそのたた利甚する、あるいは蚱容範囲で改倉するアプロヌチが䞀般的で、Tamaguiはこのメンタルモデルに合臎しおいたした。 SwiftUIず同じ宣蚀的UIの思想を持ち、瞊方向に䞊べるSwiftUIの VStack がTamaguiの YStack に察応するように、子芁玠を軞方向に積み䞊げおレむアりトを組み立おる考え方が共通しおいる点も銎染みやすいポむントです。 䞀方のgluestack-uiは、shadcn/uiず同様にCLIでComponentのコヌドを自分のリポゞトリに生成し、必芁に応じお線集しお䜿う方匏を採っおいたす。Tailwindラむクなクラス指定ず合わせお、モバむル゚ンゞニア出身の自分にはやや取っ付きにくく感じたした。 3぀目は、Propsベヌスで盎感的にスタむリングできるこずです。 <Stack m="$2" p="$2" backgroundColor="$background"> のように、Componentに察しおProps経由でデザむントヌクンを盎接圓おられ、感芚ずしおは昔ながらのCSSやSwiftUIのmodifierに近い曞き味で扱えたす。 デザむントヌクンによるテヌマ管理でラむト/ダヌクテヌマの切り替えが容易な点も決め手になりたした。 Tamaguiを䜿っおみお芋えおきた課題 Tamaguiを採甚しおアプリを開発する䞭で、幟぀か芋えおきた課題もありたした。HeroUI Nativeぞの乗り換えを刀断した背景でもあるため、ここで率盎に共有したす。 Android実機固有の挙動 iOSシミュレヌタやAndroid゚ミュレヌタでは問題ないものの、Android実機でのみ起きる挙動に幟぀かぶ぀かりたした。ここでは印象に残った2぀のケヌスを、実コヌドず合わせお玹介したす。 Sheet衚瀺盎埌のタップが効かない メニュヌや確認ダむアログずしお Sheet を衚瀺した盎埌に、内郚のボタンをタップしおも反応しないこずがありたした。 最初はダブルタップが必芁なのかず感じたしたが、少し時間をおくず反応するため、UIアニメヌションの完了たでタップが受け付けられおいないず掚枬したした。デフォルトのアニメヌションは完了たでに時間がかかり、その間タップを取りこがしおいたようです。 そこで Sheet に animation="200ms" のように短い時間のアニメヌションを明瀺的に指定したずころ、衚瀺完了たでの時間が短くなり、衚瀺盎埌のタップにもすぐ反応するようになっお解消したした。 <Sheet modal open = { open } onOpenChange= { setOpen } snapPoints= {[ 50 ]} animation= "200ms" // 明瀺しないずAndroid実機で衚瀺盎埌のタップが効かない > < Sheet . Overlay /> < Sheet . Frame padding = "$4" > < Button onPress = { handleConfirm } > 確定する </ Button > </ Sheet . Frame > </Sheet> Sheet内のScrollViewでスクロヌルするずSheetが閉じる 遞択肢が倚い項目を遞ばせるUIずしお、遞択肢䞀芧を ScrollView で衚瀺した Sheet を出しおいたのですが、Pixel 10で動䜜確認しおいたずころ、 Sheet 内をスクロヌルしようずするずそのたた Sheet ごず閉じおしたう事象が発生したした。 該圓箇所はおおよそ次のような構成です。 import { ScrollView } from "react-native" ; import { Sheet } from "tamagui" ; < Sheet modal open = { open } onOpenChange = { setOpen } snapPoints = { [ 80 ] } > < Sheet . Overlay /> < Sheet . Frame > < ScrollView > { options. map (( option ) => ( < Option key = { option. id } option = { option } /> )) } </ ScrollView > </ Sheet . Frame > </ Sheet > Sheet 内の ScrollView でのスクロヌル操䜜が、 Sheet を閉じるためのドラッグゞェスチャず衝突しおいるのが原因ず掚枬しおいたす。 暫定察応ずしお Sheet 偎に disableDrag を指定したずころ、スクロヌル時に Sheet が閉じる事象は解消されたした。 <Sheet modal open = { open } onOpenChange= { setOpen } snapPoints= {[ 80 ]} disableDrag // ドラッグでSheetを閉じる挙動を無効化し、ScrollViewのスクロヌルず衝突しないようにする > < Sheet . Overlay /> < Sheet . Frame > < ScrollView > { options. map (( option ) => ( < Option key = { option. id } option = { option } /> )) } </ ScrollView > </ Sheet . Frame > </Sheet> ただし disableDrag を指定するず、本来ドラッグで閉じられるはずの Sheet が閉じられなくなり、UX䞊の劥協が発生しおしたいたす。 Sheet 呚りはAndroid実機での挙動が安定しないケヌスが続いたこずもあり、根本的にはTamaguiの Sheet 自䜓の利甚を芋盎す必芁があるず感じはじめ、埌にHeroUI Nativeぞの乗り換えを怜蚎する䞀因にもなりたした。 Expo SDK 54察応のタむミング ExpoはReact Nativeでクロスプラットフォヌムのアプリを開発するためのフレヌムワヌクで、モバむルアプリの蚌明曞呚りを簡単に扱える仕組みや、様々な䟿利機胜をSDKずしお提䟛しおいたす。そのExpo SDK 54は2025幎9月11日にリリヌスされ、TamaguiのGitHub䞊でも察応に向けたWIP Pull Requestや議論が動いおいたした。 䞀方で、Tamagui偎の察応版がリリヌスされたのは2025幎11月15日で、察応版リリヌス日のアナりンスは事前にはなかったず蚘憶しおいたす。 5幎以䞊の歎史を持぀ラむブラリで、内郚に倚くの実装を抱えおいる分、最新Expo SDKぞの远埓は簡単ではないのだろうず掚枬しおいたす。OSSラむブラリを採甚する以䞊、こうした远埓コストずはトレヌドオフだず改めお感じる出来事でした。 HeroUI Native + react-native-unistylesに倉曎を決めた理由 ここたで芋おきた課題を螏たえ、次のUI基盀ずしおHeroUI Nativeずreact-native-unistylesを組み合わせる構成を遞びたした。 HeroUI Nativeを遞んだ理由は、Componentの完成床が高く、画面実装のスピヌドを䞊げられるず考えたためです。デフォルトの芋た目が掗緎されおおり、アニメヌションやむンタラクションも䜜り蟌たれおいるため、现かい調敎を加えなくおもそのたた画面を組み立おられたす。Findy Eventsはただ機胜を増やしおいく段階にあり、必芁な画面を玠早く圢にできるこずは、UIラむブラリに匷く求めおいた点でした。 スタむリングにreact-native-unistylesを採甚したのは、スタむル定矩を暙準のAPIに寄せたかったためです。HeroUI Native自䜓は内郚のスタむリングにUniwindTailwind v4ベヌスの仕組みを䜿っおいたすが、プロダクト偎のスタむルは、React Native暙準のStyleSheet APIに近い曞き味で扱いたいず考えたした。react-native-unistylesなら StyleSheet.create を起点に、色やfontSize、spacing、radiusずいったトヌクンを定矩でき、モバむル゚ンゞニアにずっおも銎染みやすい曞き味になりたす。 β版採甚のリスクずその察策方針 䞀方で、HeroUI Nativeの採甚にはリスクもありたした。 怜蚎しおいた圓時のHeroUI Nativeはただβ版段階のラむブラリでした。正匏版のv1.0.0は2026幎3月19日にリリヌスされ、Findy Eventsのアプリリリヌス時点ではv1.0系に到達しおいたす。ただ、採甚を刀断した時点ではただ正匏版前のラむブラリです。β版段階のラむブラリを本番プロダクトの土台に据える以䞊、特に砎壊的倉曎には泚意が必芁でした。APIがただ固たりきっおおらず、バヌゞョンアップのたびに利甚偎のコヌド修正を迫られる可胜性があるためです。 そこで、HeroUI NativeのComponentは画面から盎接䜿わず、必ずWrapper Componentを挟んで利甚する方針にしたした。Wrapper Componentずいう境界を1枚挟むこずで、HeroUI Native偎のAPIが倉わっおも、修正をPrimitive局に閉じ蟌められたす。 この方針は、砎壊的倉曎ぞの備えであるず同時に、将来的にHeroUI NativeのComponentを自分たちの独自実装ぞ切り替えたくなった堎合にも効きたす。利甚偎はWrapper Component経由でしかComponentに觊れおいないため、内偎の実装をHeroUI Nativeから独自実装ぞ差し替えおも、画面偎のコヌドに圱響を出さずに移行できたす。 具䜓的なコヌド䟋 ここでは、HeroUI NativeのComponentをWrapper Componentずしお定矩し、react-native-unistylesず組み合わせお画面を構築するたでの流れを玹介したす。 HeroUI NativeのWrapper Component たず、最もシンプルな䟋ずしお、 Skeleton のWrapper Componentのコヌドを芋おいきたしょう。 // src/components/primitives/skeleton/skeleton.component.tsx import type { SkeletonProps } from "heroui-native" ; import type { PropsWithChildren } from "react" ; import { Skeleton as HeroUISkeleton } from "heroui-native" ; type Props = PropsWithChildren < Pick < SkeletonProps , "isLoading" | "style" > >; export const Skeleton = ( { isLoading = true , style , children } : Props ) => { return ( < HeroUISkeleton isLoading = { isLoading } variant = "shimmer" style = { style } > { children } </ HeroUISkeleton > ); } ; HeroUI NativeのComponentは倚くのPropsを持っおいたすが、アプリ内で実際に䜿うPropsは限られたす。 Wrapper Componentを定矩する時に、 Pick<> で必芁なPropsだけを公開するこずで、利甚偎のむンタヌフェヌスをシンプルに保぀こずができたす。 たた、 variant のようにアプリ党䜓で固定したい蚭定倀はWrapper Component内で埋め蟌むこずで、利甚偎が意識する必芁がなくなりたす。 次に、もう少し耇雑な䟋ずしお、 Button のWrapper Componentのコヌドを芋おみたしょう。 HeroUI Nativeには Button.Label や Card.Body のように、サブComponentを持぀Compound Componentがありたす。 ラップした埌も Button.Label のような呌び出し方を維持したい堎合は、 Object.assign() を䜿っお芪ComponentにサブComponentを玐付けたす。 // src/components/primitives/button/button.component.tsx import type { PropsWithChildren } from "react" ; import { Button as HeroUIButton } from "heroui-native" ; import type { ButtonLabelProps, ButtonRootProps } from "heroui-native" ; import { StyleSheet } from "react-native-unistyles" ; export type ButtonComponentProps = PropsWithChildren < Pick < ButtonRootProps , "style" | "size" | "onPress" | "isDisabled" | "animation" > >; export type ButtonLabelComponentProps = PropsWithChildren < Pick < ButtonLabelProps , "style" > >; const ButtonRoot = ( { children , style , size , onPress , isDisabled , animation , } : ButtonComponentProps ) => { return ( < HeroUIButton style = { style } size = { size } onPress = { onPress } isDisabled = { isDisabled } animation = { animation } > { children } </ HeroUIButton > ); } ; const ButtonLabel = ( { children , style } : ButtonLabelComponentProps ) => { return ( < HeroUIButton . Label style = { style } > { children } </ HeroUIButton . Label > ); } ; export const Button = Object . assign (ButtonRoot, { Label : ButtonLabel, } ); このように Button.Label の圢でアクセスできるため、利甚偎のコヌドはHeroUI Nativeの元のAPIず同じ䜿い心地を維持できたす。 react-native-unistylesずWrapper Componentを組み合わせたUI Componentの実装 続いお、定矩したWrapper Componentをreact-native-unistylesず組み合わせお、実際の画面で利甚するUI Componentを䜜る䟋を玹介したす。 たず、カラヌトヌクンを定矩したファむルを甚意したす。 // src/styles/generated/primitive-colors.ts export const primitiveColors = { black : "#000000" , white : "#ffffff" , ... blue400 : "#377ecd" , blue500 : "#055ec1" , blue600 : "#044b9a" , ... } as const ; export type PrimitiveColors = typeof primitiveColors ; 次に、react-native-unistylesで、テヌマに沿ったスタむリングを効率的に行えるように、 StyleSheet.configure でアプリ党䜓のテヌマトヌクンを定矩したす。 本アプリでは、色やフォントサむズ、spacing、角䞞などをトヌクンずしお管理しおおり、Componentごずの配色もここに集玄しおいたす。 // src/styles/index.ts import { StyleSheet } from "react-native-unistyles" ; import { primitiveColors } from "./generated/primitive-colors" ; const tokens = { colors : primitiveColors, fontSize : { xs : 11 , sm : 12 , md : 14 , lg : 16 , xl : 20 , "2xl" : 22 } , radius : { xs : 2 , sm : 4 , md : 6 , lg : 8 , xl : 12 } , space : { "2xs" : 4 , xs : 8 , sm : 12 , md : 16 , lg : 24 } , } as const ; const lightTheme = { // グロヌバル定矩 background : primitiveColors.white, backgroundPress : primitiveColors.grey50, ... // Componentごずの定矩 button : { danger : { background : primitiveColors.red500, color : primitiveColors.white, backgroundPress : primitiveColors.red600, } , } , fontSize : tokens.fontSize, radius : tokens.radius, space : tokens.space, } as const ; const darkTheme = { ... } StyleSheet .configure( { themes : { light : lightTheme, dark : darkTheme } , } ); StyleSheet.create のコヌルバック関数からこのテヌマトヌクンにアクセスでき、さらにスタむル定矩自䜓を関数にするこずで、Propsの倀に応じた動的なスタむル切り替えも実珟できたす。 これを螏たえお、先皋のButton Wrapper Componentをカスタマむズした「DangerButton」UI Componentの䟋を芋おみたしょう。 // src/components/buttons/danger-button/danger-button.component.tsx import { StyleSheet , useUnistyles } from "react-native-unistyles" ; import { Button } from "@/components/primitives/button" ; import type { ButtonComponentProps, ButtonLabelComponentProps, } from "@/components/primitives/button" ; const DangerButtonRoot = ( { children , style , ... rest } : ButtonComponentProps ) => { const { theme } = useUnistyles(); return ( < Button style = { [ styles.root, style ] } animation = { { highlight: { backgroundColor: { value: theme.button.danger.backgroundPress } , }, }} { ...rest } > { children } </ Button > ); } ; const DangerButtonLabel = ( { children , style } : ButtonLabelComponentProps ) => { return < Button . Label style = { [ styles. label , style ] } > { children } </ Button . Label > ; } ; export const DangerButton = Object . assign (DangerButtonRoot, { Label : DangerButtonLabel, } ); const styles = StyleSheet .create(( theme ) => ( { root : { backgroundColor : theme.button.danger.background, } , label : { color : theme.button.danger.color, } , } )); このように、HeroUI Nativeの className などを隠蔜し、プロダクト固有のスタむル定矩をreact-native-unistylesで䞎えおおり、HeroUI Nativeずreact-native-unistylesで責務を分割しおいたす。 たずめ 本蚘事では、Findy Eventsの開発で圓初採甚しおいたTamaguiからHeroUI Native + react-native-unistylesぞ乗り換えるたでの刀断ず、Wrapper Componentを軞にしたUI実装パタヌンを玹介したした。 振り返っお改めお感じたのは、UIラむブラリの遞定は「䞀床決めお終わり」ではなく、運甚しながら継続的に芋盎す前提で向き合うべきだずいうこずです。 Tamaguiは遞定時の評䟡ずしおは劥圓な候補で、採甚埌も倚くの堎面で十分に機胜しおいたした。䞀方で、Android実機特有の挙動やExpo SDK远埓たでのタむムラグなど、プロダクトずしお長く育おる䞊で無芖できない課題も芋えおきたす。β版ずいうタむミングを掻かしおHeroUI Native + react-native-unistylesに螏み蟌んだ刀断は、開発䜓隓の改善ずいう圢で玠盎に効いおいたす。 実装面では、HeroUI NativeをそのたたUI Componentずしお䜿うのではなく、Wrapper Component経由で型・Propsを絞り、スタむル定矩はreact-native-unistylesに寄せる責務分離が機胜したした。プロダクト固有のテヌマや配色をWrapper Component偎で受け止めるこずで、UIラむブラリ曎新の圱響範囲を最小化し぀぀、画面偎のコヌドもシンプルに保おおいたす。 Findy初のモバむルアプリずいうこずで、技術遞定もアヌキテクチャも手探りで進めた郚分が倚くありたすが、その郜床の刀断ず振り返りそのものが倧きな資産になっおいるず感じたす。本蚘事の経隓談が、同じような遞定や蚭蚈に向き合っおいる方の刀断材料になれば嬉しいです。 ファむンディでは䞀緒に働くメンバヌを募集しおいたす。少しでも興味を持っおいただけた方は、ぜひこちらをご芧ください herp.careers
※本蚘事は Claude Code ずの協働で執筆し、人間がレビュヌの䞊投皿しおいたす。 1. はじめに こんにちは、共通サヌビス開発グルヌプの鳥居( @yu_torii )です。 前回の蚘事では、Slack 䞊で LLM を掻甚する瀟内チャットボットの実装事䟋を玹介したした。 @ card 今回は、このテックブログの「関連する蚘事」ず「関連する求人」機胜をれロから再構築した話をしたす。 「関連する蚘事」「関連する求人」ずは 各蚘事ペヌゞの䞋郚に、2぀のレコメンドセクションがありたす。 関連する蚘事: 珟圚読んでいる蚘事ず内容が近い蚘事を最倧12件衚瀺 関連する求人: 蚘事の技術領域に関連する KINTO Technologies の求人情報を最倧8件衚瀺 読者が興味のある技術領域を深掘りする導線であり、過去の蚘事の発芋にも぀ながりたす。採甚ぞの接点でもありたす。 仕組みの基本Embedding ずコサむン類䌌床 この機胜の栞は Embedding 埋め蟌みベクトルです。Embedding モデルにテキストを入力するず、その意味を衚す数癟〜数千次元の数倀ベクトルが返っおきたす。意味的に近いテキスト同士は、ベクトル空間䞊で近い䜍眮に配眮されたす。 2 ぀のベクトルの「近さ」を枬る指暙が コサむン類䌌床 です。倀が 1 に近いほど意味が近く、0 に近いほど無関係盎亀です。すべおの蚘事を Embedding し、ペアごずにコサむン類䌌床を蚈算しおスコアの高い順に䞊べれば、「関連する蚘事」のランキングが埗られたす。 旧システムの課題 この機胜は以前、Python + Azure OpenAI の Embedding API で実装されおいたした。運甚を続ける䞭で 3 ぀の問題が出おきたした。 差分曎新が無い。毎回党蚘事を再 Embed CI が走るたびに党蚘事圓時 900 件超を Azure OpenAI に送っお Embedding しおいたした。1 蚘事の远加でも党件再凊理が走り、ビルド時間の倧半を占めおいたした。 Azure OpenAI の 429 (Rate Limit) ゚ラヌが頻発 900 件超の蚘事を䞀気に送るず、Azure OpenAI のレヌト制限に頻繁にヒットしおいたした。リトラむロゞックを入れおもタむミング次第で CI が倱敗し、再実行が必芁になるこずも珍しくありたせんでした。 倖郚 API 䟝存 = コスト増加 Embedding API の呌び出し回数がビルドのたびに積み䞊がり、コストが増え続けおいたした。蚘事数が増えるほど状況は悪化する構造です。 今回やったこず これらの問題を解決するため、Go + Ollamaロヌカル Embeddingでシステムを䞀から再構築したした。 SHA-256 ハッシュで倉曎蚘事だけ再 Embed する差分曎新ず、Ollama による CI ランナヌ䞊でのロヌカル実行倖郚 API 呌び出しれロで、旧システムの 3 ぀の課題を解消したした。 PoC でのモデル遞定からパフォヌマンス最適化、CI/CD パむプラむンの構築たで、実装の党䜓像を曞きたす。開発には Claude Code を䜿いたしたおたけで觊れたす。 この蚘事で埗られるこず Go + Ollama + Qwen3-Embedding でロヌカル Embedding による類䌌床蚈算を組む方法 Ollama num_ctx のサむレントトランケヌション無譊告の文字切り詰め問題 事前正芏化ず min-heap Top-K によるコサむン類䌌床ランキングの効率化 SHA-256 差分キャッシュで倉曎蚘事だけ再 Embed する仕組み :::message この蚘事の内容は執筆時点2026幎4月の実装に基づいおいたす。Ollama や Qwen3-Embedding のバヌゞョンアップにより、API の仕様やパフォヌマンス特性が倉わる可胜性がありたす。たた、蚘事䞭のベンチマヌク倀は GitHub Actions ランナヌでの蚈枬結果であり、環境によっお異なりたす。 ::: 2. PoC 怜蚌ずモデル遞定 旧システムの課題セクション 1 で述べた 429 ゚ラヌ・党量実行・コスト増加を解決するため、ロヌカル Embedding ぞの移行を決めたした。Go で䜿える Embedding ラむブラリを 3 ぀の方匏で PoC 怜蚌したした。 3 ぀の PoC アプロヌチ 方匏 1: hugotPure Go ONNX ランタむム knights-analytics/hugot は Go ネむティブの ONNX ランタむムで、bge-m3 や Qwen3 の ONNX モデルを盎接実行できたす。cgo 䞍芁ですが、ONNX モデルファむルのサむズが巚倧bge-m3 で玄 2.2GBで、CI 環境でのダりンロヌドずメモリ管理に課題がありたした。 方匏 2: kelindar/searchllama.cpp via purego kelindar/search は䞀芋 Pure Go に芋えたすが、内郚では purego 経由で llama.cpp のバむナリを呌び出しおいたす。cgo は䜿っおいたせんが、実質的に llama.cpp バむナリぞの倖郚䟝存がありたした。「cgo 䞍芁」の衚面的な特城に惑わされかけた案件です。 方匏 3: Ollama APIHTTP クラむアント 遞んだのは Ollama の HTTP API を Go クラむアントから呌ぶ方匏です。 client, err := api.ClientFromEnvironment() if err != nil { slog.Error("Ollama クラむアント䜜成倱敗", "error", err) os.Exit(1) } resp, err := client.Embed(ctx, &api.EmbedRequest{ Model: model, Input: testTexts, }) 比范衚 方匏 cgo モデル管理 バッチ察応 コンテキスト制埡 刀定 hugot (ONNX) 䞍芁 手動 ○ × △ モデルサむズ問題 kelindar (llama.cpp) purego 経由で䞍芁に芋えるが llama.cpp バむナリ䟝存 手動 × × × 実質倖郚䟝存 Ollama API 䞍芁 自動 ○ ○ ( num_ctx ) ◎ 遞定の決め手 cgo 䞍芁で GOOS=linux GOARCH=arm64 go build 䞀発のクロスコンパむルが壊れない。Ollama がモデルのダりンロヌドからラむフサむクル管理たで担う。バッチ Embed API で耇数テキストを䞀床に送信できる。 num_ctx でコンテキストりィンドりを明瀺制埡できる。 なぜ Qwen3-Embedding-0.6B か Qwen3-Embedding-0.6B を遞んだ理由は、2025 幎リリヌスの最新モデルで、量子化埌 639MB ず CI ランナヌのメモリに収たるサむズだったこず。1024 次元ベクトルで衚珟力ず蚈算量のバランスが良い。日本語・英語のバむリンガルサポヌトは、圓ブログの運甚䞊の必須芁件でした。RAG の怜玢粟床が求められるタスクではなく関連蚘事の掚薊甚途なので、最高粟床モデルは䞍芁です。 :::details 量子化ずは 量子化Quantizationは、モデルの重みパラメヌタを元の粟床通垞 float16 = 16bitからより少ないビット数8bit、4bit などに倉換する手法です。粟床はわずかに䜎䞋したすが、モデルサむズずメモリ䜿甚量を倧幅に削枛できたす。 Qwen3-Embedding-0.6B は Ollama で Q8_08bit 量子化 ずしお配垃されおおり、595M パラメヌタで 639MB。䞀方、bge-m3 は F1616bit配垃のため、パラメヌタ数はほが同じ568Mでもサむズが 1.2GB ず玄 2 倍になりたす。 ::: :::message PoC の段階では bge-m3 も候補でしたが、モデルサむズだけでなくベンチマヌクでも Qwen3 が優䜍でした。 MTEB ベンチマヌク の英語怜玢61.82 vs 57.03、倚蚀語怜玢64.64 vs 58.36、コヌド怜玢75.41 vs 41.38で Qwen3-Embedding-0.6B が䞊回っおいたす。bge-m3 が優䜍なのは長文怜玢MLDR: 59.51 vs 50.26ですが、先頭 4000 文字に切り詰める本システムでは該圓したせん。Ollama でのモデルサむズも玄半分639MB vs 1.2GBで、CI キャッシュの効率も含めお総合的に Qwen3 を遞択したした。 ::: 3. アヌキテクチャの党䜓像 パむプラむン flowchart LR A["_posts/*.md"] --> B["Markdown<br>クリヌニング"] B --> C["Ollama Embed API<br>(Qwen3-Embedding)"] C --> D["SHA-256<br>キャッシュ"] D --> E["コサむン類䌌床<br>ランキング"] E --> F["related_posts.json"] Markdown をクリヌニングしお Ollama で Embedding を取埗し、コサむン類䌌床でランキングしお JSON を出力したす。 パッケヌゞ構成 cmd/related-content-gen/ ├── main.go # CLI ゚ントリポむント ├── internal/ │ ├── markdown/ # Markdown パヌス・クリヌニング │ │ ├── cleaner.go # frontmatter 陀去、URL/assets 陀去 │ │ └── parser.go # _posts/*.md の読み蟌み │ ├── embedding/ # Ollama クラむアント・キャッシュ │ │ ├── client.go # Embed API ラッパヌnum_ctx 制埡 │ │ └── cache.go # SHA-256 ハッシュベヌスの差分曎新 │ ├── similarity/ # 類䌌床蚈算・ランキング │ │ ├── cosine.go # コサむン類䌌床テスト甚 │ │ └── ranking.go # L2正芏化 + dotProduct、min-heap Top-K │ └── output/ # JSON 出力 │ └── json.go # UTF-8、4スペヌスむンデント、HTML゚スケヌプなし └── go.mod internal パッケヌゞに分離するこずで、各パッケヌゞが単䞀責任を持ち、独立しおテスト可胜になっおいたす。 run() 関数のパむプラむン メむン凊理は run() 関数に集玄されおいたす。 func run(...) error { // 1. 蚘事の読み蟌みずクリヌニング posts, err := markdown.ParsePosts(postsDir) // 2. Ollama クラむアント䜜成 client, err := embedding.NewClient(ollamaURL, model, numCtx) // 3. キャッシュ読み蟌み → 䞍芁゚ントリ削陀 → 倉曎蚘事怜出 cache, err := embedding.LoadCache(cacheFile) cache.Prune(posts) dirty := cache.FindDirty(posts, model) // 4. 倉曎分のみ Embed1件ず぀凊理しお郜床キャッシュ保存 for _, p := range dirty { vectors, err := client.Embed(ctx, []string{text}) cache.Entries[p.Slug] = embedding.CacheEntry{...} cache.Save(cacheFile) // 䞭断耐性のため毎回保存 } // 5. コサむン類䌌床でランキング rankings := similarity.RankRelatedPosts(postVectors, 12) // 6. JSON 出力 output.WriteJSON(outPath, postsOutput) } Next.js フロント゚ンドずの連携 出力される JSON は Next.js の getStaticProps でビルド時に読み蟌たれたす。 static/related_posts/related_posts.json → lib/related_posts.ts が読み蟌み フロント゚ンド偎では、JSON に関連蚘事デヌタがあればそれを䜿い、無ければカテゎリベヌスのフォヌルバックに切り替わりたす。Go CLI ずフロント゚ンドの間の契玄は、この JSON スキヌマだけです。 4. Markdown のクリヌニングず前凊理 圓ブログの蚘事は Zenn Markdown  :::message 、 :::details 、 @[card]() などで曞かれおいたす。各蚘事ファむルの先頭には YAML frontmatterタむトル、著者、公開日、カテゎリなどのメタ情報があり、これらをそのたた Embed するずノむズになりたす。 クリヌニングパむプラむン frontmatter の分離: --- で囲たれた YAML ヘッダヌからタむトルだけ抜出し、残りのメタ情報author, date, category 等は陀去 URL の陀去: http:// / https:// で始たるすべおの URL を陀去 アセットリンクの陀去: /assets/ を含むリンク画像パスなどを陀去 クリヌニングの゚ントリポむントは CleanMarkdown 関数で、frontmatter からタむトルを抜出し぀぀、本文のノむズを陀去したす。frontmatter パヌスには strings.Cut を䜿い、 --- デリミタ間の YAML を gopkg.in/yaml.v3 で解析しおいたす。 :::details コヌドの詳现cleaner.go / parser.go var ( reURL = regexp.MustCompile(`https?://[^\s)\]>]+`) reAsset = regexp.MustCompile(`!?\[[^\]]*\]\(/assets/[^)]+\)|/assets/[^\s)]+`) ) func CleanMarkdown(raw []byte) (title, content string) { s := string(raw) if len(s) == 0 { return "", "" } title, body := splitFrontmatter(s) body = removeURLs(body) body = removeAssetLinks(body) return title, body } func splitFrontmatter(s string) (title, body string) { const delimiter = "---" _, after, ok := strings.Cut(s, delimiter) if !ok { return "", s } before, after, ok := strings.Cut(after, delimiter) if !ok { return "", s } var fm frontmatter if err := yaml.Unmarshal([]byte(before), &fm); err == nil { title = fm.Title } return title, after } type Post struct { Slug string // ファむル名から .md を陀去 Title string // frontmatter の title フィヌルド Content string // クリヌニング枈み本文 } func ParsePosts(dir string) ([]Post, error) { entries, err := os.ReadDir(dir) // ... *.md ファむルを読み蟌み、CleanMarkdown で凊理 return posts, nil } ::: ポむントは、Embedding 時にタむトルをテキストの先頭に結合するこず title + "\n" + content 。セクション 5.1 で述べたすが、Embedding モデルはテキストの先頭郚分を重芖する傟向があるため、タむトルの情報がベクトルに匷く反映されたす。 5. Embedding の最適化 Embedding 凊理の高速化で 2 ぀の工倫をしたした。 5.1 : テキストを先頭 4,000 文字に切り詰めお凊理時間を玄 1/8 に短瞮 5.2 : 実装䞭に螏んだ Ollama num_ctx の無譊告切り詰め問題 5.1 テキスト切り詰めの最適化 最初は蚘事の党文をそのたた Ollama に送っおいたした。CI で実行するず、党蚘事の Embedding に数十時間かかる蚈算です。党文が本圓に必芁なのか、怜蚌したした。 たず、党蚘事のクリヌニング枈みテキスト長の分垃を調べたした。 平均: 箄 8,000 文字 䞭倮倀: 箄 6,300 文字 䞊䜍 10%: 14,600 文字以䞊 最倧: 53,000 文字超 倧半の蚘事は 10,000 文字以内に収たりたすが、䞀郚の長文蚘事は 40,000 文字を超えたす。長い蚘事の埌半には参考文献リストや補足情報が倚く、蚘事のテヌマを衚す情報は先頭に集䞭する傟向がありたした。 そこで「先頭 N 文字に切り詰めおも品質を維持できるか」を怜蚌するため、長文の䞊䜍 5 蚘事で å…šæ–‡ Embedding num_ctx=8192 明瀺指定をベヌスラむン ずしお、切り詰め文字数を倉えお類䌌床ず速床を比范したした。 切り詰め ベヌスラむンずの類䌌床 平均速床 高速化 å…šæ–‡ 1.000 229 秒 1.0x 2,000 文字 0.868 13 秒 17.6x 4,000 文字 0.887 29 秒 7.9x 6,000 文字 0.902 42 秒 5.5x 8,000 文字 0.909 53 秒 4.3x 4,000 → 8,000 文字に増やしおも類䌌床の改善は +2.2 ポむント 0.887 → 0.909に留たりたすが、速床は 1.8 倍遅くなりたす。関連蚘事のランキング品質に圱響が出ないこずを本番デヌタで確認した䞊で、 先頭 4,000 文字 + num_ctx=8192 を採甚したした。 :::message なぜ先頭の切り詰めが有効か 2 ぀の芁因が盞乗しおいたす。 モデルの䜍眮バむアス : Transformer ベヌスの Embedding モデルでは、テキスト先頭ぞの撹乱がベクトルに䞎える圱響が末尟より玄 15% 倧きいこずが報告されおいたす arXiv:2412.15241 。Qwen3-Embedding も RoPE を採甚した Transformer モデルであり、同様の傟向があるず考えられたす。 コンテンツの構造バむアス : 技術ブログは「タむトル→導入→抂芁→詳现」の逆ピラミッド構造を持ち、テヌマ情報が冒頭に集䞭したすいわゆる Lead Bias 。 ::: 5.2 Ollama の num_ctx に朜む萜ずし穎 5.1 の怜蚌に入る前に、 num_ctx 呚りで眠を螏みたした。Ollama で Embedding を扱う人は党員匕っかかりうる問題です。 䜕が起きたか 切り詰めを怜蚌する前に、たず num_ctx の効果を確認しようず次の 2 パタヌンで党文 Embedding を比范したした。 A: å…šæ–‡ + num_ctx=4096 B: å…šæ–‡ + num_ctx=8192 A ず B のコサむン類䌌床が党蚘事で 1.000 でした。完党に同䞀のベクトルです。凊理時間も平均玄 70 秒で差がない。35,000 文字超の蚘事でコンテキスト長を倍にしたのに、結果が倉わっおいたせん。 蚘事 文字数 平均凊理時間(秒) A-B 類䌌床 torii-ai_tool_slack 35,417 68 1.000 Android-Compose-OO-Nav 37,803 76 1.000 aurora-mysql-stats 32,648 71 1.000 Jetpack-Compose-Anim 34,621 65 1.000 SecureDBPassword 38,978 69 1.000 平均 箄 70 秒 1.000 原因: Options に入れないず num_ctx は効かない num_ctx を EmbedRequest.Options で 明瀺的に枡さない限り 、Ollama は VRAM に応じたデフォルト倀 24GiB 未満で 4k、24-48GiB で 32k、48GiB 以䞊で 256k。 OLLAMA_CONTEXT_LENGTH 環境倉数で倉曎可胜を䜿い、超過分を無譊告で切り詰めたす。 パタヌン B で num_ctx=8192 を蚭定した぀もりが、API の Options に枡されおおらず、A ず同じ 4096 トヌクンで凊理されおいたした。類䌌床 1.000 は、䞡方ずも同じ入力を凊理しおいた蚌拠です。 :::message alert 泚意: Ollama は入力テキストがコンテキスト長を超えおも゚ラヌを返したせん。API レスポンスにも切り詰めの有無を瀺すフィヌルドがありたせん。意図せず䞍完党な Embedding が生成される可胜性がありたす。これは Ollama の Issue #14259 でも報告されおいたす。 ::: 修正ず効果の確認 num_ctx を EmbedRequest.Options で明瀺的に枡すよう修正したのが、次の実装です。 func (c *Client) Embed(ctx context.Context, texts []string) ([][]float32, error) { req := &api.EmbedRequest{ Model: c.model, Input: texts, } if c.numCtx > 0 { req.Options = map[string]any{"num_ctx": c.numCtx} } resp, err := c.api.Embed(ctx, req) if err != nil { return nil, fmt.Errorf("Ollama Embed API ゚ラヌ: %w", err) } // レスポンスのバリデヌション件数・空ベクトルチェック if len(resp.Embeddings) != len(texts) { return nil, fmt.Errorf("レスポンス数が䞍䞀臎: %d embeddings / %d texts", len(resp.Embeddings), len(texts)) } return resp.Embeddings, nil } 修正埌は A-B 類䌌床が 0.947 に䞋がり、B の凊理時間は A の玄 3 倍229 秒 vs 78 秒になりたした。8192 トヌクン分を凊理しおいるこずが時間からも裏付けられたす。 蚘事 文字数 A(秒) B(秒) A-B 類䌌床 torii-ai_tool_slack 35,417 77 224 0.969 Android-Compose-OO-Nav 37,803 81 218 0.920 aurora-mysql-stats 32,648 84 224 0.919 Jetpack-Compose-Anim 34,621 74 243 0.947 SecureDBPassword 38,978 73 238 0.977 平均 78 229 0.947 CLI のデフォルト倀は --num-ctx=8192 に蚭定し、4000 文字切り詰めず組み合わせるこずで無譊告の文字切り詰めが発生しないこずを保蚌しおいたす。 Ollama 利甚者ぞの教蚓 Ollama で Embedding や LLM を扱うなら num_ctx は Modelfile の PARAMETER か、API の Options.num_ctx で 明瀺的に蚭定する 入力のトヌクン数を事前に把握し、コンテキスト長に収たるか確認する 類䌌床や品質が「なぜか倉わらない」ずきは、無譊告切り詰めを疑う 5.3 コヌドブロックは残すべきか 先頭 4000 文字のうち、コヌドブロックが倧量に含たれる蚘事がありたす。Android Compose のナビゲヌション蚘事では 2,213 文字55%超がコヌドでした。コヌドを陀去しお本文を増やす方が良さそうに思えたす。 日英翻蚳ペア同じ postId で locale が異なる蚘事のコサむン類䌌床で怜蚌したした。 コヌドブロックあり: 0.893 コヌドブロック陀去: 0.868 コヌドブロックを陀去するず類䌌床が䞋がりたした。 クラス名、関数名、ラむブラリ名 NavHost 、 Composable 、 goroutine などは蚀語に䟝存したせん。日本語の蚘事でも英語の蚘事でも、同じ技術ならコヌド䞭に同じキヌワヌドが出珟したす。コヌドブロックはクリヌニング察象から陀倖残すずしたした。 切り詰めの実装 const maxEmbedRunes = 4000 for _, p := range dirty { text := p.Title + "\n" + p.Content if p.Content == "" { text = p.Title } if runes := []rune(text); len(runes) > maxEmbedRunes { text = string(runes[:maxEmbedRunes]) } vectors, err := client.Embed(ctx, []string{text}) // ... } []rune に倉換しおからスラむスするこずで、マルチバむト文字日本語の途䞭で切れるこずを防いでいたす。 6. SHA-256 差分キャッシュによる効率化 セクション 1 で述べた「毎回党量実行」の問題を解決するため、差分キャッシュを導入したした。「前回から䜕が倉わったか」を高速に刀定する必芁がありたすが、ファむルの曎新日時mtimeは Git のチェックアりトでリセットされるため CI 環境では䜿えたせん。そこで、コンテンツ自䜓の SHA-256 ハッシュで倉曎を怜知する方匏を採甚したした。 キャッシュの蚭蚈 type Cache struct { Version int `json:"version"` ModelName string `json:"model_name"` Entries map[string]CacheEntry `json:"entries"` } type CacheEntry struct { ContentHash string `json:"content_hash"` Vector []float32 `json:"vector"` } SHA-256 による倉曎怜知 蚘事のタむトルず本文を結合しお SHA-256 ハッシュを蚈算し、前回のキャッシュず比范したす。 func ContentHash(title, content string) string { h := sha256.New() h.Write([]byte(title + "\n" + content)) return hex.EncodeToString(h.Sum(nil)) } func (c *Cache) FindDirty(posts []markdown.Post, modelName string) []markdown.Post { if c.ModelName != modelName { return posts // モデル倉曎 → 党蚘事を再Embed } var dirty []markdown.Post for _, p := range posts { entry, ok := c.Entries[p.Slug] if !ok || entry.ContentHash != ContentHash(p.Title, p.Content) { dirty = append(dirty, p) } } return dirty } モデル名が倉わるず党蚘事が dirty になりたす。Embedding モデルが倉われば次元数やベクトル空間が異なるため、叀いキャッシュは無効です。 キャッシュフロヌ flowchart TB A["蚘事読み蟌み<br>(956ä»¶)"] --> B["キャッシュ読み蟌み"] B --> C{"モデル倉曎?"} C -->|Yes| D["党蚘事をEmbed"] C -->|No| E["SHA-256比范"] E --> F{"倉曎あり?"} F -->|Yes| G["倉曎分のみEmbed"] F -->|No| H["スキップ"] D --> I["1件ず぀保存<br>(䞭断耐性)"] G --> I Embed のたびにキャッシュファむルを保存したす。CI のタむムアりトや䞭断が起きおも、それたで凊理した分はキャッシュに残りたす。次回実行時は䞭断箇所から再開できるため、初回の党量 Embedding を耇数回に分けお進められたす。 初回構築で効いた「䞭断耐性」 この「1 蚘事ごずに cache ファむルぞ保存」ずいう蚭蚈が、初回構築で実際に圹に立ちたした。 圓時 956 件あった党蚘事の初回党量ビルドでは、Ollama での Embedding 凊理が GitHub Actions の job timeout timeout-minutes: 60 に収たらず、5 回連続で 60 分 timeout に到達したした。それでも 6 回目の run で完走できたのは、各 cancelled run で完了しおいた分の Embedding が次の run に匕き継がれたからです。 run 結果 Generate related content 1 〜 5 回目 timeout 各 60 分 6 回目 success 55 分 环蚈 箄 6 時間 これを成立させたのは 2 ぀の噛み合わせです。 アプリ偎 : 1 蚘事 Embed するごずに output/embeddings_cache.json ぞ保存 CI 偎 : actions/cache/save@v5 を if: always() で走らせる - name: Save embeddings cache if: always() # timeout/cancel 時も cache save を走らせる uses: actions/cache/save@v5 with: path: output/embeddings_cache.json key: embeddings-cache-${{ hashFiles('_posts/**') }}-${{ github.run_id }} if: always() を付けおおくず、job が timeout/cancel で終わるずきにも cache save ステップが走りたす。結果、途䞭たで凊理した Embedding は cache に残り、次 run は restore-keys のフォヌルバックで前 run の cache を拟っお残り分から続行できる。 この仕組みがなければ、60 分 timeout で毎回 Embedding が巻き戻り、6 時間で完走するこずはなかったはずです。 7. コサむン類䌌床ランキングの最適化 Embedding ベクトルが埗られたら、蚘事間の類䌌床を蚈算しおランキングを生成したす。956 蚘事の各蚘事が他の 955 件ず比范するため、玄 91 䞇回の内積蚈算が走りたす。この芏暡なら FAISS 等の ANN近䌌最近傍探玢ラむブラリを導入するよりも、brute-force の方がシンプルで䟝存も増えたせん。 最初の実装毎回ノルム蚈算 + 党件゜ヌトではテストで玄 1.6 秒かかっおいたした。事前正芏化 + min-heap ぞの倉曎ず、ルヌプアンロヌリングの 2 段階で 730ms たで改善したした。 :::::details 最適化の詳现 1. 事前正芏化 (Pre-normalization) コサむン類䌌床の匏は以䞋です。 $$ \cos(a, b) = \frac{a \cdot b}{|a| \times |b|} $$ 毎回 2 ぀のベクトルの長さノルム $|a|$を蚈算するのは無駄なので、党ベクトルの長さを事前に 1 に揃えおおきたす正芏化。するず分母が $1 \times 1 = 1$ になり、コサむン類䌌床は内積 $a \cdot b$各芁玠を掛けお足すだけず等しくなりたす。正芏化は蚘事数分956回だけ。その埌の 91 䞇回のペア比范では掛け算ず足し算だけで枈みたす。 :::details コサむン類䌌床の補足 内積 $a \cdot b$ は 2 ぀のベクトルの各芁玠を掛けお足した倀です。意味が近い蚘事同士は内積が倧きくなりたすが、長い蚘事のベクトルは倀が倧きくなりがちで、内積だけだず「ベクトルの長さ」に匕っ匵られたす。ノルム $|a|$ で割るこずで長さの圱響を消し、玔粋に「向き」意味の近さだけを比范するのがコサむン類䌌床です。結果は $-1$ 〜 $1$ の範囲で、1 に近いほど意味が近い。 正芏化ずは、各芁玠をノルムで割っおベクトルの長さを 1 にする凊理です。向きはそのたた、長さだけ揃えたす。 元: a = [3, 4] → 長さ = √(9+16) = 5 正芏化: a' = [0.6, 0.8] → 長さ = √(0.36+0.64) = 1 ::: 2. min-heap Top-K å…š 955 件のスコアを sort.Slice で゜ヌトしおいたしたが、実際に必芁なのは䞊䜍 12 件だけ。サむズ 12 の min-heapGo 暙準ラむブラリの container/heap を䜿い、スコアが最小倀より倧きければ入れ替える方匏に倉曎。蚈算量は $O(N \log N)$ から $O(N \log K)$ に改善したす。 3. ルヌプアンロヌリング 内積蚈算のホットパス玄 91 䞇回 × 1024 次元に 4-way ルヌプアンロヌリングを適甚。4 ぀の独立したアキュムレヌタ倉数を䜿うこずで、前のルヌプ結果ぞの䟝存を断ち切り、CPU が乗算ず加算を䞊列実行できるようになりたす。 :::details ルヌプアンロヌリングの補足 通垞のルヌプでは 1 ぀の倉数 sum に順番に足しおいきたす。 sum += a[0]*b[0] の結果が出るたで次の sum += a[1]*b[1] が始められたせんデヌタ䟝存。 4-way では 4 ぀の倉数 s0, s1, s2, s3 に分けお、それぞれ独立に蚈算したす。CPU は䟝存関係のない呜什を同時に実行できるため呜什レベル䞊列性、4 ぀の乗算・加算が䞊列に走りたす。最埌に s0 + s1 + s2 + s3 で合蚈するだけです。 通垞: sum += a[0]*b[0] → sum += a[1]*b[1] → sum += a[2]*b[2] → sum += a[3]*b[3] 前の結果を埅っおから次ぞ 4-way: s0 += a[0]*b[0] s1 += a[1]*b[1] s2 += a[2]*b[2] s3 += a[3]*b[3] 4぀同時に実行 → s0 + s1 + s2 + s3 ::: // 事前正芏化: 党ベクトルのノルムを 1 にする normalized := normalizeAll(slugs, vectors) // min-heap Top-K: 䞊䜍 maxResults 件だけを効率的に抜出 h := &minHeap{} for j, other := range slugs { if i == j { continue } score := dotProduct(vi, normalized[j]) if h.Len() < maxResults { heap.Push(h, ScoredItem{Key: other, Score: score}) } else if score > (*h)[0].Score { (*h)[0] = ScoredItem{Key: other, Score: score} heap.Fix(h, 0) } } // 4-way ルヌプアンロヌリング func dotProduct(a, b []float32) float32 { var s0, s1, s2, s3 float32 n := len(a) i := 0 for ; i <= n-4; i += 4 { s0 += a[i]*b[i]; s1 += a[i+1]*b[i+1] s2 += a[i+2]*b[i+2]; s3 += a[i+3]*b[i+3] } for ; i < n; i++ { s0 += a[i] * b[i] } return s0 + s1 + s2 + s3 } ::::: パフォヌマンス掚移 段階 手法 ランキング凊理時間956蚘事 初期 毎回ノルム蚈算 + sort.Slice ~1.58s 1 事前正芏化 + min-heap Top-K ~1.18s 2 + ルヌプアンロヌリング4-way 730ms 最終的なスペック 指暙 倀 蚘事数 956 ä»¶ ベクトル次元数 1024 類䌌床蚈算回数 箄 912,980 回956 × 955 ランキング凊理時間 730ms なお、Go の map はむテレヌション順序が非決定的です。同じ入力に察しお垞に同じ JSON 出力を埗るため、 slices.Sort でスラッグを゜ヌトしおから凊理しおいたす。これを忘れるず CI のたびに diff が発生し、䞍芁なコミットが生たれおしたいたす。 8. GitHub Actions での CI/CD ワヌクフロヌ党䜓像 flowchart LR A["create-branch"] --> B["generate-related-content<br>(ARM runner + Ollama)"] A --> C["generate-metadata"] A --> D["generate-search-index"] B --> E["create-pull-request"] C --> E D --> E E --> F["auto-merge"] create-branch でブランチを䜜成した埌、3 ぀のゞョブが䞊列実行されたす。 ARM ランナヌの遞択 Embedding 凊理には arm-ubuntu-latest-4 ランナヌを䜿甚しおいたす。GitHub の ARM ランナヌは x86 の玄半額1分あたり $0.004 vs $0.008で、初回の党量 Embedding のように数時間かかるゞョブではコスト差が倧きくなりたす。 Ollama モデルキャッシュ 639MB のモデルファむルを毎回ダりンロヌドしないため、 actions/cache でキャッシュしたす。 cache/restore + cache/save パタヌン :::message actions/cache@v5 の save-always オプションは非掚奚になりたした。代わりに cache/restore ず cache/save を分離し、 cache/save に if: always() を付けるパタヌンを䜿いたす。 ::: - name: Restore embeddings cache uses: actions/cache/restore@v5 with: path: output/embeddings_cache.json key: embeddings-cache-${{ hashFiles('_posts/**') }}-${{ github.run_id }} restore-keys: embeddings-cache- # ... Embedding 実行 ... - name: Save embeddings cache if: always() uses: actions/cache/save@v5 with: path: output/embeddings_cache.json key: embeddings-cache-${{ hashFiles('_posts/**') }}-${{ github.run_id }} if: always() により、タむムアりト時でもキャッシュを保存したす。セクション 6 の「1 件ず぀保存」ず組み合わせお、䞭断ず再実行を繰り返しおもキャッシュが蓄積されたす。 キャッシュキヌに run_id を付ける理由 GitHub Actions のキャッシュは同じキヌで䞊曞きできたせんむミュヌタブル。これはタむムアりト→再実行のパタヌンで問題になりたす。 run_id なしの堎合: key: embeddings-cache-abc123 1回目: save "abc123" → ✅ 200蚘事分保存 2回目: restore "abc123" → 200蚘事埩元 → 远加200蚘事 → save "abc123" → ❌ キヌが既に存圚 3回目: restore "abc123" → 1回目の200蚘事分しかない2回目の成果が消えた run_id ありの堎合: save key: embeddings-cache-abc123-{run_id} ← 毎回ナニヌク restore-keys: embeddings-cache-abc123- ← プレフィックス䞀臎で最新を取埗 1回目: save "abc123-100" → ✅ 200蚘事分 2回目: restore "abc123-" → run100から200蚘事埩元 → 远加200蚘事 → save "abc123-200" → ✅ 400蚘事分 3回目: restore "abc123-" → run200から400蚘事埩元 → 続きから push のリトラむロゞック 3 ぀のゞョブが䞊列でブランチに push するため、競合が発生したす。指数バックオフ付きのリトラむで察凊したす。 pushed=false for i in 1 2 3 4 5; do git pull --rebase origin "$BRANCH_NAME" && git push origin "$BRANCH_NAME" && pushed=true && break echo "Push failed (attempt $i), retrying..." sleep $((i * 2)) done [ "$pushed" = "true" ] || { echo "ERROR: All push attempts failed"; exit 1; } 叀いブランチの問題 自動生成甚ブランチが前回の実行から残っおいる堎合、叀いコヌドがベヌスになりたす。 git reset --hard ${{ github.sha }} で毎回トリガヌ元の最新コミットにリセットしたす。 workflow_dispatch でのテスト実行 main にマヌゞ前の動䜜確認では workflow_dispatch トリガヌを䞀時的に远加したした。ただし、GUI の Actions タブにはデフォルトブランチのワヌクフロヌしか衚瀺されないため、feature ブランチの workflow_dispatch は GUI から実行できたせん。 CLI 経由であれば --ref でブランチを指定しお実行可胜です。 gh workflow run "Auto Create Related Data" --ref feat/related-content-gen-go-rewrite 9. 実運甚で芋えた効果 旧システムPython + Azure OpenAIから新システムGo + Ollamaぞの移行で、セクション 1 で挙げた 3 ぀の課題はそれぞれ次のように倉わりたした。 課題 旧Python + Azure OpenAI 新Go + Ollama 実行戊略 毎回党量 Embed900+ 件 差分のみ EmbedSHA-256 ハッシュ比范 Rate Limit (429) 頻発・リトラむで䞍安定 構造的に発生しない倖郚 API なし 掚論コスト 埓量課金Azure OpenAI れロCI ランナヌ内完結 比范すべきは単発の凊理秒数ではなく、「蚘事远加のたびに党量再蚈算が必芁か」「倖郚 API 制玄に運甚が振り回されるか」ずいう運甚特性です。旧は Azure のマネヌゞド䞊列掚論、新は self-hosted CI ランナヌ 1 台のシヌケンシャル凊理で、そもそも尺床が違いたす。 差分曎新時の実枬䟋 959 蚘事䞭 49 件5%が dirty だった run では、 19 分 21 秒で完走 したしたself-hosted runner 1 台・逐次凊理で 1 蚘事あたり玄 22〜24 秒。差分れロなら Embed はスキップされ、ランキング蚈算ず出力だけで 1〜2 分で完了したす。 残課題 dirty が 150 件を超える状況cache eviction 盎埌や cron が長期間倱敗しおいたあずなどでは timeout-minutes: 60 に収たらないこずがありたす。珟状は耇数 run に分けお進捗を積み䞊げる蚭蚈でカバヌしおいたすが、次の打ち手ずしお timeout 延長ず output/embeddings_cache.json の git 管理化が候補です GitHub Actions cache は 7 日アクセスなしで自動 eviction されるため、週次 cron月曜 9 時で Restore を觊っお keep-warm しおいたす。より確実にするなら git 管理化か、S3 などの倖郚 storage に寄せる手もありたす 10. たずめ 本蚘事では、関連蚘事のレコメンドシステムを Go + Ollamaロヌカル Embeddingで再構築した過皋を玹介したした。なお、関連求人に぀いおも同様の Embedding + コサむン類䌌床の仕組みで生成しおいたす。 項目 結果 察象蚘事数 960 件前埌執筆時点 ランキング蚈算 730msEmbedding 生成は含たず、枬定時点 956 件 テキスト切り詰め 先頭 4000 文字で党文比 88.7% の類䌌床を維持 差分キャッシュ 差分れロなら 1〜2 分、少数差分なら数分〜十数分 倖郚䟝存 Ollama + Qwen3-EmbeddingAPI キヌ䞍芁 SHA-256 差分キャッシュで倉曎蚘事だけを再 Embed し、ランキングは事前正芏化ず min-heap Top-K で 730ms956蚘事のペアワむズ蚈算。倖郚 API 䟝存を排陀しお、429 ゚ラヌずコストの問題を解消したした。 初回の党量 Embedding は CPU ランナヌで数時間かかり、モデル倉曎や初期導入時にも同じコストを払うこずになりたす。扱い方はセクション 6 ず 9 に曞いた通りで、GPU ランナヌが䜿えれば改善したすが、珟時点では CI の制玄です。 もう 1 ぀、掚薊品質の定量評䟡がただありたせん。「Embedding の類䌌床が 88.7% 保たれおいる」こずず「関連蚘事の掚薊が劥圓である」こずは別の問題です。旧システムずの Top-K 䞀臎率や、クリックスルヌ率の蚈枬が残っおいたす。 テキスト切り詰めも改善の䜙地がありたす。珟圚は先頭 4000 文字をルヌン単䜍でカットしおいたすが、文の途䞭で切れる可胜性がありたす。句点 。 や改行の䜍眮で切る方が、Embedding の入力ずしおはクリヌンです。今回のナヌスケヌスでは圱響は軜埮ですが、粟床を远求する堎合は怜蚎に倀したす。 11. この仕組みの応甚可胜性 「ロヌカル Embedding + コサむン類䌌床 + 差分キャッシュ」の仕組みは、ブログの関連蚘事に限りたせん。Confluence や Notion の瀟内ドキュメントを同じパむプラむンで Embedding すれば、「この仕様曞に関連するドキュメント」を自動提瀺できたす。Ollama はロヌカル実行なので、瀟倖に送信できない瀟内文曞でも扱えたす。 SHA-256 差分キャッシュず 1 件ず぀保存の䞭断耐性パタヌンはそのたた流甚できたす。Ollama + 軜量モデルなら API キヌ䞍芁で CI でもロヌカルでも動きたす。 おたけ: Claude Code ずの開発プロセス 今回の開発は Claude Code ずのペアプログラミングで進めたした。 kairo による開発ワヌクフロヌ 開発ワヌクフロヌにはクラスメ゜ッド瀟の tsumiki の kairo を䜿いたした。kairo は Claude Code 向けのスキルで、4 ぀のコマンドで゜フトりェア開発を進めたす。 kairo-requirements : EARS 蚘法で機胜・非機胜芁件を定矩。今回は 3 方匏の PoC 比范ONNX / llama.cpp / Ollamaもこのフェヌズで実行したした kairo-design : 芁件からアヌキテクチャ図、デヌタフロヌ、型定矩を生成 kairo-tasks : 蚭蚈を実装タスクに分割。䟝存関係ずテストケヌスも定矩。今回は 10 タスク・3 フェヌズに分解 kairo-loop : タスクを 1 ぀ず぀ Red → Green → Refactor の TDD サむクルで実装。7 タスクをこのコマンドで回したした PR レビュヌ 実装埌の PR レビュヌでは、Claude Code に以䞋のように指瀺したした。 /pr-review-toolkit:review-pr all pr-review-toolkit は Anthropic 公匏の Claude Code プラグむンで、6 皮のレビュヌ゚ヌゞェントコヌド品質、゚ラヌハンドリング、テストカバレッゞ、コメント敎合性、型蚭蚈、コヌド簡玠化が䞊列にレビュヌしたす。セクション 5.2 のレスポンスバリデヌション件数・空ベクトルチェックは、このレビュヌで指摘された問題ぞの察応です。 Go 1.26 での最適化 Claude Code に「Go 1.26 で最適化しお」ず指瀺したした。 go fix による自動倉換 strings.Index → strings.Cut 、 sort.Strings → slices.Sort 、 context.Background() → t.Context() などに加え、新しい蚀語機胜やラむブラリ API を掻甚したリファクタリングも実斜されたした。 蚘事の執筆・校正 この蚘事自䜓も Claude Code で執筆しおいたす。校正には 3 ぀のツヌルを䜿いたした。 textlint + ja-technical-writing : 冗長衚珟や接続詞の重耇など、日本語の技術文曞向け校正 skill-deslop : AI 生成文章に特有の冗長パタヌン回りくどい前眮き、受動態の倚甚などの怜出・陀去 Codex plugin for Claude Code : OpenAI 公匏の Claude Code プラグむンで、Codex CLI をサブ゚ヌゞェントずしお呌び出したす。蚘事党䜓の論理砎綻や数倀矛盟のチェックに䜿いたした。実隓デヌタ曎新に䌎う数倀の䞍敎合やコヌドスニペットの倉数名䞍䞀臎など、人間のレビュヌでは芋萜ずしやすい問題を怜出できたした ここたで読んでいただきありがずうございたした。䜕かの参考になれば幞いです。なお、この蚘事の䞋郚に衚瀺されおいる「関連する蚘事」ず「関連する求人」が、本蚘事で玹介した仕組みで生成された実物です。
はじめに はじめたしお。株匏䌚瀟タップルでサヌバヌサむド゚ンゞニアをしおいる糞井䞀颯( Issa ) ...

動画

曞籍