KINTO Tech Blog
AI

テックブログの「関連記事レコメンド」をローカル Embedding で再構築した話

Cover Image for テックブログの「関連記事レコメンド」をローカル Embedding で再構築した話

※本記事は Claude Code との協働で執筆し、人間がレビューの上投稿しています。

1. はじめに

こんにちは、共通サービス開発グループの鳥居(@yu_torii)です。

前回の記事では、Slack 上で LLM を活用する社内チャットボットの実装事例を紹介しました。

今回は、このテックブログの「関連する記事」と「関連する求人」機能をゼロから再構築した話をします。

「関連する記事」「関連する求人」とは

各記事ページの下部に、2つのレコメンドセクションがあります。

  • 関連する記事: 現在読んでいる記事と内容が近い記事を最大12件表示
  • 関連する求人: 記事の技術領域に関連する KINTO Technologies の求人情報を最大8件表示

読者が興味のある技術領域を深掘りする導線であり、過去の記事の発見にもつながります。採用への接点でもあります。

仕組みの基本:Embedding とコサイン類似度

この機能の核は Embedding(埋め込みベクトル)です。Embedding モデルにテキストを入力すると、その意味を表す数百〜数千次元の数値ベクトルが返ってきます。意味的に近いテキスト同士は、ベクトル空間上で近い位置に配置されます。

2 つのベクトルの「近さ」を測る指標がコサイン類似度です。値が 1 に近いほど意味が近く、0 に近いほど無関係(直交)です。すべての記事を Embedding し、ペアごとにコサイン類似度を計算してスコアの高い順に並べれば、「関連する記事」のランキングが得られます。

旧システムの課題

この機能は以前、Python + Azure OpenAI の Embedding API で実装されていました。運用を続ける中で 3 つの問題が出てきました。

  1. 差分更新が無い。毎回全記事を再 Embed

CI が走るたびに全記事(当時 900 件超)を Azure OpenAI に送って Embedding していました。1 記事の追加でも全件再処理が走り、ビルド時間の大半を占めていました。

  1. Azure OpenAI の 429 (Rate Limit) エラーが頻発

900 件超の記事を一気に送ると、Azure OpenAI のレート制限に頻繁にヒットしていました。リトライロジックを入れてもタイミング次第で CI が失敗し、再実行が必要になることも珍しくありませんでした。

  1. 外部 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 する仕組み

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 クライアントから呼ぶ方式です。

cmd/related-content-gen-poc-ollama/main.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 の検索精度が求められるタスクではなく関連記事の推薦用途なので、最高精度モデルは不要です。

量子化とは

量子化(Quantization)は、モデルの重み(パラメータ)を元の精度(通常 float16 = 16bit)からより少ないビット数(8bit、4bit など)に変換する手法です。精度はわずかに低下しますが、モデルサイズとメモリ使用量を大幅に削減できます。

Qwen3-Embedding-0.6B は Ollama で Q8_0(8bit 量子化)として配布されており、595M パラメータで 639MB。一方、bge-m3 は F16(16bit)配布のため、パラメータ数はほぼ同じ(568M)でもサイズが 1.2GB と約 2 倍になります。


3. アーキテクチャの全体像

パイプライン

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() 関数に集約されています。

main.go
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.jsonlib/related_posts.ts が読み込み

フロントエンド側では、JSON に関連記事データがあればそれを使い、無ければカテゴリベースのフォールバックに切り替わります。Go CLI とフロントエンドの間の契約は、この JSON スキーマだけです。


4. Markdown のクリーニングと前処理

当ブログの記事は Zenn Markdown:::message:::details@[card]() など)で書かれています。各記事ファイルの先頭には YAML frontmatter(タイトル、著者、公開日、カテゴリなどのメタ情報)があり、これらをそのまま Embed するとノイズになります。

クリーニングパイプライン

  1. frontmatter の分離: --- で囲まれた YAML ヘッダーからタイトルだけ抽出し、残りのメタ情報(author, date, category 等)は除去
  2. URL の除去: http:// / https:// で始まるすべての URL を除去
  3. アセットリンクの除去: /assets/ を含むリンク(画像パスなど)を除去

クリーニングのエントリポイントは CleanMarkdown 関数で、frontmatter からタイトルを抽出しつつ、本文のノイズを除去します。frontmatter パースには strings.Cut を使い、--- デリミタ間の YAML を gopkg.in/yaml.v3 で解析しています。

コードの詳細(cleaner.go / parser.go)
internal/markdown/cleaner.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
}
internal/markdown/parser.go
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 を採用しました。

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_ctxEmbedRequest.Options明示的に渡さない限り、Ollama は VRAM に応じたデフォルト値(24GiB 未満で 4k、24-48GiB で 32k、48GiB 以上で 256k。OLLAMA_CONTEXT_LENGTH 環境変数で変更可能)を使い、超過分を無警告で切り詰めます。

パターン B で num_ctx=8192 を設定したつもりが、API の Options に渡されておらず、A と同じ 4096 トークンで処理されていました。類似度 1.000 は、両方とも同じ入力を処理していた証拠です。

修正と効果の確認

num_ctxEmbedRequest.Options で明示的に渡すよう修正したのが、次の実装です。

internal/embedding/client.go
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%超)がコードでした。コードを除去して本文を増やす方が良さそうに思えます。

日英翻訳ペア(同じ postIdlocale が異なる記事)のコサイン類似度で検証しました。

  • コードブロックあり: 0.893
  • コードブロック除去: 0.868

コードブロックを除去すると類似度が下がりました。

クラス名、関数名、ライブラリ名(NavHostComposablegoroutine など)は言語に依存しません。日本語の記事でも英語の記事でも、同じ技術ならコード中に同じキーワードが出現します。コードブロックはクリーニング対象から除外(残す)としました。

切り詰めの実装

main.go
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 ハッシュで変更を検知する方式を採用しました。

キャッシュの設計

internal/embedding/cache.go
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 ハッシュを計算し、前回のキャッシュと比較します。

internal/embedding/cache.go
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 モデルが変われば次元数やベクトル空間が異なるため、古いキャッシュは無効です。

キャッシュフロー

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. アプリ側: 1 記事 Embed するごとに output/embeddings_cache.json へ保存
  2. CI 側: actions/cache/save@v5if: always() で走らせる
.github/workflows/auto-create-related-data-on-pushd-to-main.yml
- 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 まで改善しました。

最適化の詳細

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 万回のペア比較では掛け算と足し算だけで済みます。

コサイン類似度の補足

内積 a \cdot b は 2 つのベクトルの各要素を掛けて足した値です。意味が近い記事同士は内積が大きくなりますが、長い記事のベクトルは値が大きくなりがちで、内積だけだと「ベクトルの長さ」に引っ張られます。ノルム \|a\| で割ることで長さの影響を消し、純粋に「向き」(意味の近さ)だけを比較するのがコサイン類似度です。結果は -11 の範囲で、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 が乗算と加算を並列実行できるようになります。

ループアンローリングの補足

通常のループでは 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
internal/similarity/ranking.go
// 事前正規化: 全ベクトルのノルムを 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

ワークフロー全体像

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 パターン

auto-create-related-data-on-push-to-main.yml
- 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 つのコマンドでソフトウェア開発を進めます。

  1. kairo-requirements: EARS 記法で機能・非機能要件を定義。今回は 3 方式の PoC 比較(ONNX / llama.cpp / Ollama)もこのフェーズで実行しました
  2. kairo-design: 要件からアーキテクチャ図、データフロー、型定義を生成
  3. kairo-tasks: 設計を実装タスクに分割。依存関係とテストケースも定義。今回は 10 タスク・3 フェーズに分解
  4. 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.Indexstrings.Cutsort.Stringsslices.Sortcontext.Background()t.Context() など)に加え、新しい言語機能やライブラリ API を活用したリファクタリングも実施されました。

記事の執筆・校正

この記事自体も Claude Code で執筆しています。校正には 3 つのツールを使いました。

  • textlint + ja-technical-writing: 冗長表現や接続詞の重複など、日本語の技術文書向け校正
  • skill-deslop: AI 生成文章に特有の冗長パターン(回りくどい前置き、受動態の多用など)の検出・除去
  • Codex plugin for Claude Code: OpenAI 公式の Claude Code プラグインで、Codex CLI をサブエージェントとして呼び出します。記事全体の論理破綻や数値矛盾のチェックに使いました。実験データ更新に伴う数値の不整合やコードスニペットの変数名不一致など、人間のレビューでは見落としやすい問題を検出できました

ここまで読んでいただきありがとうございました。何かの参考になれば幸いです。なお、この記事の下部に表示されている「関連する記事」と「関連する求人」が、本記事で紹介した仕組みで生成された実物です。

Facebook

関連記事 | Related Posts

We are hiring!

生成AIエンジニア・生成AIスペシャリスト/AIファーストG/東京・名古屋・大阪・福岡

募集背景KINTOテクノロジーズでは、2023年6月に内製の生成AIチャットツールを導入して以来、全社的に生成AIの業務活用を推進してきました。現在は、単発のPoCや個別最適に留まらず、複数部門・複数チームにまたがる課題を、再現性のある形で解決していくことが求められています。

【PdM】オープンポジション/東京・名古屋・大阪

募集背景KINTOテクノロジーズでは新たな事業展開と共に開発するプロダクトが拡大しています。サービスの新規立ち上げ、立ち上げたプロダクトのグロースを推進し、KINTOの事業展開を支えるプロダクトマネージャーを求めています。