Stateless REST API для расследовательской графовой аналитики на DuckDB с переключаемым graph query backend.
Основной проверенный backend для demo/MVP - DuckDB + DuckPGQ: canonical storage в DuckDB, обход графа через DuckPGQ projection. Остальные adapter-ы в кодовой базе нужны для R&D/benchmark-сравнения и считаются experimental/unverified, если для конкретного backend-а не прогнан отдельный сценарий.
Модель данных поддерживает generic AML graph: PERSON, ACCOUNT, COMPANY, DEVICE, ADDRESS и другие node types поверх общей схемы g_nodes/g_edges/g_identifiers.
POST /api/v1/graph/expand- умное 1-hop расширение для расследовательского графа с анти-hub ранжированиемGET /api/v1/graph/full- полный вывод всей canonical graph database для frontend canvasPOST /api/v1/graph/shortest-path- кратчайший путь (minimum hops) внутри выбранного relation familyPOST /api/v1/graph/query- старт расследования с безопасного read-only SQL-запросаPOST /api/v1/graph/import/previewи/import/commit- импорт CSV с нодами/ребрами в canonical graphGET /api/v1/graph/nodes/search- поиск опорной ноды по имени, идентификатору или атрибутам для ручного ресерчаGET /api/v1/graph/dictionary- справочник типов связей/статусов для легенды фронтаPOST /api/v1/graph/export?format=JSON|CSV|NDJSON- экспорт графа, который фронт уже собрал- Стабильные
nodeId/edgeIdдля merge на фронте - Backend не генерирует интерактивный HTML export: он отдает данные графа и stable IDs, а frontend отвечает за layout, hover, pinning, hide/merge UI и HTML export
- Метрики и health endpoints (
/actuator/*)
- Backend больше не привязан к
PERSON-only модели: relation families и node types можно расширять без изменения базовой схемы - Если
relationFamilyне передан, используется конфигурируемое значениеgraph.default-relation-family(по умолчаниюPERSON_KNOWS_PERSONдля обратной совместимости) - В seed-данных по-прежнему есть расследовательские семьи
PERSON_KNOWS_PERSON,PERSON_RELATIVE_PERSON,PERSON_SAME_CITY_PERSON - Контракт уже поддерживает и generic AML families:
ACCOUNT_FLOW,CUSTOMER_OWNERSHIP,SHARED_INFRASTRUCTURE,CORPORATE_CONTROL - Backend сам ограничивает первый экран графа: candidate budget, top-K по seed, global node/edge budget, hub suppression
- Полный вывод всей базы через
GET /graph/fullне требует seed и не режет результат лимитами; frontend получает всеg_nodesиg_edges
GraphController- HTTP слойInvestigationService- оркестрация расследовательских сценариев, ранжирование и budget-лимиты через backend-интерфейсGraphQueryBackendGraphNodeRepository,GraphEdgeRepository,GraphDictionaryRepository,GraphSqlRepository- резолв идентификаторов, чтение узлов/ребер, справочники и SQL-backed graph slicesDuckPgqGraphQueryRepository- текущая DuckPGQ-реализацияGraphQueryBackendNeo4jGraphQueryBackend,MemgraphGraphQueryBackend,KuzuGraphQueryBackend,PostgresAgeGraphQueryBackend,ArangoGraphQueryBackend,JanusGraphQueryBackend- experimental adapters для R&D benchmarkdb/migration- миграции Flyway (схема + seed)
Требования:
- Java 21+ (проверялось на Java 25)
- Maven wrapper (
./mvnwуже в репозитории)
Запуск:
./mvnw spring-boot:run -Dspring-boot.run.profiles=locallocal профиль создает DuckDB файл target/graph_local.db, включает Flyway и накатывает demo seed из src/main/resources/db/migration. Это воспроизводимый сценарий для чистого clone: никакой data/*.duckdb файл заранее не нужен. По умолчанию активен DuckPGQ.
Если нужен внешний FinBench dataset, используйте отдельный профиль:
./mvnw spring-boot:run -Dspring-boot.run.profiles=finbenchПрофиль finbench ожидает уже подготовленный ./data/finbench_sf0_1.duckdb и выключает Flyway, потому что база должна быть предзагружена. Подготовка FinBench описана в docs/finbench.md; это не обязательный path для demo/smoke.
docker compose up --build -dНа Windows самый простой сценарий для фронтенда:
.\scripts\finbench-docker.ps1Он подготовит data\finbench_sf0_1.duckdb, поднимет Docker и дождется health=UP.
Фронту после этого нужен backend URL:
http://localhost:18080
Если база уже есть, но могла быть старой, пересоберите ее тем же скриптом:
.\scripts\finbench-docker.ps1 -ForceDataDocker по умолчанию использует FinBench DuckDB-файл из локальной папки ./data:
./data/finbench_sf0_1.duckdb монтируется в контейнер как /data/graph_api/finbench_sf0_1.duckdb.
Flyway в Docker-сценарии выключен, потому что FinBench-база должна быть уже подготовлена.
Имя файла можно переопределить через переменную GRAPH_API_DUCKDB_FILE, например:
GRAPH_API_DUCKDB_FILE=finbench_smoke.duckdb docker compose up --build -dЕсли нужен другой host-порт, переопределите GRAPH_API_PORT:
GRAPH_API_PORT=19080 docker compose up --build -dПо умолчанию Docker Compose уже публикует API на 18080, чтобы не конфликтовать с локальными сервисами на 8080. Если фронт использует Vite, обычно достаточно:
VITE_API_BASE_URL=http://localhost:18080
Если файл отсутствует, контейнер завершится с ошибкой вместо создания пустой DuckDB. Подготовка FinBench описана в docs/finbench.md.
На Windows можно подготовить базу через PowerShell/WSL-обертку:
.\scripts\finbench-data.ps1 -DbPath data\finbench_sf0_1.duckdbЕсли после изменения seed-данных frontend видит старую базу от предыдущего Docker volume-сценария, удалите старый volume:
docker compose down -vПоднять Neo4j для альтернативного backend-а:
docker compose --profile neo4j up -d neo4jОстановить:
docker compose down- Swagger UI:
http://localhost:8080/swagger-ui.html - OpenAPI JSON:
http://localhost:8080/api-docs - Health:
http://localhost:8080/actuator/health - Prometheus:
http://localhost:8080/actuator/prometheus - Статический контракт:
src/main/resources/openapi/graph-api-v1.yaml
make smokeили
./scripts/smoke.shSmoke рассчитан на demo seed из Flyway (PARTY_1001, N_PARTY_1001, ACCOUNT_FLOW, CORPORATE_CONTROL) и проверяет health, dictionary, expand, shortest-path и export CSV против уже запущенного приложения. Для нестандартного порта передайте base URL первым аргументом:
./scripts/smoke.sh http://localhost:18080Краткая матрица backend/frontend ответственности вынесена в docs/customer-requirements-matrix.md.
Demo node/edge types не являются production-онтологией заказчика. Они используются для воспроизводимого smoke/demo seed и обоснованы публичной финансовой graph-моделью LDBC FinBench. Mapping и источники вынесены в docs/domain-taxonomy-basis.md.
Основной research runner сравнивает backend-и по одному workload-у и пишет воспроизводимый отчет:
BENCH_PREPARE_DATA=true BENCH_SCALE=serious BENCH_REQUESTS=300 BENCH_CONCURRENCY=8 make bench-suiteBackend-и подключаются декларативно через bench/backends/*.toml, workload-и через bench/workloads/*.toml. Новый backend добавляется реализацией GraphQueryBackend и отдельным TOML-файлом, без правки benchmark runner-а.
Список зарегистрированных СУБД и статус adapter-а:
./scripts/bench-suite.sh --list-backendsБыстрый curl-based smoke benchmark против уже запущенного приложения:
make benchНастройки quick benchmark:
BENCH_REQUESTS=300 BENCH_CONCURRENCY=8 BENCH_WARMUP=20 ./scripts/bench.sh http://localhost:8080Подробно: docs/benchmarking.md.
Базовый URL:
BASE="http://localhost:8080/api/v1"Готовая HTTP-коллекция для IntelliJ/VS Code REST Client:
docs/requests.http
Expand:
curl -s -X POST "$BASE/graph/expand" \
-H "Content-Type: application/json" \
-d '{
"seeds":[{"type":"PARTY_RK","value":"PARTY_1001"}],
"relationFamily":"PERSON_KNOWS_PERSON",
"direction":"OUTBOUND",
"maxNeighborsPerSeed":5,
"maxNodes":100,
"maxEdges":150,
"includeAttributes":true
}'Full database graph for frontend:
curl -s "$BASE/graph/full?includeAttributes=true"Expand by account seed:
curl -s -X POST "$BASE/graph/expand" \
-H "Content-Type: application/json" \
-d '{
"seeds":[{"type":"ACCOUNT_NO","value":"40817810000000002001"}],
"relationFamily":"ACCOUNT_FLOW",
"direction":"OUTBOUND",
"maxNeighborsPerSeed":5,
"maxNodes":100,
"maxEdges":150,
"includeAttributes":true
}'Expand with one-hop canvas context:
curl -s -X POST "$BASE/graph/expand" \
-H "Content-Type: application/json" \
-d '{
"seeds":[{"type":"NODE_ID","value":"N_PARTY_1001"}],
"direction":"OUTBOUND",
"filters":{"relationFamilies":["CUSTOMER_OWNERSHIP"],"nodeTypes":["ACCOUNT"]},
"exclude":{"nodeIds":["N_PARTY_1001","N_ACC_2001"],"edgeIds":[]},
"maxNeighborsPerSeed":50,
"maxNodes":100,
"maxEdges":150,
"includeAttributes":true
}'Expand preview with the same request body:
curl -s -X POST "$BASE/graph/expand/preview" \
-H "Content-Type: application/json" \
-d '{
"seeds":[{"type":"NODE_ID","value":"N_PARTY_1001"}],
"direction":"OUTBOUND",
"filters":{"relationFamilies":["CUSTOMER_OWNERSHIP"]},
"exclude":{"nodeIds":["N_PARTY_1001","N_ACC_2001"],"edgeIds":[]},
"maxNeighborsPerSeed":50,
"maxNodes":100,
"maxEdges":150
}'Shortest path:
curl -s -X POST "$BASE/graph/shortest-path" \
-H "Content-Type: application/json" \
-d '{
"source":{"type":"PARTY_RK","value":"PARTY_1001"},
"target":{"type":"PARTY_RK","value":"PARTY_1003"},
"relationFamily":"PERSON_KNOWS_PERSON",
"direction":"OUTBOUND",
"maxDepth":4
}'Shortest path to company by tax id:
curl -s -X POST "$BASE/graph/shortest-path" \
-H "Content-Type: application/json" \
-d '{
"source":{"type":"PARTY_RK","value":"PARTY_1001"},
"target":{"type":"TAX_ID","value":"7701234567"},
"relationFamily":"CORPORATE_CONTROL",
"direction":"OUTBOUND",
"maxDepth":2
}'Dictionary:
curl -s "$BASE/graph/dictionary"Node summary before expand:
curl -s "$BASE/graph/node-summary?nodeId=N_PARTY_1001"Node summary for a filtered expand preview:
curl -s "$BASE/graph/node-summary?nodeId=N_PARTY_1001&relationFamily=CUSTOMER_OWNERSHIP&direction=OUTBOUND"Search nodes for a manual anchor/seed:
curl -s "$BASE/graph/nodes/search?query=Alice&nodeType=PERSON&limit=10&includeAttributes=true"Start investigation from SQL seed query:
curl -s -X POST "$BASE/graph/query" \
-H "Content-Type: application/json" \
-d '{
"sql":"select node_id from g_nodes where is_blacklist = true",
"resultMode":"SEEDS",
"relationFamily":"ALL_RELATIONS",
"direction":"BOTH",
"maxNeighborsPerSeed":25,
"maxNodes":200,
"maxEdges":300,
"includeAttributes":true
}'Return graph slice from SQL edge query:
curl -s -X POST "$BASE/graph/query" \
-H "Content-Type: application/json" \
-d '{
"sql":"select edge_id from g_edges where tx_sum > 100000",
"resultMode":"GRAPH",
"maxNodes":200,
"maxEdges":300,
"includeAttributes":true
}'Import CSV preview:
curl -s -X POST "$BASE/graph/import/preview" \
-F "file=@graph-import.csv"Import CSV commit:
curl -s -X POST "$BASE/graph/import/commit" \
-F "file=@graph-import.csv"Минимальный CSV может содержать и ноды, и связи в одном файле:
record_type,node_id,node_type,display_name,party_rk,account_no,from_node_id,to_node_id,edge_id,edge_type,relation_family,directed
NODE,N_IMPORT_1,PERSON,Imported Customer,PARTY_IMPORT_1,,,,,,,
NODE,N_IMPORT_2,ACCOUNT,Imported Account,,40817810000000999999,,,,,,
EDGE,,,,,,N_IMPORT_1,N_IMPORT_2,E_IMPORT_1,OWNS,CUSTOMER_OWNERSHIP,trueПоддерживаемые node-колонки: node_id/id, node_type/entity_type, display_name/name, party_rk, person_id, phone_no/phone, full_name, is_blacklist, is_vip, employer, city, source_system, pagerank_score, hub_score, attrs_json, а также identifier_*.
Поддерживаемые edge-колонки: edge_id, from_node_id/source/from, to_node_id/target/to, edge_type/type/relation, relation_family, directed, tx_count, tx_sum, strength_score, evidence_count, source_system, first_seen_at, last_seen_at, attrs_json.
CSV import валидирует непустые numeric/date поля. Например, pagerank_score=not-a-number, tx_count=not-a-long, first_seen_at=not-an-instant вернут errors с rowNumber, field, value и не будут молча превращены в 0/null. Пустые optional-поля остаются допустимыми.
Export NDJSON:
curl -s -X POST "$BASE/graph/export?format=NDJSON" \
-H "Content-Type: application/json" \
-d '{
"nodes":[{"nodeId":"N1","displayName":"Node 1"}],
"edges":[]
}'Export CSV:
curl -s -X POST "$BASE/graph/export?format=CSV" \
-H "Content-Type: application/json" \
-d '{
"nodes":[{"nodeId":"N1","displayName":"Node 1"}],
"edges":[]
}'Основные env-флаги:
GRAPH_QUERY_BACKEND=DUCKPGQдля основного demo path;NEO4J,MEMGRAPH,POSTGRES_AGE,ARANGODB,JANUSGRAPH,KUZUдоступны как experimental adapter valuesGRAPH_DUCKPGQ_ENABLED=true|falseGRAPH_DUCKPGQ_AUTO_LOAD=true|falseGRAPH_DUCKPGQ_SYNC_GRAPH_STATE_ON_STARTUP=true|false
Поведение:
enabled=true, auto-load=true- backend поднимает projection tables и property graphs на стартеsync-graph-state-on-startup=false- extension загружается, но projection tables и property graphs не пересобираются автоматически- если активен
DUCKPGQиduckpgqнедоступен, приложение падает при старте
| backend | текущий статус |
|---|---|
| DuckDB + DuckPGQ | основной demo/MVP backend, покрыт integration smoke/test path |
| Neo4j | experimental adapter: есть код и unit-level coverage, production-ready поддержка не заявляется |
| Memgraph | experimental/unverified adapter для benchmark-кандидата |
| Kuzu | experimental/unverified adapter для benchmark-кандидата |
| PostgreSQL + Apache AGE | experimental/unverified adapter для benchmark-кандидата |
| ArangoDB | experimental/unverified adapter для benchmark-кандидата |
| JanusGraph | experimental/unverified adapter для benchmark-кандидата |
bench/backends/*.toml регистрируют кандидатов для исследования и не означают production-ready поддержку всех СУБД. Перед демонстрацией или защитой конкретного backend-а нужно отдельно прогнать его compose/service setup, projection sync, smoke и workload.
Основные env-флаги:
GRAPH_QUERY_BACKEND=NEO4JGRAPH_NEO4J_URI=bolt://localhost:7687GRAPH_NEO4J_USERNAME=neo4jGRAPH_NEO4J_PASSWORD=graph-api-passwordGRAPH_NEO4J_DATABASE=neo4jGRAPH_NEO4J_SYNC_GRAPH_STATE_ON_STARTUP=true|falseGRAPH_NEO4J_CLEAR_PROJECTION_ON_STARTUP=true|false
Поведение:
- Neo4j используется как graph query backend, а canonical
g_nodes/g_edges/g_identifiersпо-прежнему живут в DuckDB - при
sync-graph-state-on-startup=truebackend на старте пересобирает projection graph в Neo4j из текущих данных DuckDB - по умолчанию sync делает upsert projection-узлов/ребер без массового удаления;
clear-projection-on-startup=trueудаляет только projection-узлы с owner markergraph_api_v2 - API-контракт не меняется, но
meta.sourceстановитсяNEO4J
Для прода запускайте с профилем prod и задавайте секреты/разрешенные origin-ы явно:
SPRING_PROFILES_ACTIVE=prod \
GRAPH_CORS_ALLOWED_ORIGINS=https://app.example.com \
GRAPH_NEO4J_PASSWORD=... \
java -jar app.jarВ prod профиле Swagger/OpenAPI выключены по умолчанию, health details скрыты, unsigned DuckDB extensions запрещены по умолчанию, а Neo4j startup sync выключен до явного GRAPH_NEO4J_SYNC_GRAPH_STATE_ON_STARTUP=true.
- Основной merge-friendly формат:
nodes[],edges[],meta - Для ручной опорной ноды фронт может дергать
GET /graph/nodes/search?query=..., показывать найденныеnodes[], а выбранный результат передавать вexpandкак seed{ "type": "NODE_ID", "value": nodeId } - Для сценария
Start from queryфронт может дергатьPOST /graph/query:SEEDSожидает SQL сnode_idи затем расширяет найденные seed-ноды,GRAPHожидаетnode_id,edge_idилиsource/targetи возвращает готовый срез графа - Для сценария
Start from fileфронт загружает CSV вPOST /graph/import/preview, показывает counts/errors, затем по подтверждению пользователя отправляет тот же файл вPOST /graph/import/commit - Для сценария "показать всю базу" фронт дергает
GET /graph/full?includeAttributes=true: backend возвращает все узлы и все ребра canonical graph без seed-ов и лимитов - Перед
expandможно дергатьGET /graph/node-summary?nodeId=...и показывать пользователю сводку по клику на узел node-summaryвозвращает общие counts по соседям, разбивку поrelationFamilies,edgeTypes,neighborNodeTypesи признак, урежет ли узел дефолтный budget expand-аPOST /graph/expand/previewпринимает тот же body, что иexpand, и возвращает counts/facets уже с учетомfiltersиexcludefilters.relationFamilies/filters.edgeTypesимеют приоритет над legacyrelationFamily/edgeTypes; дополнительно поддерживаютсяnodeTypes,nodeAttributes,edgeAttributesexclude.nodeIdsозначает "ноды уже есть на холсте": они не возвращаются вnodes[]и не считаются как новые, но новые связи к ним возвращаютсяexclude.edgeIdsозначает "связи уже есть на холсте": они исключаются и из preview, и из expandnodes[]теперь могут нестиnodeTypeи genericidentifiersedges[]теперь могут нестиrelationFamily,sourceSystem,firstSeenAt,lastSeenAtmeta.sourceприходит от активного backend-а:DUCKPGQилиNEO4Jmeta.relationFamily,meta.rankingStrategy,meta.candidateEdgeCount,meta.warningsобъясняют, как backend сузил результатexpandне хранит серверное UI-состояние: фронт присылает локальный one-hop context черезexclude, а backend возвращает только новые элементы относительно этого контекста- JSON/CSV/NDJSON export - backend responsibility; интерактивный HTML export - frontend responsibility, потому что он зависит от layout, pinning, hover, hide nodes и merge UI
- Контрактные заглушки интеграции:
src/main/java/com/pm/graph_api_v2/integration
- Security/auth, multi-tenant authorization и audit trail не реализованы; это production gap, а не часть demo backend scope.
- Интерактивный HTML export, layout gravity, pinning, hover, hide nodes и визуальный merge UI реализуются на frontend.
- Experimental graphDB adapters требуют отдельной проверки перед заявлением production-ready поддержки.
make test
make run
make up
make logs
make down