Контекст
У нас есть 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 MarketingContextGraph MATCH (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_Logic ORDER BY d.timestamp DESC LIMIT 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_path FROM 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 из БД |
Нет |
|
Графовый язык |
GQL (декларативный) |
WITH RECURSIVE (императивный) |
Объединение вектор + граф |
В коде агента |
В одном SQL-запросе |
Новые типы рёбер |
Новая таблица + DDL |
INSERT с новым kind |
Минимальная стоимость |
~$100/мес (Spanner instance) |
$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
Комментарии (4)

headliner1985
06.06.2026 06:51Если очень нужно, можно добавить apache age для графового поиска в Postgres, там по перфомансу максимум 30% просадка по сравнению с графовыми бд, и то на очень больших данных. Для вас мне кажется идеальный вариант.

veriga Автор
06.06.2026 06:51я смотрел на него, но для AlloyDB нет готового рсширения apache age. Возможно, это политика гугла, и на голый postgres оно ставится, но я решил вообще пока не заморачиваться с графовыми запросами, а посмотреть как это работает на чисто реляционных отношениях. Эта субд хвалится новыми индексами, хочется понять, что там на самом деле происходит
AngryEvilCookie
На графе такого размера, если синтаксис сиквела устраивает, то нет смысла в graph db. Плюс GQL сложнее читать, даже учитывая что он сильно короче, имхо
veriga Автор
согласен. Хотя рекурсивные запросы на SQL тоже не легкое чтение