30分で Spanner の検索とグラフクエリを試す

この記事は TVer Advent Calendar 2025 18日目の記事です。

はじめに

Backend Enabling Team の小林 (@k0bya4) です。

TVer のサービスユーザー向けメインバックエンドシステムは AWS で構築していますが、サブシステムとして全文検索・ベクトル検索・グラフクエリなどの機能を、バックエンドエンジニアが RDB の知識を活かしながら運用できないか検証しています。Spanner はこれらの機能を 1 つのデータベースで提供しており、今回は Google Cloud が提供するサンプルデータセットを使って、30 分ほどでクイックにその使用感を確認してみました。

Spanner Graph が 2025年1月に GA となりました。Spanner は従来からリレーショナルデータベースとしての機能を持っていますが、グラフクエリ(GQL)、全文検索、ベクトル検索といった機能が追加され、1つのデータベースで多様なワークロードに対応できるようになっています。

本記事では、Retail サンプルデータセットを使って、全文検索、ベクトル検索、グラフクエリによるレコメンドを実際に動かしてみます。サンプルデータセットを使えば、スキーマ設計やデータ投入なしですぐに試すことができます。

環境構築

本記事では Google Cloud が提供する Retail サンプルデータセットを使用します。これは EC サイトを模したデータで、ユーザー、商品、注文などのテーブルがあらかじめ用意されています。

Retail サンプルには Spanner Graph、全文検索、ベクトル検索を実行するための構文とサンプルクエリが含まれているため、Enterprise エディション以上のインスタンスが必要です。

インスタンスの作成からサンプルデータセットの投入まで、こちらの記事が参考になります。構築を試す際には参照してください。

本記事では retail データベースが作成済みの前提で進めます。

Retail データセットの概要

Retail サンプルは EC サイトを模したデータセットで、以下のテーブルで構成されています。

テーブル名 役割 グラフでの扱い
Users ユーザー情報 Node
Products 商品情報 Node
Orders 注文情報 Node
Payments 支払い情報 Node
Addresses 住所情報 Node
OrderItems 注文と商品の関連 Edge(Orders → Products)
ShoppingCarts カートと商品の関連 Edge(Users → Products)

全文検索の設定

Spanner の全文検索には複数の種類があります。Retail サンプルでは TOKENIZE_FULLTEXT を使った基本的な全文検索が設定されています。TOKENIZE_FULLTEXT自然言語テキストを単語単位でトークン化し、キーワード検索に適しています。

Products テーブルには商品名をトークン化した列が定義されています。

Name_Tokens TOKENLIST AS (TOKENIZE_FULLTEXT(Name)) HIDDEN

この列に対して Search Index が作成されており、SEARCH() 関数でキーワード検索ができます。全文検索の詳細は公式ドキュメントを参照してください。

ベクトル類似度検索の設定

Products テーブルには商品の埋め込み表現のベクトルが格納されています。埋め込み表現は Vertex AI の Embedding API などで生成でき、テキストや画像の意味的な特徴を数値ベクトルとして表現したものです。

ProductEmbedding ARRAY<FLOAT64>(vector_length=>768)

大量のベクトル同士の類似度計算を高速化するため、Vector Index が設定されています。

CREATE VECTOR INDEX ProductVectorIndex ON Products(ProductEmbedding)
  WHERE ProductEmbedding IS NOT NULL
  OPTIONS (distance_type = 'COSINE');

Property Graph の定義

グラフクエリを実行するには、テーブルを Node や Edge として定義した Property Graph が必要です。Retail サンプルでは以下のように定義されています。

CREATE PROPERTY GRAPH ECommerceGraph
  NODE TABLES (
    Users,
    Products,
    Orders,
    Payments,
    Addresses
  )
  EDGE TABLES (
    OrderItems
      SOURCE KEY (OrderID) REFERENCES Orders
      DESTINATION KEY (ProductID) REFERENCES Products,
    ShoppingCarts
      SOURCE KEY (UserID) REFERENCES Users
      DESTINATION KEY (ProductID) REFERENCES Products
  );

Node は既存のテーブルをそのまま指定します。Edge では SOURCE KEYDESTINATION KEY で接続元・接続先の Node を指定します。

OrderItems Edge は「どの注文にどの商品が含まれるか」、ShoppingCarts Edge は「どのユーザーがどの商品をカートに入れているか」という関係を表現しています。

Property Graph の詳細は公式ドキュメントを参照してください。

全文検索を試す

全文検索を使って商品名からキーワード検索してみましょう。Retail サンプルには以下のようなクエリが用意されています。

SELECT ProductID, Name, PriceUSD
FROM Products
WHERE SEARCH(Name_Tokens, 'phone')
ORDER BY PriceUSD DESC
LIMIT 5;

SEARCH() 関数の第一引数にはトークン化された列(Name_Tokens)、第二引数には検索キーワードを指定します。このクエリは「phone」を含む商品を価格の高い順に 5 件取得します。

実行結果:

ProductID Name PriceUSD
201414 Smith-Allen Mobile Phone 1086.15
197251 Lutz-Howard Phone 974.94
301629 Garza-Rogers Cell Phone Plus 69 908.03
581741 Brock, Phone 830.75
377370 Adkins, Cell Phone Plus 58 734.93

AND / OR 検索

複数キーワードでの検索も可能です。スペース区切りで AND 検索、OR 演算子で OR 検索になります。

AND 検索(両方を含む):

SELECT ProductID, Name, PriceUSD
FROM Products
WHERE SEARCH(Name_Tokens, 'cell phone')
ORDER BY PriceUSD DESC
LIMIT 5;
ProductID Name PriceUSD
301629 Garza-Rogers Cell Phone Plus 69 908.03
377370 Adkins, Cell Phone Plus 58 734.93
865179 Jones, Cell Phone Pro 43 709.21
407419 Farley, Cell Phone 625.71
988662 Johnson Cell Phone Plus 28 395.94

OR 検索(いずれかを含む):

SELECT ProductID, Name, PriceUSD
FROM Products
WHERE SEARCH(Name_Tokens, 'cell OR phone')
ORDER BY PriceUSD DESC
LIMIT 5;
ProductID Name PriceUSD
201414 Smith-Allen Mobile Phone 1086.15
197251 Lutz-Howard Phone 974.94
301629 Garza-Rogers Cell Phone Plus 69 908.03
581741 Brock, Phone 830.75
377370 Adkins, Cell Phone Plus 58 734.93

AND 検索では「Cell Phone」を両方含む商品のみ、OR 検索では「Mobile Phone」のように片方のみ含む商品も結果に含まれています。

日本語テキストの検索

全文検索は日本語にも対応しています。テストデータで確認してみましょう。

INSERT INTO Products (ProductID, Name, Description, PriceUSD)
VALUES (99999901, 'スマートフォン Pro Max', 'Latest smartphone', 999.99);

SELECT ProductID, Name FROM Products
WHERE SEARCH(Name_Tokens, 'スマートフォン');
ProductID Name
99999901 スマートフォン Pro Max

スマートフォン」ではヒットしますが、「スマート」や「フォン」ではヒットしませんでした。公式ドキュメントによると、日本語は自動でセグメンテーションされます。分割の粒度は内部の辞書に依存するため、検索対象のテキストに応じてどのようなキーワードでヒットするかを事前に確認しておくと良いでしょう。

部分文字列での検索が必要な場合は、TOKENIZE_NGRAMSTOKENIZE_SUBSTRING の使用を検討してください。TOKENIZE_NGRAMS は文字列を N 文字単位で分割してトークン化するため、「スマート」のような部分文字列でもヒットするようになります。

トークンの分割結果は DEBUG_TOKENLIST() 関数で確認できます。

SELECT Name, DEBUG_TOKENLIST(Name_Tokens) AS tokens
FROM Products
WHERE ProductID = 99999901;
Name tokens
スマートフォン Pro Max スマートフォン(boundary), pro, max(end_boundary)

スマートフォン」が1つのトークンとして扱われているため、部分文字列の「スマート」や「フォン」ではヒットしません。

ベクトル類似度検索を試す

ベクトル類似度検索を使って、ある商品に似た商品を検索してみましょう。

まず、埋め込み表現のベクトルを持つ商品を確認します。

SELECT ProductID, Name, ARRAY_LENGTH(ProductEmbedding) AS embedding_dim
FROM Products
WHERE ProductID = 123;
ProductID Name embedding_dim
123 High-end Smartphone Model X 768

ProductID 123 は「High-end Smartphone Model X」で、768次元の埋め込みベクトルを持っています。この商品を基準に、他の商品とのコサイン距離を計算します。COSINE_DISTANCE() はコサイン距離を返し、値が小さいほど類似度が高いことを示します。

WITH ReferenceProduct AS (
  SELECT ProductEmbedding FROM Products WHERE ProductID = 123
)
SELECT p.ProductID, p.Name, COSINE_DISTANCE(rp.ProductEmbedding, p.ProductEmbedding) AS distance
FROM Products p, ReferenceProduct rp
WHERE p.ProductID != 123 AND p.ProductEmbedding IS NOT NULL
ORDER BY distance
LIMIT 5;

実行結果:

ProductID Name distance
748564 Gonzales, Phone 0.160337
733052 Mathews Smartphone Pro 16 0.160598
789 Premium Wireless Headphones 0.167190
476417 Dixon, Mobile Phone Pro 37 0.179702
865179 Jones, Cell Phone Pro 43 0.185011

基準の商品「High-end Smartphone Model X」に対して、Phone や Smartphone といった類似カテゴリの商品が上位に並んでいます。

グラフクエリでレコメンドを試す

グラフクエリを使って、よく一緒に購入される商品を見つけてみましょう。「この商品を買った人はこんな商品も買っています」のようなレコメンド機能の基礎となる分析です。

SQL での分析

まず、公式サンプルに含まれる SQL 版のクエリを実行します。

SELECT
  p1.ProductID AS product1_id,
  p1.Name AS product1_name,
  p2.ProductID AS product2_id,
  p2.Name AS product2_name,
  COUNT(*) AS frequency
FROM OrderItems oi1
JOIN OrderItems oi2
  ON oi1.OrderID = oi2.OrderID AND oi1.ProductID < oi2.ProductID
JOIN Products p1 ON oi1.ProductID = p1.ProductID
JOIN Products p2 ON oi2.ProductID = p2.ProductID
GROUP BY p1.ProductID, p1.Name, p2.ProductID, p2.Name
ORDER BY frequency DESC
LIMIT 5;
product1_id product1_name product2_id product2_name frequency
270555 Roy, Camping Chair 733052 Mathews Smartphone Pro 16 6
205907 Silva-Navarro Smart Yoga Mat 974628 Quinn-Burton Deluxe Sleeping Bag 5
182627 Peterson, mobile device 838797 Conley Smartphone 5
662275 Wood-Oneal Smartphone Max 79 759176 Moreno mobile device Max 10 5
789 Premium Wireless Headphones 733052 Mathews Smartphone Pro 16 5

同じ注文に含まれる商品ペアを集計しています。自己結合と複数の JOIN が必要で、クエリがやや複雑です。

GQL での分析

同じ分析を GQL で書くと、関係をパターンとして直感的に表現できます。

GRAPH ECommerceGraph
MATCH (p1:Products)<-[:OrderItems]-(o:Orders)-[:OrderItems]->(p2:Products)
WHERE p1.ProductID < p2.ProductID
RETURN p1.ProductID AS ProductID1, p1.Name AS Name1, p2.ProductID AS ProductID2, p2.Name AS Name2, COUNT(*) AS frequency
GROUP BY ProductID1, Name1, ProductID2, Name2
ORDER BY frequency DESC
LIMIT 5;
ProductID1 Name1 ProductID2 Name2 frequency
270555 Roy, Camping Chair 733052 Mathews Smartphone Pro 16 6
205907 Silva-Navarro Smart Yoga Mat 974628 Quinn-Burton Deluxe Sleeping Bag 5
182627 Peterson, mobile device 838797 Conley Smartphone 5
662275 Wood-Oneal Smartphone Max 79 759176 Moreno mobile device Max 10 5
789 Premium Wireless Headphones 733052 Mathews Smartphone Pro 16 5

MATCH 句の (p1:Products)<-[:OrderItems]-(o:Orders)-[:OrderItems]->(p2:Products) で「注文を介して繋がる2つの商品」という関係を1行で表現しています。SQL 版で必要だった自己結合や複数の JOIN が不要になり、データの関係性が読み取りやすくなります。

まとめ

Retail サンプルデータセットを使って、Spanner の 3 つの機能を試しました。

機能 用途 使用例
全文検索 キーワードによる検索 商品名から「phone」を含む商品を検索
ベクトル類似度検索 意味的に似たアイテムの検索 ある商品に似た商品を検索
グラフクエリ 関係性をたどる分析 よく一緒に購入される商品を検索

これらの機能は 1 つのデータベースに統合されており、ユースケースに応じて使い分けたり組み合わせたりできます。

Retail サンプルには本記事で紹介した以外にもクエリが用意されています。ぜひ他のクエリも試してみてください。

TVer では今後、実際のサービスデータを想定した検証を進めていく予定です。