🎯

設定駆動型マッチングエンジンの設計と実装:数学的抽象化で複数ドメインに対応する

に公開

はじめに

マッチングアルゴリズムは、恋愛マッチングアプリ、M&Aプラットフォーム、求人マッチング、不動産検索など、様々なドメインで必要とされます。しかし、従来のアプローチでは、各ドメインごとに個別のマッチングロジックを実装する必要があると考えられます。これは、コードの重複、保守コストの増大、新規ドメイン追加時の開発工数の増加といった問題を引き起こします。

本記事では、数学的抽象化に基づくドメイン非依存のマッチングエンジンを設計し、JSON設定ファイルだけで異なるドメインに対応できるアーキテクチャを解説します。実装にはGoを使用していますが、設計思想は他の言語にも適用可能です。

ソースコードはmatching-engineとして公開しています。

筆者について
筆者はマッチングエンジンの専門開発者ではありません。本記事は、マッチングという問題を数学的に抽象化し、ドメイン駆動設計の原則を適用して実装した経験をまとめたものです。マッチングアルゴリズムの専門的な最適化手法や、大規模システムでの運用実績については限定的な知見しか持ち合わせていないことをご了承ください。

なぜドメイン非依存のエンジンが必要なのか

従来のアプローチの問題点

一般的なマッチングシステムでは、おそらくドメイン固有のロジックがコードに直接埋め込まれていると想像できます(違っていたらごめんなさい!)。

// 従来のアプローチ:ドメイン固有ロジックがハードコード
func CalculateDatingScore(userA, userB *User) float64 {
    score := 0.0
    
    // 年齢の近さ
    ageDiff := math.Abs(float64(userA.Age - userB.Age))
    score += (1.0 / (1.0 + ageDiff)) * 0.2
    
    // 住所の一致
    if userA.Prefecture == userB.Prefecture {
        score += 0.15
    }
    
    // 趣味の一致度
    commonHobbies := countCommonHobbies(userA.Hobbies, userB.Hobbies)
    score += (float64(commonHobbies) / float64(len(userA.Hobbies))) * 0.25
    
    // ... さらに多くの条件が続く
    
    return score
}

このアプローチには以下の問題があります。

まず、ビジネスルールの変更がコード変更を伴います。例えば、「趣味の重みを0.25から0.3に変更したい」という要望に対して、コードを修正し、テストし、デプロイする必要があります。

次に、新しいドメインを追加する際、類似のロジックを一から実装する必要があります。仮に企業買収のためにM&Aマッチングを追加する場合、企業規模、業種、財務指標などの計算ロジックを新たに書く必要があります。

さらに、A/Bテストが困難です。異なるスコアリングアルゴリズムを試すには、複数のバージョンのコードを管理する必要があります。

数学的抽象化によるアプローチ

本システムでは、マッチングを「2つの特徴ベクトル間の類似度計算」という数学的問題に抽象化します。

マッチング問題の抽象化

ドメインエンティティ → 特徴ベクトル → 類似度計算 → スコア → ランキング
     ↓                    ↓         ↓         ↓        ↓
   User/Company  [0.35, "tokyo"]  Euclidean  0.85    1位, 2位...

この抽象化により、以下のメリットが得られます。

第一に、ビジネスルールを設定ファイルで管理できます。重みの調整、新しい評価軸の追加、フィルタ条件の変更などを、コード変更なしで実現できます。

第二に、新しいドメインの追加が容易です。ドメイン固有の部分は「エンティティを特徴ベクトルに変換する」マッパーのみです。コアエンジンは再利用できます。

第三に、A/Bテストが簡単です。異なる設定ファイルを用意するだけで、複数のアルゴリズムを並行して実行できます。

システムアーキテクチャ

レイヤー構成

システムは4つの層で構成されています。

数学関数層の用語説明

各アルゴリズムの特徴と使い分けを説明します。

ユークリッド距離(Euclidean Distance)
2つの点の間の直線距離を計算します。年齢、身長、年収など、連続的な数値の差を評価するのに適しています。距離が小さいほど似ていることを意味します。

例:太郎さん(25歳、175cm)と花子さん(23歳、160cm)の距離を計算すると、年齢と身長の差から総合的な「離れ具合」がわかります。

コサイン類似度(Cosine Similarity)
2つのベクトルの方向の類似性を計算します。ベクトルの大きさに影響されず、方向性だけを評価できます。テキストの類似度や、複数の特徴を持つデータの傾向を比較するのに適しています。

例:ユーザーAとユーザーBの趣味の傾向が似ているかを、趣味の種類とその重要度から判断できます。

Jaccard係数(Jaccard Index)
2つの集合の重なり具合を計算します。共通要素が多いほど高い値になります。タグ、趣味、スキルなど、複数の項目を持つデータの一致度を評価するのに適しています。

計算式:Jaccard係数 = 共通要素数 ÷ 全体の要素数(和集合)

例:太郎さんの趣味が「サッカー、旅行、料理」で、花子さんの趣味が「旅行、料理、映画」の場合

  • 共通する趣味(積集合):「旅行、料理」= 2個
  • 全ての趣味(和集合):「サッカー、旅行、料理、映画」= 4個
  • Jaccard係数 = 2 ÷ 4 = 0.5

特徴ベクトルの設計

特徴ベクトルは、あらゆるエンティティを数値表現に変換するための汎用データ構造です。

// FeatureVector 任意のエンティティを数値表現に変換したもの
type FeatureVector struct {
    ID   string
    Type string
    
    // 数値特徴(正規化済み 0-1)
    Numerical map[string]float64
    
    // カテゴリ特徴(one-hot encoding)
    Categorical map[string]map[string]float64
    
    // スパース特徴(タグ、キーワード)
    Sparse map[string]map[string]float64
    
    // メタデータ(スコアリング対象外)
    Metadata map[string]interface{}
}
特徴ベクトルの各フィールドの説明

各フィールドの役割と使い分けを具体例で説明します。

Numerical(数値特徴)
連続的な数値データを0から1の範囲に正規化して格納します。年齢、身長、年収、売上高など、大小比較が意味を持つデータに使用します。

正規化の計算式:(値 - 最小値) / (最大値 - 最小値)

例:

  • 年齢25歳(18-80歳の範囲)→ (25 - 18) / (80 - 18) = 7 / 62 = 0.11
  • 身長175cm(140-200cmの範囲)→ (175 - 140) / (200 - 140) = 35 / 60 = 0.58
  • 年収500万円(200-2000万円の範囲)→ (500 - 200) / (2000 - 200) = 300 / 1800 = 0.17

この正規化により、異なるスケールのデータを公平に比較できます。

Categorical(カテゴリ特徴)
選択肢が限定されている離散的なデータを格納します。都道府県、業種、性別など、「一致するか/しないか」を評価するデータに使用します。

one-hot encoding形式とは、選択されたカテゴリに1.0を、それ以外に0を割り当てる表現方法です。これにより、カテゴリデータを数値として扱えるようになります。

例:

  • 都道府県「東京」→ {"prefecture": {"東京": 1.0}}(東京が選択されている)
  • 結婚願望「すぐにでもしたい」→ {"marriage_desire": {"すぐにでもしたい": 1.0}}
  • 業種「IT」→ {"industry": {"IT": 1.0}}

マッチング時は、同じカテゴリを持っている場合に1.0(完全一致)、異なる場合に0.0(不一致)として評価されます。

Sparse(スパース特徴)
複数の値を持つ集合データを格納します。趣味、タグ、スキル、技術スタックなど、複数選択可能で重複のないデータに使用します。

例:

  • 趣味 → {"tags": {"サッカー": 1.0, "旅行": 1.0, "料理": 1.0}}
  • スキル → {"skills": {"Go": 1.0, "Python": 1.0, "Docker": 1.0}}
  • 技術スタック → {"technologies": {"React": 1.0, "TypeScript": 1.0}}

Metadata(メタデータ)
スコアリングには使用しないが、結果表示や処理に必要な情報を格納します。ユーザー名、プロフィール画像URL、作成日時など、マッチング計算には影響しないが後で参照したいデータに使用します。

例:

  • {"name": "太郎", "avatar_url": "https://...", "created_at": "2025-12-01"}

恋愛マッチングの例を見てみましょう。

// ユーザーを特徴ベクトルに変換
fv := NewFeatureVector(user.ID, "dating_user")

// 年齢を0-1の範囲に正規化(18-80歳を想定)
age := calculateAge(user.BirthDate)
fv.SetNumerical("age", NormalizeValue(float64(age), 18, 80))

// 身長を正規化(140-200cmを想定)
fv.SetNumerical("height", NormalizeValue(float64(user.Height), 140, 200))

// 都道府県(カテゴリ特徴)
fv.SetCategorical("prefecture", user.Prefecture, 1.0)

// 結婚願望(カテゴリ特徴)
fv.SetCategorical("marriage_desire", user.MarriageDesire, 1.0)

// 趣味(スパース特徴)
for _, tag := range user.Tags {
    fv.SetSparse("tags", tag, 1.0)
}

M&Aマッチングでも同じ構造を使用します。

// 企業を特徴ベクトルに変換
fv := NewFeatureVector(company.ID, "ma_company")

// 売上を正規化(0-100億円を想定)
fv.SetNumerical("revenue", NormalizeValue(company.Revenue, 0, 10000000000))

// 従業員数を正規化
fv.SetNumerical("employee_count", NormalizeValue(float64(company.EmployeeCount), 1, 10000))

// 業種(カテゴリ特徴)
fv.SetCategorical("industry", company.Industry, 1.0)

// 技術スタック(スパース特徴)
for _, tech := range company.Technologies {
    fv.SetSparse("technologies", tech, 1.0)
}

この設計により、ドメインが異なっても同じデータ構造で表現できます。

類似度関数の実装

距離関数と類似度関数

マッチングエンジンは、複数の数学的関数を組み合わせてスコアを計算します。

ユークリッド距離

2つのベクトル間の直線距離を計算します。年齢や身長など、連続値の差を評価するのに適しています。

type EuclideanDistance struct {
    Fields []string
}

func (d *EuclideanDistance) Compute(a, b *FeatureVector) (float64, error) {
    var sum float64
    
    for _, field := range d.Fields {
        valA, okA := a.Numerical[field]
        valB, okB := b.Numerical[field]
        
        if !okA || !okB {
            continue
        }
        
        diff := valA - valB
        sum += diff * diff
    }
    
    return math.Sqrt(sum), nil
}

使用例を見てみましょう。

太郎さん: 年齢 0.11(25歳)、身長 0.58(175cm)
花子さん: 年齢 0.08(23歳)、身長 0.33(160cm)

年齢の差: 0.11 - 0.08 = 0.03
身長の差: 0.58 - 0.33 = 0.25

距離 = √((0.11-0.08)² + (0.58-0.33)²)
     = √(0.03² + 0.25²)
     = √(0.0009 + 0.0625)
     = √0.0634
     = 0.252

類似度 = 1 / (1 + 0.252) = 1 / 1.252 = 0.799

Jaccard係数

2つの集合の類似度を計算します。趣味やタグなど、集合データの一致度を評価するのに適しています。

type JaccardSimilarity struct {
    SparseField string
}

func (s *JaccardSimilarity) Compute(a, b *FeatureVector) (float64, error) {
    setA, okA := a.Sparse[s.SparseField]
    setB, okB := b.Sparse[s.SparseField]
    
    if !okA || !okB {
        return 0, nil
    }
    
    // 積集合のサイズ
    intersection := 0.0
    for key := range setA {
        if _, exists := setB[key]; exists {
            intersection++
        }
    }
    
    // 和集合のサイズ
    union := float64(len(setA) + len(setB)) - intersection
    
    if union == 0 {
        return 0, nil
    }
    
    return intersection / union, nil
}

使用例を見てみましょう。

太郎さんの趣味: {サッカー, 旅行, 料理}
花子さんの趣味: {旅行, 料理, 映画}

共通の趣味: {旅行, 料理} = 2個
全ての趣味: {サッカー, 旅行, 料理, 映画} = 4個

Jaccard係数 = 2 / 4 = 0.5

カテゴリ一致

カテゴリの一致/不一致を評価します。都道府県、業種など、離散的な属性の比較に使用します。

type CategoricalSimilarity struct {
    Field string
}

func (s *CategoricalSimilarity) Compute(a, b *FeatureVector) (float64, error) {
    catA, okA := a.Categorical[s.Field]
    catB, okB := b.Categorical[s.Field]
    
    if !okA || !okB {
        return 0, nil
    }
    
    // 同じカテゴリを持っているかチェック
    for key := range catA {
        if _, exists := catB[key]; exists {
            return 1.0, nil
        }
    }
    
    return 0.0, nil
}

変換関数による柔軟性

距離を類似度に変換したり、スコアを非線形に変換したりする関数を提供します。

逆数変換

距離を類似度に変換します。距離が小さいほど類似度が高くなります。

func InverseTransform() TransformFunc {
    return func(x float64) float64 {
        return 1.0 / (1.0 + x)
    }
}
距離 0.0 → 類似度 1.0(完全一致)
距離 1.0 → 類似度 0.5
距離 9.0 → 類似度 0.1(大きく異なる)

ガウシアン変換

特定の値付近を強調します。理想的な年齢差などを表現できます。

func GaussianTransform(mu, sigma float64) TransformFunc {
    return func(x float64) float64 {
        return math.Exp(-math.Pow(x-mu, 2) / (2*sigma*sigma))
    }
}
例: 理想的な年齢差は0歳、±5歳まで許容

距離 0.0 → スコア 1.0(同い年)
距離 0.1 → スコア 0.98(2歳差)
距離 0.2 → スコア 0.85(4歳差)
距離 0.5 → スコア 0.14(10歳差)
変換関数の詳細説明

各変換関数の計算式と具体例を説明します。

逆数変換(Inverse Transform)

計算式:f(x) = 1 / (1 + x)

この関数は、距離(0以上の値)を類似度(0から1の範囲)に変換します。距離が0に近いほど類似度は1に近づき、距離が大きくなるほど類似度は0に近づきます。

具体例(ユークリッド距離を類似度に変換):

  • 距離 0.0(完全一致)→ 1 / (1 + 0.0) = 1 / 1 = 1.0
  • 距離 0.252(前述の太郎さんと花子さんの例)→ 1 / (1 + 0.252) = 1 / 1.252 = 0.799
  • 距離 1.0(やや離れている)→ 1 / (1 + 1.0) = 1 / 2 = 0.5
  • 距離 9.0(大きく異なる)→ 1 / (1 + 9.0) = 1 / 10 = 0.1

この変換により、距離の値を直感的な「似ている度合い」として解釈できます。

ガウシアン変換(Gaussian Transform)

計算式:f(x) = exp(-(x - μ)² / (2σ²))

  • μ(ミュー):理想的な値(中心値)
  • σ(シグマ):許容範囲の広さ(標準偏差)

この関数は、特定の値を中心に、その周辺を強調する変換です。理想的な値からの距離に応じて、スコアが急激に減少します。

具体例(年齢差の評価、μ=0、σ=0.08を想定):

年齢差0歳が理想、±5歳程度まで許容する場合を考えます。
正規化された年齢差(0-1スケール)で計算します。

  • 年齢差 0.0(同い年)→ exp(-(0.0-0)² / (2×0.08²)) = exp(0) = 1.0
  • 年齢差 0.03(約2歳差)→ exp(-(0.03-0)² / (2×0.08²)) = exp(-0.0009/0.0128) = exp(-0.07) ≈ 0.93
  • 年齢差 0.08(約5歳差)→ exp(-(0.08-0)² / (2×0.08²)) = exp(-0.0064/0.0128) = exp(-0.5) ≈ 0.61
  • 年齢差 0.16(約10歳差)→ exp(-(0.16-0)² / (2×0.08²)) = exp(-0.0256/0.0128) = exp(-2.0) ≈ 0.14

この変換により、「理想的な年齢差」を中心に、急激にスコアが減少する評価ができます。σの値を小さくすると許容範囲が狭くなり、大きくすると許容範囲が広くなります。

複合スコアラーの設計

複数の評価軸を組み合わせて、総合的なマッチングスコアを計算する仕組みを実装します。例えば、恋愛マッチングでは「年齢の近さ」「趣味の一致」「住所の近さ」など、複数の要素を総合的に判断する必要があります。

重み付き合成の仕組み

各評価軸に重みを設定し、加重平均で総合スコアを計算します。これにより、ビジネス要件に応じて「どの要素を重視するか」を柔軟に調整できます。

type CompositeScorer struct {
    Components []ScoringComponent
}

type ScoringComponent struct {
    Similarity SimilarityFunction
    Distance   DistanceFunction
    Weight     float64
    Transform  TransformFunc
    Filter     FilterFunc
}

スコア計算の流れは以下の通りです。

  1. 各評価軸で類似度または距離を計算
  2. 必要に応じて変換関数を適用(距離→類似度など)
  3. 重みを掛けて合計
  4. 全体の重みで割って正規化

計算式で表すと以下のようになります。

最終スコア = Σ(重みᵢ × 変換(スコアᵢ)) / Σ重みᵢ

具体例: 恋愛マッチングの場合

最終スコア = (0.20 × 年齢類似度) 
           + (0.15 × 住所一致度) 
           + (0.25 × 趣味類似度) 
           + (0.20 × 結婚願望一致度)
           + (0.05 × 身長類似度)
           + (0.10 × 喫煙一致度)
           + (0.05 × 飲酒一致度)
           ────────────────────────
                    1.0

重みの合計を1.0にすることで、最終スコアも0-1の範囲に収まります。

実装コードは以下の通りです。

func (s *CompositeScorer) Score(
    ctx context.Context,
    a, b *FeatureVector,
) (float64, map[string]float64, error) {
    var totalScore float64
    var totalWeight float64
    breakdown := make(map[string]float64)
    
    for _, comp := range s.Components {
        // フィルタチェック
        if comp.Filter != nil && !comp.Filter(a, b) {
            continue
        }
        
        // 類似度または距離を計算
        var score float64
        if comp.Similarity != nil {
            score, _ = comp.Similarity.Compute(a, b)
        } else if comp.Distance != nil {
            distance, _ := comp.Distance.Compute(a, b)
            score = 1.0 / (1.0 + distance)
        }
        
        // 変換関数を適用
        if comp.Transform != nil {
            score = comp.Transform(score)
        }
        
        // 重み付け
        weightedScore := score * comp.Weight
        totalScore += weightedScore
        totalWeight += comp.Weight
        
        breakdown[comp.getName()] = score
    }
    
    // 正規化
    if totalWeight == 0 {
        return 0, breakdown, nil
    }
    
    finalScore := totalScore / totalWeight
    return finalScore, breakdown, nil
}

スコア内訳の可視化

各評価軸がどれだけ貢献したかを確認できるよう、内訳情報も返します。これにより、「なぜこのマッチが提案されたのか」をユーザーに説明できます。

{
  "total_score": 0.72,
  "breakdown": {
    "age_similarity": 0.85,
    "location_match": 1.0,
    "hobby_similarity": 0.5,
    "marriage_compatibility": 1.0,
    "height_preference": 0.68,
    "smoking_compatibility": 0.0,
    "drinking_compatibility": 1.0
  }
}

この例では、総合スコア0.72のうち、住所と結婚願望が完全一致(1.0)している一方、喫煙の相性が悪い(0.0)ことがわかります。

設定駆動型の実装

コードを変更せずに、JSON設定ファイルだけでマッチングアルゴリズムを制御できる仕組みを実装します。これにより、ビジネスルールの変更、A/Bテスト、新規ドメインの追加が容易になります。

JSON設定ファイル

マッチングのルールを宣言的に記述します。重み、評価関数、ランキング戦略など、すべてを設定ファイルで指定できます。

{
  "version": "1.0",
  "domain": "dating",
  "scoring": {
    "min_score": 0.30,
    "components": [
      {
        "name": "age_similarity",
        "type": "euclidean",
        "fields": ["age"],
        "weight": 0.20,
        "transform": {
          "type": "inverse"
        }
      },
      {
        "name": "hobby_similarity",
        "type": "jaccard",
        "field": "tags",
        "weight": 0.25
      },
      {
        "name": "location_match",
        "type": "categorical",
        "field": "prefecture",
        "weight": 0.15
      }
    ]
  },
  "ranking": {
    "sort_order": "desc",
    "diversity": {
      "enabled": true,
      "group_by_field": "prefecture",
      "max_per_group": 5
    },
    "random_factor": 0.15,
    "limit": 20
  }
}

設定からエンジンを構築

設定ファイルを読み込んで、実行時にエンジンを動的に構築します。この仕組みにより、デプロイなしで設定を変更できます。

func NewConfigurableEngineFromFile(path string) (*ConfigurableEngine, error) {
    // 設定ファイルを読み込み
    config, err := LoadConfig(path)
    if err != nil {
        return nil, err
    }
    
    // スコアラーを構築
    scorer, err := BuildScorer(&config.Scoring)
    if err != nil {
        return nil, err
    }
    
    // ランカーを構築
    ranker, err := BuildRanker(&config.Ranking)
    if err != nil {
        return nil, err
    }
    
    return &ConfigurableEngine{
        config: config,
        scorer: scorer,
        ranker: ranker,
    }, nil
}

動的なコンポーネント生成

設定ファイルの内容に基づいて、適切な類似度関数や変換関数を選択し、スコアラーを構築します。新しい関数タイプを追加する場合も、この部分を拡張するだけで対応できます。

func BuildScorer(config *ScoringConfig) (*CompositeScorer, error) {
    components := make([]ScoringComponent, 0, len(config.Components))
    
    for _, compConfig := range config.Components {
        var comp ScoringComponent
        
        // 類似度/距離関数を選択
        switch compConfig.Type {
        case "euclidean":
            comp.Distance = &EuclideanDistance{Fields: compConfig.Fields}
        case "jaccard":
            comp.Similarity = &JaccardSimilarity{SparseField: compConfig.Field}
        case "categorical":
            comp.Similarity = &CategoricalSimilarity{Field: compConfig.Field}
        case "cosine":
            comp.Similarity = &CosineSimilarity{VectorField: compConfig.Field}
        default:
            return nil, fmt.Errorf("unknown type: %s", compConfig.Type)
        }
        
        // 変換関数を選択
        if compConfig.Transform != nil {
            switch compConfig.Transform.Type {
            case "inverse":
                comp.Transform = InverseTransform()
            case "gaussian":
                mu := compConfig.Transform.Params["mu"]
                sigma := compConfig.Transform.Params["sigma"]
                comp.Transform = GaussianTransform(mu, sigma)
            }
        }
        
        comp.Weight = compConfig.Weight
        components = append(components, comp)
    }
    
    return &CompositeScorer{Components: components}, nil
}

ランキング戦略

スコアリングだけでなく、結果の並び順も重要です。単純にスコア順に並べるだけでは、ユーザー体験が単調になったり、特定のグループに偏ったりする可能性があります。

多様性の確保

同じグループ(例: 同じ都道府県)の結果ばかりが上位に来ないよう、多様性を制御します。これにより、ユーザーに幅広い選択肢を提供できます。

type Ranker struct {
    Config *RankingConfig
}

func (r *Ranker) Rank(matches []ScoredMatch) []ScoredMatch {
    // スコアでソート
    sort.Slice(matches, func(i, j int) bool {
        return matches[i].Score > matches[j].Score
    })
    
    // 多様性を適用
    if r.Config.Diversity != nil && r.Config.Diversity.Enabled {
        matches = r.applyDiversity(matches)
    }
    
    // ランダムネスを注入
    if r.Config.RandomFactor > 0 {
        matches = r.applyRandomness(matches)
    }
    
    // ページネーション
    start := r.Config.Offset
    end := start + r.Config.Limit
    if end > len(matches) {
        end = len(matches)
    }
    
    return matches[start:end]
}

具体的な実装を見てみましょう。各グループから最大N件までを取得することで、多様性を確保します。

func (r *Ranker) applyDiversity(matches []ScoredMatch) []ScoredMatch {
    groupField := r.Config.Diversity.GroupByField
    maxPerGroup := r.Config.Diversity.MaxPerGroup
    
    groupCounts := make(map[string]int)
    result := make([]ScoredMatch, 0, len(matches))
    
    for _, match := range matches {
        // グループ値を取得
        groupValue := r.getGroupValue(match.Candidate, groupField)
        
        // グループの上限チェック
        if groupCounts[groupValue] >= maxPerGroup {
            continue
        }
        
        result = append(result, match)
        groupCounts[groupValue]++
    }
    
    return result
}

使用例を見てみましょう。

設定: 都道府県ごとに最大5人まで

結果:
1. 太郎(東京)スコア 0.95
2. 次郎(大阪)スコア 0.92
3. 三郎(東京)スコア 0.90
4. 四郎(福岡)スコア 0.88
5. 五郎(東京)スコア 0.87
...
11. 十一郎(東京)スコア 0.75 ← スキップ(東京が5人に達した)

ランダムネスの注入

完全に決定論的な結果ではなく、適度なランダム性を加えます。これにより、同じユーザーが何度もアクセスしても、少し異なる結果が表示され、新鮮な体験を提供できます。

func (r *Ranker) applyRandomness(matches []ScoredMatch) []ScoredMatch {
    factor := r.Config.RandomFactor
    
    for i := range matches {
        // スコアにランダムな変動を加える
        randomAdjustment := (rand.Float64() - 0.5) * factor
        matches[i].Score += randomAdjustment
    }
    
    // 再ソート
    sort.Slice(matches, func(i, j int) bool {
        return matches[i].Score > matches[j].Score
    })
    
    return matches
}

ランダムファクターを0.15に設定した場合、各スコアに±0.075程度の変動が加わります。上位の順位は大きく変わりませんが、中位の候補の順序が入れ替わり、ユーザーに新しい出会いの機会を提供します。

実際の使用例

ここまで説明してきた仕組みを、実際にどのように使用するかを見ていきます。

恋愛マッチング

// エンジンを初期化
engine, err := NewConfigurableEngineFromFile("configs/dating/matching.json")
if err != nil {
    return err
}

// ユーザーを特徴ベクトルに変換
sourceVector := mapper.ToFeatureVector(currentUser)

// 候補ユーザーを取得
candidates := fetchCandidates(currentUser)
candidateVectors := make([]*FeatureVector, len(candidates))
for i, c := range candidates {
    candidateVectors[i] = mapper.ToFeatureVector(c)
}

// マッチングを実行
matches, err := engine.FindMatches(ctx, sourceVector, candidateVectors)
if err != nil {
    return err
}

// 結果を表示
for _, match := range matches {
    fmt.Printf("ユーザー: %s, スコア: %.2f\n", 
        match.Candidate.ID, match.Score)
    fmt.Printf("内訳: %+v\n", match.Breakdown)
}

M&Aマッチング

同じエンジンを使用しますが、設定ファイルを変更するだけで、全く異なるドメインに対応できます。評価軸や重みが異なるだけで、コアロジックは共通です。

{
  "version": "1.0",
  "domain": "ma",
  "scoring": {
    "min_score": 0.40,
    "components": [
      {
        "name": "revenue_similarity",
        "type": "euclidean",
        "fields": ["revenue"],
        "weight": 0.25,
        "transform": {
          "type": "gaussian",
          "params": {
            "mu": 0.0,
            "sigma": 0.2
          }
        }
      },
      {
        "name": "industry_match",
        "type": "categorical",
        "field": "industry",
        "weight": 0.30
      },
      {
        "name": "technology_overlap",
        "type": "jaccard",
        "field": "technologies",
        "weight": 0.20
      },
      {
        "name": "geographic_proximity",
        "type": "categorical",
        "field": "region",
        "weight": 0.15
      },
      {
        "name": "growth_rate_similarity",
        "type": "euclidean",
        "fields": ["growth_rate"],
        "weight": 0.10
      }
    ]
  }
}

使用するコードは全く同じです。設定ファイルのパスを変更するだけで、M&Aマッチングが動作します。

// M&A用のエンジンを初期化(設定ファイルだけが異なる)
engine, err := NewConfigurableEngineFromFile("configs/ma/matching.json")

// 企業を特徴ベクトルに変換
sourceVector := mapper.ToFeatureVector(currentCompany)

// マッチングを実行(同じインターフェース)
matches, err := engine.FindMatches(ctx, sourceVector, candidateVectors)

パフォーマンス最適化

実用的なシステムでは、パフォーマンスが重要です。数万件の候補に対してマッチングを実行する場合、工夫が必要です。

事前フィルタリング

すべての候補に対してスコアリングを実行するのではなく、データベースレベルで候補を絞り込んでから、スコアリングを実行します。

-- 候補を1000件以下に削減
SELECT * FROM users
WHERE gender = ?
  AND age BETWEEN ? AND ?
  AND prefecture IN (?)
  AND verified = TRUE
LIMIT 1000

これにより、計算量を大幅に削減できます。

計算量: O(N × M × C)

N: ソースエンティティ数
M: 候補エンティティ数(フィルタリング後)
C: スコアリングコンポーネント数

例: 1 × 1000 × 7 = 7000回の計算
フィルタリングなし: 1 × 100000 × 7 = 700000回(100倍)

並列処理

複数のユーザーに対して同時にマッチングを実行する場合、Goのgoroutineを使って並列化します。CPUコア数に応じてワーカーを起動し、効率的に処理します。

func (e *ConfigurableEngine) FindMatchesBatch(
    ctx context.Context,
    sources []*FeatureVector,
    candidates []*FeatureVector,
) ([][]ScoredMatch, error) {
    results := make([][]ScoredMatch, len(sources))
    
    numWorkers := runtime.NumCPU()
    jobs := make(chan int, len(sources))
    var wg sync.WaitGroup
    
    for w := 0; w < numWorkers; w++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for i := range jobs {
                matches, _ := e.FindMatches(ctx, sources[i], candidates)
                results[i] = matches
            }
        }()
    }
    
    for i := range sources {
        jobs <- i
    }
    close(jobs)
    
    wg.Wait()
    return results, nil
}

キャッシング

同じ候補セットに対する計算結果は、一定期間キャッシュします。特に、人気のあるユーザーに対するマッチングは頻繁にリクエストされるため、キャッシュの効果が高くなります。

type CachedEngine struct {
    engine *ConfigurableEngine
    cache  Cache
}

func (e *CachedEngine) FindMatches(
    ctx context.Context,
    source *FeatureVector,
    candidates []*FeatureVector,
) ([]ScoredMatch, error) {
    cacheKey := generateCacheKey(source.ID, extractIDs(candidates))
    
    if cached, found := e.cache.Get(cacheKey); found {
        return cached.([]ScoredMatch), nil
    }
    
    matches, err := e.engine.FindMatches(ctx, source, candidates)
    if err != nil {
        return nil, err
    }
    
    e.cache.Set(cacheKey, matches, time.Hour)
    return matches, nil
}

テスト戦略

数学的な計算を含むシステムでは、テストが特に重要です。各レイヤーで適切なテストを実施します。

単体テスト

各類似度関数や距離関数を独立してテストします。既知の入力に対して、期待される出力が得られることを確認します。

func TestEuclideanDistance(t *testing.T) {
    tests := []struct {
        name     string
        fields   []string
        a        *FeatureVector
        b        *FeatureVector
        expected float64
    }{
        {
            name:   "同一ベクトル",
            fields: []string{"age"},
            a:      &FeatureVector{Numerical: map[string]float64{"age": 0.5}},
            b:      &FeatureVector{Numerical: map[string]float64{"age": 0.5}},
            expected: 0.0,
        },
        {
            name:   "異なるベクトル",
            fields: []string{"age"},
            a:      &FeatureVector{Numerical: map[string]float64{"age": 0.3}},
            b:      &FeatureVector{Numerical: map[string]float64{"age": 0.7}},
            expected: 0.4,
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            dist := &EuclideanDistance{Fields: tt.fields}
            result, err := dist.Compute(tt.a, tt.b)
            
            if err != nil {
                t.Fatalf("unexpected error: %v", err)
            }
            
            if math.Abs(result-tt.expected) > 0.001 {
                t.Errorf("expected %f, got %f", tt.expected, result)
            }
        })
    }
}

統合テスト

設定ファイルからエンジンを構築し、エンドツーエンドでマッチングをテストします。実際のドメインデータに近いテストケースを用意することが重要です。

func TestDatingMatchingIntegration(t *testing.T) {
    // エンジンを初期化
    engine, err := NewConfigurableEngineFromFile("testdata/dating_config.json")
    if err != nil {
        t.Fatalf("failed to create engine: %v", err)
    }
    
    // テストデータを準備
    source := createTestUser("user1", 25, "tokyo", []string{"sports", "travel"})
    candidates := []*FeatureVector{
        createTestUser("user2", 24, "tokyo", []string{"sports", "music"}),
        createTestUser("user3", 30, "osaka", []string{"travel", "cooking"}),
        createTestUser("user4", 26, "tokyo", []string{"sports", "travel"}),
    }
    
    // マッチングを実行
    matches, err := engine.FindMatches(context.Background(), source, candidates)
    if err != nil {
        t.Fatalf("matching failed: %v", err)
    }
    
    // 結果を検証
    if len(matches) == 0 {
        t.Fatal("expected at least one match")
    }
    
    // 最も高いスコアのマッチを確認
    topMatch := matches[0]
    if topMatch.Candidate.ID != "user4" {
        t.Errorf("expected user4 as top match, got %s", topMatch.Candidate.ID)
    }
}

実装から得られた学び

正規化の重要性

特徴量の正規化を適切に行わないと、スケールの大きい特徴が支配的になります。

正規化なし:
年齢の差: 5(歳)
年収の差: 3000000(円)
→ 年収の影響が圧倒的に大きい

正規化後:
年齢の差: 0.08(0-1スケール)
年収の差: 0.06(0-1スケール)
→ 公平に評価できる

設定ファイルのバリデーション

設定ファイルの誤りは、実行時エラーではなく起動時に検出すべきです。

func ValidateConfig(config *MatchingConfig) error {
    // 重みの合計チェック
    totalWeight := 0.0
    for _, comp := range config.Scoring.Components {
        totalWeight += comp.Weight
    }
    
    if math.Abs(totalWeight-1.0) > 0.01 {
        return fmt.Errorf("weights must sum to 1.0, got %f", totalWeight)
    }
    
    // フィールド存在チェック
    for _, comp := range config.Scoring.Components {
        if len(comp.Fields) == 0 && comp.Field == "" {
            return fmt.Errorf("component %s has no fields", comp.Name)
        }
    }
    
    return nil
}

A/Bテストの容易さ

設定駆動型の設計により、A/Bテストが非常に簡単になりました。

// バリアントAの設定
engineA, _ := NewConfigurableEngineFromFile("configs/dating/variant_a.json")

// バリアントBの設定
engineB, _ := NewConfigurableEngineFromFile("configs/dating/variant_b.json")

// ユーザーをランダムに振り分け
var matches []ScoredMatch
if user.ID % 2 == 0 {
    matches, _ = engineA.FindMatches(ctx, source, candidates)
} else {
    matches, _ = engineB.FindMatches(ctx, source, candidates)
}

まとめ

本記事では、数学的抽象化に基づくドメイン非依存のマッチングエンジンを設計し、実装しました。

実現できたこと

データ表現の統一
特徴ベクトルにより、恋愛マッチングからM&Aまで、異なるドメインを同じ構造で扱えるようになりました。

柔軟なビジネスルール
複数の数学的関数を組み合わせることで、複雑なビジネスルールを表現できます。重み付き合成とランキング戦略により、ドメイン特有の要件に対応できます。

コード変更不要の調整
JSON設定ファイルによる制御により、アルゴリズムの調整、A/Bテスト、新規ドメインの追加をコード変更なしで実現できます。

実用的なパフォーマンス
並列処理とキャッシングにより、大量の候補に対しても実用的な速度でマッチングを実行できます。

応用範囲

このアプローチは、マッチングシステムだけでなく、レコメンデーション、検索ランキング、スコアリングなど、様々な類似度計算が必要なシステムに応用できます。

ソースコードはGitHubで公開していますので、実際の実装を確認してください。

参考リンク

Discussion