Почему мы выбрали рекурсивные SQL-запросы вместо GraphQL для графа знаний

от автора

Контекст

У нас есть wiki по архитектурам нейронной памяти — 72 страницы, хранящиеся в AlloyDB Omni (PostgreSQL 16.11) с pgvector для векторного поиска. Мы добавили граф знаний: типизированные рёбра между страницами, 11 типов связей, весовые коэффициенты. Граф обогатил retrieval: recall вырос с 46.7% до 68.3%.

Google недавно опубликовал context-graph-on-spanner — референсную реализацию контекстного графа на Cloud Spanner с встроенным Property Graph и GQL (Graph Query Language). Возник вопрос: стоит ли перенести наш граф на Spanner с его нативной поддержкой графов? Или остаться на AlloyDB?

Мы остались на AlloyDB. Вот почему.


Два подхода к графу

Spanner: Property Graph + GQL

Spanner с версии 7.0+ поддерживает Property Graph — декларативную схему графа поверх реляционных таблиц:

CREATE PROPERTY GRAPH MarketingContextGraph  NODE TABLES (Customers, Decisions, Policies, Outcomes)  EDGE TABLES (    AboutCustomer      SOURCE KEY (decision_id) REFERENCES Decisions (decision_id)      DESTINATION KEY (customer_id) REFERENCES Customers (customer_id),    FollowedPolicy      SOURCE KEY (decision_id) REFERENCES Decisions (decision_id)      DESTINATION KEY (policy_id) REFERENCES Policies (policy_id),    ResultedIn      SOURCE KEY (decision_id) REFERENCES Decisions (decision_id)      DESTINATION KEY (outcome_id) REFERENCES Outcomes (outcome_id)  );

Запросы — на GQL, декларативном языке паттерн-матчинга:

GRAPH MarketingContextGraphMATCH (c:Customers {industry: $1, tier: $2})<-[:AboutCustomer]-(d:Decisions {signal_type: $3})-[:ResultedIn]->(o:Outcomes)WHERE o.result = 'Renewed'RETURN d.decision_type AS Action_Type, d.reasoning_text AS Success_LogicORDER BY d.timestamp DESCLIMIT 3

Красиво. Лаконично. Паттерн-матчинг: «найди мне все пути Decision → Customer ← Decision → Outcome, где outcome = Renewed». Одна конструкция MATCH заменяет 20 строк SQL-джойнов.

AlloyDB: рекурсивные CTE

У нас нет GQL. Но есть WITH RECURSIVE — стандартный механизм PostgreSQL для обхода графов:

CREATE OR REPLACE FUNCTION graph_neighbors(  _label TEXT, _kind TEXT DEFAULT NULL,  _depth INT DEFAULT 1, _limit INT DEFAULT 20)RETURNS TABLE (node_label TEXT, node_kind TEXT, edge_kind TEXT, depth INT, wiki_path TEXT) AS $$WITH RECURSIVE traverse(id, node_label, node_kind, wiki_path, edge_kind, depth) AS (  -- Anchor: стартовая нода  SELECT n.id, n.label, n.kind, n.wiki_path, NULL, 0  FROM graph_nodes n WHERE n.label = _label  UNION ALL  -- Recursive step: обход по рёбрам в обе стороны  SELECT COALESCE(t.id, s.id), COALESCE(t.label, s.label),         COALESCE(t.kind, s.kind), COALESCE(t.wiki_path, s.wiki_path),         e.kind, p.depth + 1  FROM traverse p  JOIN graph_edges e ON (e.source_id = p.id OR e.target_id = p.id)  LEFT JOIN graph_nodes t ON t.id = e.target_id AND e.source_id = p.id  LEFT JOIN graph_nodes s ON s.id = e.source_id AND e.target_id = p.id  WHERE p.depth < _depth    AND (COALESCE(t.id, s.id) IS NOT NULL)    AND (_kind IS NULL OR e.kind = _kind))SELECT DISTINCT ON (node_label) node_label, node_kind, edge_kind, depth, wiki_pathFROM traverse WHERE depth > 0 ORDER BY node_label, depth LIMIT _limit;$$ LANGUAGE sql STABLE;

Громоздко? Да. Но работает.


Почему мы остались на SQL: пять причин

1. Одна база данных вместо двух

В нашем AlloyDB уже живут:

  • wiki_pages — полнотекстовый контент страниц

  • graph_nodes с pgvector — эмбеддинги для векторного поиска

  • graph_edges — рёбра графа знаний

  • ai.generate() — вызов Gemini прямо из SQL

Перенос графа на Spanner означает: векторы в AlloyDB, граф в Spanner, текст в AlloyDB. Каждый запрос — join между двумя базами. Это не упрощение, а усложнение.

На Spanner-сэмпле граф и так живёт отдельно — потому что у них нет векторов в Spanner. Но у нас векторы уже в AlloyDB. Разрывать их от графа — шаг назад.

2. Гибридный запрос в одном SQL

Наша ключевая функция — graph_context_for_query — делает векторный поиск и графовый обход в одном запросе:

WITH seeds AS (  -- Векторный поиск → 5 seed-нод  SELECT n.id, n.wiki_path, 1 - (n.embedding <=> _embedding) AS sim  FROM graph_nodes n ORDER BY n.embedding <=> _embedding LIMIT $2),hops AS (  -- Seed → hop 1 (прямые соседи)  SELECT nb.id, nb.wiki_path, ... FROM seeds s  JOIN graph_edges e ON e.source_id = s.id  JOIN graph_nodes nb ON nb.id = e.target_id  UNION ALL  -- Seed → hop 2 (соседи соседей)  SELECT nb2.id, nb2.wiki_path, ... FROM seeds s  JOIN graph_edges e ON e.source_id = s.id  JOIN graph_edges e2 ON e2.source_id = e.target_id  JOIN graph_nodes nb2 ON nb2.id = e2.target_id)SELECT ... FROM hops JOIN wiki_pages wp ON wp.path = h.wiki_path;

Вектор нашёл seed-страницы. Граф раскрыл контекст на 2 прыжка. Edge weights участвуют в ранжировании. Результат — одна таблица с rank_score = similarity × edge_weight / (depth + 1).

На Spanner это два отдельных вызова: векторный поиск в одном сервисе, GQL-запрос — в другом. Склеивать — в коде агента.

3. ai.generate() из SQL — Killer Feature

AlloyDB с google_ml_integration ≥ 1.5.2 позволяет вызывать Gemini прямо из plpgsql:

_result := ai.generate(_prompt);

Мы используем это для классификации рёбер: 205 нетипизированных mentions-рёбер получили конкретные типы (depends_on, develops, based_on) за $0.01 на весь прогон. Без промежуточного слоя, без экспорта-импорта.

Spanner не имеет встроенного ai.generate(). Классификация рёбер — внешний процесс, отдельный скрипт.

4. Таблица edge types — гибче, чем Property Graph DDL

Spanner требует отдельную таблицу для каждого типа ребра:

CREATE TABLE AboutCustomer (decision_id ..., customer_id ...) PRIMARY KEY (decision_id, customer_id);CREATE TABLE FollowedPolicy (decision_id ..., policy_id ...) PRIMARY KEY (decision_id, policy_id);CREATE TABLE ResultedIn (decision_id ..., outcome_id ...) PRIMARY KEY (decision_id, outcome_id);

Три типа рёбер — три таблицы. У нас 11 типов — это 11 таблиц? Или универсальная graph_edges с kind?

У нас — одна таблица graph_edges с текстовым полем kind. Хочешь добавить новый тип ребра — не нужно менять DDL, достаточно INSERT с новым значением kind. Это важно для растущей wiki, где типы связей эволюционируют.

Spanner-подход строже, но жёстче. Для enterprise-системы с фиксированной схемой — отлично. Для исследовательской wiki, где мы постоянно добавляем новые типы — слишком негибко.

5. Стоимость и сложность инфраструктуры

Аспект

Spanner + GQL

AlloyDB + SQL

База данных

Spanner ($$$, отдельный сервис)

AlloyDB Omni (уже работает на нашем VPS)

Векторный поиск

Нужен отдельный сервис (Vertex AI Matching Engine)

pgvector в той же БД

LLM из БД

Нет

ai.generate()

Графовый язык

GQL (декларативный)

WITH RECURSIVE (императивный)

Объединение вектор + граф

В коде агента

В одном SQL-запросе

Новые типы рёбер

Новая таблица + DDL

INSERT с новым kind

Минимальная стоимость

~$100/мес (Spanner instance)

$0 (уже работает)

Для стартапа/персонального проекта разница между «0 поверх существующей инфраструктуры» и «100/мес за новый сервис» — это не вопрос предпочтений, а вопрос выживания.


Чего нам не хватает без GQL

Честно — есть вещи, которые GQL делает лучше:

Паттерн-матчинг. Запрос «найди все пути A → B → C, где B — конкретный тип» на GQL — одна строка MATCH. На SQL — два JOIN + WITH RECURSIVE + вручную собирать путь. Код получается длиннее и сложнее для чтения.

Двунаправленный обход. GQL естественно обходит граф в обе стороны: ()-[]-() без указания направления. В нашем SQL приходится писать e.source_id = p.id OR e.target_id = p.id с COALESCE для определения, какая сторона — сосед.

Читаемость. GQL-запрос понимает любой, кто знаком с Cypher (Neo4j). SQL-функцию на 30 строк с рекурсией нужно разбирать.

Но для нашего use case — граф с 72 нодами и 215 рёбрами, обслуживающий 2-3 агента — это избыточная мощь. SQL справляется.


Как работает наш retrieval pipeline

Для полноты картины — весь путь от запроса до результата:

Пользователь спрашивает: «Как связаны MesaNet и Titans?»        │        ▼1. getEmbedding(query) → Vertex AI → 768-мерный вектор        │        ▼2. graph_context_for_query(embedding, top_k=5, hop_depth=1)   │   ├─ Векторный поиск: 5 seed-страниц (MesaNet, Titans, NLM, ...)   │   ├─ Hop 1: соседи seed-страниц по графу   │  → graph_edges JOIN graph_nodes   │  → edge_weight: depends_on=0.95, mentions=0.3   │   ├─ rank_score = similarity × edge_weight / (depth + 1)   │   └─ JOIN wiki_pages → содержимое страниц        │        ▼3. MCP-инструмент wiki_graph_context возвращает:[seed] MesaNet (sim=0.82)[seed] Titans (sim=0.78)[hop1] Surrogate Memory (via based_on, rank=0.62)[hop1] Memory as Context (via develops, rank=0.55)[hop2] Fast Weight Programmers (via depends_on, rank=0.31)        │        ▼4. LLM-агент получает структурированный контекст   и строит ответ со ссылками на конкретные концепты

Где GQL был бы кстати

Если бы wiki выросла до сотен страниц и десятков агентов — Spanner + GQL стал бы оправдан:

  • Более сложные паттерны: «найди все концепты, которые зависят от X, но противоречат Y, при этом Y основан на Z» — на GQL это вложенный MATCH, на SQL — каскад CTE.

  • Производительность: Spanner оптимизирован для графовых запросов на больших графах. CTE на 10 000+ нод может начать тормозить.

  • Стандартизация: GQL — стандарт ISO (GQL/ISO 39075). SQL-рекурсия — каждый пишет по-своему.

Но для наших масштабов пока слишком круто.


Вывод

Spanner Property Graph + GQL — элегантная технология для enterprise-графов с предсказуемой схемой и бюджетом. Но для персональной wiki, где:

  • векторы и текст уже живут в одной БД (AlloyDB),

  • граф маленький (< 1000 нод),

  • LLM вызывается прямо из SQL,

  • бюджет близок к нулю,

рекурсивные SQL-запросы — прагматичный выбор. Менее красивый, но более практичный.

Мы не отказались от GQL принципиально, но выбрали инструмент, соответствующий масштабу задачи. Если wiki вырастет — миграция на Spanner будет логичным следующим шагом. Но не раньше, чем в этом появится реальная необходимость.


Spanner: cloud-spanner-samples/context-graph-on-spanner

Стек: AlloyDB Omni 16.11, pgvector, google_ml_integration 1.5.2, Vertex AI, Gemini 2.5 Flash Lite

ссылка на оригинал статьи https://habr.com/ru/articles/1044164/