Контекст
У нас есть 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 из БД |
Нет |
|
|
Графовый язык |
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
ссылка на оригинал статьи https://habr.com/ru/articles/1044164/