1. Почему синтаксис влияет на GraphRAG
GraphRAG сочетает поиск по структуре графа с генерацией ответа. В простейшем варианте система находит стартовые сущности, извлекает окружающие узлы и рёбра, помещает их в запрос и просит модель вывести ответ. На практике наиболее полезны вопросы, где требуется multi-hop reasoning: пройти от исходной сущности через две или более связи, отфильтровать неподходящие ветви и объяснить найденный путь.
После извлечения подграф нужно превратить в текст. Этот этап называется сериализацией. Он часто воспринимается как нейтральная техническая операция, хотя именно здесь структура данных превращается в последовательность токенов, доступную языковой модели.
JSON удобен для программ: у него явные поля, вложенность и широкая поддержка библиотеками. Но запись из десяти рёбер может десятки раз повторять ключи source, relation и target. В контекстном окне эти служебные элементы конкурируют с названиями сущностей, описаниями отношений, вопросом и инструкцией.
Проблема не сводится к цене входных токенов. Формат меняет:
- число рёбер, которые помещаются в заданный бюджет;
- расстояние между логически связанными фрагментами;
- заметность направления и типа каждого ребра;
- вероятность того, что модель перепутает идентификатор с естественным текстом;
- устойчивость разбора ответа и возможность автоматической проверки;
- положение полезных фактов относительно начала и конца длинного контекста.
Следовательно, «самый компактный» и «самый точный» формат могут оказаться разными. Нужен контролируемый тест, в котором данные, вопросы, модельные параметры и оценивание фиксированы, а меняется только сериализация.
2. Конкретный тестовый случай
Используем небольшой синтетический граф исследовательских проектов. Он не содержит customer-фактов и не претендует на статистическую репрезентативность. Его назначение — сделать процедуру полностью повторяемой и показать ошибки, характерные для многошагового поиска.
В графе есть исследователи, проекты, методы, организации и города. Основной проверочный вопрос:
Какой город связан с методом, который использует проект под руководством Ирины?
Эталонный путь состоит из четырёх рёбер:
person_irina —LEADS→ project_orionproject_orion —USES→ method_graphmethod_graph —DEVELOPED_BY→ org_vectororg_vector —LOCATED_IN→ city_kazan
Ожидаемый ответ — «Казань». В граф добавлены конкурирующие ветви: другой проект Ирины, другой метод и организация в другом городе. Без них модель могла бы угадать ответ по единственной цепочке, не выполняя точный обход.
Набор проверочных вопросов
| ID | Задача | Длина пути | Эталон |
|---|---|---|---|
| q1 | Где находится организация, разработавшая метод проекта под руководством Ирины? | 4 | Казань |
| q2 | Кто руководит проектом, использующим метод организации из Томска? | 4 | Максим |
| q3 | Какой метод использует проект участника лаборатории «Север»? | 3 | Векторный поиск |
| q4 | В каком городе разработан метод проекта «Орион»? | 3 | Казань |
| q5 | Назови проект, который использует «Семантическое сжатие». | 1 | Атлас |
| q6 | Есть ли в графе город организации, разработавшей «Гибридный обход»? | 2 | Нет данных |
Последний вопрос — проверка воздержания. Если у метода нет полного пути до города, система не должна достраивать недостающее ребро из общих знаний или соседних ветвей.
3. Пять форматов одного графа
Бенчмарк сравнивает пять представлений. Они выбраны не как исчерпывающий каталог, а как практический диапазон от многословного структурированного формата до компактной доменной записи.
3.1. Полный JSON
{
"edges": [
{
"source": "person_irina",
"relation": "LEADS",
"target": "project_orion"
}
]
}
Преимущества: строгая структура, стандартный парсер, понятные имена полей. Недостатки: повторение ключей, кавычек и пунктуации. Вложенные свойства увеличивают издержки ещё сильнее.
3.2. JSONL
JSONL хранит один объект на строку:
{"s":"person_irina","r":"LEADS","t":"project_orion"}
{"s":"project_orion","r":"USES","t":"method_graph"}
Обёртка массива исчезает, а ключи можно сократить. Формат остаётся машинно разбираемым, но смысл s, r и t необходимо один раз определить в инструкции.
3.3. CSV/TSV
CSV или табличный вариант с табуляцией записывает схему один раз:
source relation target
person_irina LEADS project_orion
project_orion USES method_graph
Для идентификаторов без переводов строк и табуляций это компактное представление. Однако экранирование естественного текста и запятых может усложнить как сериализацию, так и чтение моделью. В тесте применяется TSV, чтобы русские названия с запятыми не требовали дополнительных кавычек.
3.4. Список рёбер
person_irina -LEADS-> project_orion
project_orion -USES-> method_graph
Такой формат хорошо показывает направление. Он почти не содержит служебных слов, но требует ограничения символов в идентификаторах и однозначного определения грамматики.
3.5. Компактный DSL
DSL — минимальный предметно-ориентированный синтаксис:
@ person_irina|Ирина|person
@ project_orion|Орион|project
> person_irina|LEADS|project_orion
Строки @ задают словарь узлов, а строки > — рёбра. Идентификаторы отделены от отображаемых имён. Это уменьшает повторение длинных названий, но добавляет необходимость обучить модель локальной грамматике.
4. Метрики и правила оценки
4.1. Размер в байтах
Полезен для хранения и сетевой передачи, но не является заменой подсчёту токенов. Два текста одинаковой длины могут токенизироваться по-разному.
4.2. Число токенов
Токенизация зависит от конкретной модели или кодировки. Поэтому отчёт обязан указывать:
- имя токенизатора;
- версию библиотеки;
- считался ли только граф или полный запрос;
- включались ли системная инструкция, вопрос и схема ответа;
- использовалась ли нормализация Unicode.
Основная величина для сравнения — число токенов полного запроса. Дополнительно полезно считать только сериализованный граф, чтобы видеть вклад формата отдельно.
4.3. Точность восстановления рёбер
До обращения к языковой модели каждый формат декодируется обратно в канонический набор троек. Метрика:
edge_roundtrip_accuracy =
число совпавших рёбер / число эталонных рёбер
Для формата без потерь значение должно быть равно 1. Любое меньшее значение делает дальнейшее сравнение некорректным.
4.4. Точность пути
Модель должна вернуть не только короткий ответ, но и последовательность идентификаторов рёбер. Для каждого вопроса сравниваем предсказанный путь с эталонным:
path_exact_match— совпадает ли вся последовательность;edge_precision— доля предсказанных рёбер, входящих в эталон;edge_recall— доля эталонных рёбер, найденных моделью;valid_path— образуют ли рёбра непрерывную направленную цепочку.
4.5. Качество конечного ответа
Для синтетического набора используйте детерминированную нормализацию и точное совпадение, а не оценку другой моделью. Удалите внешние пробелы, приведите регистр, замените ё на е. Не применяйте нечёткое совпадение там, где допустим один короткий ответ.
4.6. Воздержание
Для вопросов без полного пути проверяется точная метка INSUFFICIENT_DATA. Это отделяет полезный отказ от галлюцинации.
4.7. Стоимость
Не фиксируйте тарифы в коде: они меняются. Бенчмарк должен выводить токены, а стоимость рассчитывать из явно переданных параметров:
input_cost = input_tokens / 1_000_000 × input_price_per_million
output_cost = output_tokens / 1_000_000 × output_price_per_million
Если цены не переданы, поле стоимости оставляется пустым. Это лучше, чем публиковать устаревшее или выдуманное значение.
5. Структура воспроизводимого эксперимента
graphrag-format-benchmark/
├── data/
│ ├── graph.json
│ └── questions.json
├── outputs/
├── benchmark.py
├── requirements.txt
└── README.md
Создайте виртуальное окружение и установите зависимости:
python -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install tiktoken
Файл requirements.txt после успешной установки лучше зафиксировать командой:
python -m pip freeze > requirements.lock.txt
Это важно: изменения токенизатора могут изменить измерения даже при неизменном исходном тексте.
Канонический граф
Сохраните следующий объект в data/graph.json:
{
"nodes": [
{"id":"person_irina","name":"Ирина","type":"person"},
{"id":"person_maxim","name":"Максим","type":"person"},
{"id":"project_orion","name":"Орион","type":"project"},
{"id":"project_atlas","name":"Атлас","type":"project"},
{"id":"project_pulse","name":"Пульс","type":"project"},
{"id":"method_graph","name":"Графовый поиск","type":"method"},
{"id":"method_compress","name":"Семантическое сжатие","type":"method"},
{"id":"method_vector","name":"Векторный поиск","type":"method"},
{"id":"method_hybrid","name":"Гибридный обход","type":"method"},
{"id":"org_vector","name":"Институт Вектор","type":"organization"},
{"id":"org_siberia","name":"Центр Сибирь","type":"organization"},
{"id":"org_north","name":"Лаборатория Север","type":"organization"},
{"id":"city_kazan","name":"Казань","type":"city"},
{"id":"city_tomsk","name":"Томск","type":"city"}
],
"edges": [
{"source":"person_irina","relation":"LEADS","target":"project_orion"},
{"source":"person_irina","relation":"MEMBER_OF","target":"org_north"},
{"source":"person_maxim","relation":"LEADS","target":"project_pulse"},
{"source":"project_orion","relation":"USES","target":"method_graph"},
{"source":"project_atlas","relation":"USES","target":"method_compress"},
{"source":"project_pulse","relation":"USES","target":"method_vector"},
{"source":"method_graph","relation":"DEVELOPED_BY","target":"org_vector"},
{"source":"method_compress","relation":"DEVELOPED_BY","target":"org_north"},
{"source":"method_vector","relation":"DEVELOPED_BY","target":"org_siberia"},
{"source":"method_hybrid","relation":"DEVELOPED_BY","target":"org_north"},
{"source":"org_vector","relation":"LOCATED_IN","target":"city_kazan"},
{"source":"org_siberia","relation":"LOCATED_IN","target":"city_tomsk"},
{"source":"org_north","relation":"RUNS","target":"project_atlas"}
]
}
Вопросы и эталонные пути
Сохраните в data/questions.json:
[
{
"id":"q1",
"question":"Где находится организация, разработавшая метод проекта под руководством Ирины?",
"answer":"Казань",
"path":[0,3,6,10],
"insufficient":false
},
{
"id":"q2",
"question":"Кто руководит проектом, использующим метод организации из Томска?",
"answer":"Максим",
"path":[11,8,5,2],
"insufficient":false
},
{
"id":"q3",
"question":"Какой метод использует проект участника лаборатории Север?",
"answer":"Графовый поиск",
"path":[1,0,3],
"insufficient":false
},
{
"id":"q4",
"question":"В каком городе разработан метод проекта Орион?",
"answer":"Казань",
"path":[3,6,10],
"insufficient":false
},
{
"id":"q5",
"question":"Назови проект, который использует Семантическое сжатие.",
"answer":"Атлас",
"path":[4],
"insufficient":false
},
{
"id":"q6",
"question":"Есть ли в графе город организации, разработавшей Гибридный обход?",
"answer":"INSUFFICIENT_DATA",
"path":[9],
"insufficient":true
}
]
Индексы путей ссылаются на положение ребра в каноническом массиве. Для production-набора лучше дать рёбрам устойчивые ID, потому что перестановка массива изменит индексы.
6. Реализация бенчмарка
Ниже — самостоятельный скрипт для сериализации, обратного разбора, подсчёта токенов и подготовки запросов. Он не вызывает внешнюю модель и поэтому сразу воспроизводит безопасную часть эксперимента.
from __future__ import annotations
import argparse
import csv
import hashlib
import io
import json
import unicodedata
from pathlib import Path
import tiktoken
ROOT = Path(__file__).resolve().parent
DATA = ROOT / "data"
OUTPUTS = ROOT / "outputs"
def canonical_edge(edge):
return (
str(edge["source"]),
str(edge["relation"]),
str(edge["target"]),
)
def canonical_graph(graph):
nodes = sorted(
(
str(n["id"]),
str(n["name"]),
str(n["type"]),
)
for n in graph["nodes"]
)
edges = sorted(canonical_edge(e) for e in graph["edges"])
return {"nodes": nodes, "edges": edges}
def serialize_full_json(graph):
return json.dumps(
graph,
ensure_ascii=False,
indent=2,
sort_keys=True,
)
def parse_full_json(text):
return json.loads(text)
def serialize_jsonl(graph):
lines = []
for node in graph["nodes"]:
lines.append(json.dumps({
"k": "n",
"i": node["id"],
"n": node["name"],
"t": node["type"],
}, ensure_ascii=False, separators=(",", ":")))
for edge in graph["edges"]:
lines.append(json.dumps({
"k": "e",
"s": edge["source"],
"r": edge["relation"],
"t": edge["target"],
}, ensure_ascii=False, separators=(",", ":")))
return "\n".join(lines)
def parse_jsonl(text):
nodes, edges = [], []
for line in text.splitlines():
if not line.strip():
continue
item = json.loads(line)
if item["k"] == "n":
nodes.append({
"id": item["i"],
"name": item["n"],
"type": item["t"],
})
elif item["k"] == "e":
edges.append({
"source": item["s"],
"relation": item["r"],
"target": item["t"],
})
else:
raise ValueError(f"Unknown record kind: {item['k']}")
return {"nodes": nodes, "edges": edges}
def serialize_tsv(graph):
out = io.StringIO()
writer = csv.writer(
out,
delimiter="\t",
lineterminator="\n",
quoting=csv.QUOTE_MINIMAL,
)
writer.writerow(["kind", "id/source", "name/relation", "type/target"])
for node in graph["nodes"]:
writer.writerow(["node", node["id"], node["name"], node["type"]])
for edge in graph["edges"]:
writer.writerow([
"edge",
edge["source"],
edge["relation"],
edge["target"],
])
return out.getvalue().rstrip("\n")
def parse_tsv(text):
nodes, edges = [], []
reader = csv.DictReader(io.StringIO(text), delimiter="\t")
for row in reader:
if row["kind"] == "node":
nodes.append({
"id": row["id/source"],
"name": row["name/relation"],
"type": row["type/target"],
})
elif row["kind"] == "edge":
edges.append({
"source": row["id/source"],
"relation": row["name/relation"],
"target": row["type/target"],
})
else:
raise ValueError(f"Unknown row kind: {row['kind']}")
return {"nodes": nodes, "edges": edges}
def reject_delimiters(value, forbidden):
if any(char in value for char in forbidden):
raise ValueError(
f"Unsafe delimiter in value {value!r}; "
"escape it or choose another grammar"
)
def serialize_edge_list(graph):
lines = ["NODES"]
for node in graph["nodes"]:
for value in (node["id"], node["name"], node["type"]):
reject_delimiters(value, "|\n\r")
lines.append(
f'{node["id"]}|{node["name"]}|{node["type"]}'
)
lines.append("EDGES")
for edge in graph["edges"]:
for value in canonical_edge(edge):
reject_delimiters(value, "\n\r")
lines.append(
f'{edge["source"]} -{edge["relation"]}-> {edge["target"]}'
.replace(">", ">")
)
return "\n".join(lines)
def parse_edge_list(text):
nodes, edges = [], []
section = None
for line in text.splitlines():
if line == "NODES":
section = "nodes"
continue
if line == "EDGES":
section = "edges"
continue
if not line:
continue
if section == "nodes":
node_id, name, node_type = line.split("|", 2)
nodes.append({
"id": node_id,
"name": name,
"type": node_type,
})
elif section == "edges":
source, rest = line.split(" -", 1)
relation, target = rest.split("-> ", 1)
edges.append({
"source": source,
"relation": relation,
"target": target,
})
else:
raise ValueError("Record outside a section")
return {"nodes": nodes, "edges": edges}
def serialize_dsl(graph):
lines = [
"# @ id|name|type",
"# > source|relation|target",
]
for node in graph["nodes"]:
for value in (node["id"], node["name"], node["type"]):
reject_delimiters(value, "|\n\r")
lines.append(
f'@ {node["id"]}|{node["name"]}|{node["type"]}'
)
for edge in graph["edges"]:
for value in canonical_edge(edge):
reject_delimiters(value, "|\n\r")
lines.append(
f'> {edge["source"]}|{edge["relation"]}|{edge["target"]}'
)
return "\n".join(lines)
def parse_dsl(text):
nodes, edges = [], []
for line in text.splitlines():
if not line or line.startswith("#"):
continue
marker, payload = line.split(" ", 1)
a, b, c = payload.split("|", 2)
if marker == "@":
nodes.append({"id": a, "name": b, "type": c})
elif marker == ">":
edges.append({"source": a, "relation": b, "target": c})
else:
raise ValueError(f"Unknown DSL marker: {marker}")
return {"nodes": nodes, "edges": edges}
SERIALIZERS = {
"json": (serialize_full_json, parse_full_json),
"jsonl": (serialize_jsonl, parse_jsonl),
"tsv": (serialize_tsv, parse_tsv),
"edge_list": (serialize_edge_list, parse_edge_list),
"dsl": (serialize_dsl, parse_dsl),
}
def normalize(text):
return unicodedata.normalize("NFC", text)
def sha256(text):
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def build_prompt(serialized_graph, question, format_name):
return f"""Ты выполняешь точный обход направленного графа.
Формат графа: {format_name}.
Используй только явно записанные рёбра.
Не обращай направление связи.
Не добавляй отсутствующие факты.
Если полного пути нет, верни INSUFFICIENT_DATA.
Верни только JSON:
{{
"answer": "краткий ответ или INSUFFICIENT_DATA",
"path": [индексы рёбер по порядку],
"evidence": [
{{"source":"id","relation":"TYPE","target":"id"}}
]
}}
ГРАФ
{serialized_graph}
ВОПРОС
{question}
"""
def count_tokens(text, encoding):
return len(encoding.encode(text))
def main():
parser = argparse.ArgumentParser()
parser.add_argument(
"--encoding",
default="o200k_base",
help="Tokenizer encoding name",
)
args = parser.parse_args()
OUTPUTS.mkdir(exist_ok=True)
graph = json.loads((DATA / "graph.json").read_text(encoding="utf-8"))
questions = json.loads(
(DATA / "questions.json").read_text(encoding="utf-8")
)
encoding = tiktoken.get_encoding(args.encoding)
reference = canonical_graph(graph)
report = []
for format_name, (serializer, deserializer) in SERIALIZERS.items():
serialized = normalize(serializer(graph))
decoded = deserializer(serialized)
roundtrip_ok = canonical_graph(decoded) == reference
if not roundtrip_ok:
raise AssertionError(
f"{format_name}: round-trip mismatch"
)
suffix = "txt"
(OUTPUTS / f"graph.{format_name}.{suffix}").write_text(
serialized,
encoding="utf-8",
)
graph_tokens = count_tokens(serialized, encoding)
for question in questions:
prompt = build_prompt(
serialized,
question["question"],
format_name,
)
prompt_path = OUTPUTS / (
f'prompt.{format_name}.{question["id"]}.txt'
)
prompt_path.write_text(prompt, encoding="utf-8")
report.append({
"format": format_name,
"question_id": question["id"],
"bytes": len(serialized.encode("utf-8")),
"graph_tokens": graph_tokens,
"prompt_tokens": count_tokens(prompt, encoding),
"roundtrip_ok": roundtrip_ok,
"graph_sha256": sha256(serialized),
"encoding": args.encoding,
})
(OUTPUTS / "size_report.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2),
encoding="utf-8",
)
print("format\tgraph_tokens\tprompt_tokens_min\tprompt_tokens_max")
for format_name in SERIALIZERS:
rows = [r for r in report if r["format"] == format_name]
print(
format_name,
rows[0]["graph_tokens"],
min(r["prompt_tokens"] for r in rows),
max(r["prompt_tokens"] for r in rows),
sep="\t",
)
if __name__ == "__main__":
main()
В production-репозитории код сериализаторов разумно покрыть отдельными модульными тестами. Здесь обязательная проверка уже встроена: скрипт завершится ошибкой, если декодированный граф отличается от исходного.
7. Запуск локальных и модельных тестов
7.1. Подсчёт токенов
python benchmark.py --encoding o200k_base
Команда создаст сериализованные графы, отдельный prompt для каждой пары «формат × вопрос» и файл outputs/size_report.json. Числа в статье намеренно не приведены: они должны быть получены вашим токенизатором и вашей версией данных.
7.2. Проверка нескольких размеров графа
Маленький граф выявляет грубые ошибки, но почти не проверяет давление на контекст. Проведите минимум три режима:
- Малый: исходные 13 рёбер.
- Средний: добавьте 10–20 нерелевантных ветвей на каждый стартовый узел.
- Большой: увеличивайте число отвлекающих рёбер до выбранного лимита контекста.
Дистракторы должны быть структурно правдоподобными, но не менять эталонный ответ. Генерируйте их детерминированно с фиксированным seed и сохраняйте полученный граф как артефакт запуска. Не создавайте разные дистракторы для разных форматов.
7.3. Честное ограничение бюджета
Есть два разных эксперимента, и смешивать их нельзя.
Режим A: одинаковый граф
Каждый формат получает все рёбра. Этот режим показывает чистую разницу в числе токенов и восприятии представления. Если один prompt не помещается в окно модели, это фиксируется как отдельный исход CONTEXT_OVERFLOW, а не исправляется скрытым усечением.
Режим B: одинаковый токен-бюджет
Для каждого формата добавляйте рёбра в одном и том же каноническом порядке, пока следующий блок не превысит бюджет. Этот режим показывает, сколько полезной структуры формат способен донести при фиксированной стоимости.
В режиме B узлы нельзя отрывать от рёбер: словарь должен содержать определения всех ID, присутствующих в выбранных рёбрах. Бюджет проверяется после формирования полного prompt, а не по приблизительному числу символов.
7.4. Модельный прогон
Используйте доступный вам API или локальную модель. Не помещайте ключ в скрипт или отчёт. Передавайте его через переменную окружения, принятую вашим SDK, либо через локальное хранилище секретов.
Для каждого prompt сохраните:
- точное имя модели и версию, если провайдер её раскрывает;
- время запуска в UTC;
- температуру и другие параметры семплирования;
- полный сырой ответ;
- число входных и выходных токенов из ответа API;
- идентификатор вопроса, формат и номер повтора;
- хеш prompt;
- ошибку или причину отказа, если запрос не завершился.
Для оценки способности формата, а не вариативности генерации, начните с наиболее детерминированных параметров, поддерживаемых выбранной моделью. Затем выполните несколько повторов. Даже при минимальной температуре облачный inference не всегда гарантирует побитовую повторяемость.
7.5. Формат результата модели
{
"answer": "Казань",
"path": [0, 3, 6, 10],
"evidence": [
{
"source": "person_irina",
"relation": "LEADS",
"target": "project_orion"
},
{
"source": "project_orion",
"relation": "USES",
"target": "method_graph"
},
{
"source": "method_graph",
"relation": "DEVELOPED_BY",
"target": "org_vector"
},
{
"source": "org_vector",
"relation": "LOCATED_IN",
"target": "city_kazan"
}
]
}
Если платформа поддерживает структурированный вывод по схеме, используйте одну и ту же схему во всех вариантах. Ошибка разбора должна учитываться как ошибка задачи, а не исключаться из статистики.
8. Проверка корректности
8.1. Round-trip до модельного запуска
Для каждого формата должны совпасть:
- множество узлов с ID, именем и типом;
- мультимножество направленных рёбер;
- регистр и Unicode-форма значений;
- число дубликатов, если дубликаты допустимы.
Обычное множество скрывает повторяющиеся рёбра. Если повтор имеет смысл — например, разные источники подтверждают одну связь, — сравнивайте счётчики или добавляйте ID и provenance.
8.2. Валидация предсказанного пути
Не доверяйте полю path само по себе. Для каждого индекса:
- проверьте, что индекс существует;
- получите соответствующее ребро из канонического массива;
- сопоставьте его с элементом
evidence; - проверьте непрерывность соседних рёбер;
- убедитесь, что путь действительно поддерживает ответ.
Некоторые вопросы требуют прохода против направления ребра. Например, q2 начинается с города и логически движется от LOCATED_IN к организации. Это допустимо только как явно описанный обратный поиск по существующему ребру. Модель не должна менять семантическое направление связи: из org —LOCATED_IN→ city нельзя выводить city —LOCATED_IN→ org.
8.3. Защита от утечки ответа
Проверьте, что:
- эталонный ответ не содержится в системной инструкции;
- имя файла не кодирует ответ;
- порядок вариантов не всегда ставит правильную сущность первой;
- в few-shot примерах нет того же пути с заменёнными именами;
- в metadata запроса не передаётся эталонный путь.
8.4. Перестановочный тест
Повторите эксперимент с несколькими детерминированными перестановками рёбер. Если качество резко меняется, вы измеряете не только формат, но и позиционное смещение. Отчёт должен показывать среднее по перестановкам и разброс, а не единственный удачный порядок.
8.5. Контрольный алгоритмический решатель
Напишите обычный обход графа без языковой модели. Он должен получать правильный путь для каждого положительного вопроса и отсутствие пути для отрицательного. Это проверяет разметку. Если детерминированный решатель не согласен с эталоном, модельный тест запускать рано.
8.6. Итоговая таблица
Формируйте таблицу из фактических логов:
| Формат | Токены графа | Токены prompt | Round-trip | Path EM | Answer EM | Valid path | Воздержание |
|---|---|---|---|---|---|---|---|
| JSON | из запуска | из запуска | должно быть 100% | из запусков | из запусков | из запусков | из запусков |
| JSONL | из запуска | из запуска | должно быть 100% | из запусков | из запусков | из запусков | из запусков |
| TSV | из запуска | из запуска | должно быть 100% | из запусков | из запусков | из запусков | из запусков |
| Список рёбер | из запуска | из запуска | должно быть 100% | из запусков | из запусков | из запусков | из запусков |
| DSL | из запуска | из запуска | должно быть 100% | из запусков | из запусков | из запусков | из запусков |
Не заменяйте незаполненные значения правдоподобными числами. Пустая ячейка честно показывает, что измерение ещё не выполнено.
9. Как интерпретировать результаты
Сценарий 1: формат компактнее и не теряет точность
Это сильный кандидат для текущей модели, языка и типа графа. Но проверьте его на большем наборе, длинных значениях, разных степенях узлов и перестановках рёбер. Локальная победа на 13 рёбрах ещё не доказывает устойчивость.
Сценарий 2: формат компактнее, но хуже восстанавливает путь
Вероятно, потеряна явность. Частые причины:
- непонятно направление связи;
- сокращённые коды требуют постоянного обращения к легенде;
- узлы и рёбра визуально не разделены;
- несколько смыслов упакованы в одну колонку;
- инструкция по грамматике слишком далека от данных.
Добавляйте минимальную избыточность по одному элементу: заголовок колонок, стрелку направления, отдельный словарь отношений или типы узлов. После каждого изменения повторяйте тест.
Сценарий 3: полный JSON точнее на малом графе, но не помещается на большом
Это не противоречие. При отсутствии давления на контекст явная схема помогает, а после достижения лимита формат проигрывает из-за усечения полезных рёбер. Сравните режим одинакового графа с режимом одинакового бюджета.
Сценарий 4: ответ правильный, путь неправильный
Не считайте такой запуск полностью успешным. Модель могла угадать по имени, использовать неразрешённое знание или выбрать правильный город из неправильной ветви. Для GraphRAG точность evidence важна не меньше краткого ответа.
Сценарий 5: путь правильный, ответ отличается формой
Проверьте нормализацию и контракт ответа. «в Казани» и «Казань» семантически совместимы, но свободная морфология усложняет детерминированную оценку. Лучше попросить вернуть ID узла и отдельно отображаемое имя:
{
"answer_id": "city_kazan",
"answer_text": "Казань"
}
Сценарий 6: выигрывает не один формат
Это нормальный итог. Можно получить фронт Парето: один формат минимизирует токены, второй лучше сохраняет точность длинных путей, третий проще валидировать. Выбор зависит от ограничений системы, а не от единственного рейтинга.
10. Типовые сбои и способы диагностики
Потеря разделителей
Имя узла содержит |, табуляцию или перевод строки, и компактный парсер разбивает запись неверно. Решение: запретить символы валидатором, реализовать экранирование либо использовать стандартный формат с готовым парсером.
Нестабильный порядок
Сериализатор проходит по множеству или хеш-таблице, из-за чего порядок меняется между запусками. Тогда меняются токены, позиции и индексы путей. Сортируйте узлы и рёбра или фиксируйте входной массив как часть набора.
Скрытая разница prompt
Для DSL добавлена длинная инструкция, а для JSON — короткая. Сравнение только токенов графа объявляет DSL победителем, хотя полный запрос может оказаться больше. Всегда публикуйте обе метрики.
Усечение в середине записи
Обрезка по токенам разрывает JSON-объект или строку DSL. Модель получает синтаксически повреждённый контекст. Выбирайте целые блоки до сериализации и проверяйте итоговый бюджет после неё.
Обращение направления
Модель видит A —LOCATED_IN→ B, но объясняет его как B находится в A. Добавьте типы узлов, явную стрелку и автоматическую проверку последовательности source-target.
Слияние одноимённых узлов
Две организации называются «Вектор», и формат передаёт только имена. Используйте устойчивые ID. Человекочитаемое имя должно быть атрибутом, а не ключом идентичности.
Слишком компактная легенда
Коды L, U и D экономят место в каждой строке, но модель путает их значения. Сравните общую экономию с ценой легенды и ростом ошибок. Для небольшого подграфа полные отношения могут быть дешевле.
Невалидный JSON ответа
Отдельно считайте parse_success. Не извлекайте ответ регулярным выражением из повреждённого объекта только для одной группы: это изменит условия оценки.
Случайное изменение данных
Один сериализатор пропускает атрибут типа или удаляет дубли. Round-trip проверка должна остановить запуск до обращения к модели.
Утечка через идентификатор
ID вроде correct_answer_kazan делает задачу тривиальной. Используйте нейтральные идентификаторы или выполните дополнительный тест с псевдослучайным переименованием всех узлов.
Слишком лёгкие вопросы
Если ответ встречается рядом с формулировкой вопроса, модель может не проходить граф. Добавьте конкурирующие сущности одинакового типа и требуйте evidence.
Неполный отрицательный пример
Отсутствие ребра в переданном подграфе не обязательно означает отсутствие факта во всей базе. Формулируйте контракт точно: «нет данных в предоставленном графе», а не «в реальности не существует».
11. Ограничения эксперимента
Этот тест даёт воспроизводимую процедуру, но не универсальный рейтинг форматов.
- Модельная зависимость. Разные токенизаторы и модели по-разному воспринимают JSON, таблицы, стрелки и сокращения.
- Языковая зависимость. Русские и английские имена могут иметь разную токенную стоимость.
- Размер набора. Шесть вопросов подходят для smoke-теста, но недостаточны для уверенного статистического вывода.
- Синтетическая структура. Реальные графы содержат длинные свойства, источники, даты, веса, гиперрёбра и конфликтующие факты.
- Изоляция retrieval. Здесь сравнивается представление уже выбранного подграфа. Ошибки извлечения кандидатов не моделируются.
- Изменчивость сервисов. Поведение размещённой модели может измениться без изменения имени endpoint.
- Стоимость latency. Меньше токенов не всегда означает пропорционально меньшую задержку.
- Качество объяснения. Точное совпадение ответа не измеряет полноту естественно-языкового обоснования.
Для серьёзного решения расширьте набор до разных длин путей, степеней узлов и типов отношений. Отдельно анализируйте вопросы на 1, 2, 3, 4 и более переходов. Публикуйте доверительные интервалы или хотя бы разброс по повторам и перестановкам.
12. Практический перенос в production
Бенчмарк полезен не только для выбора одного статического формата. Его выводы можно превратить в политику формирования контекста.
Разделите хранение и prompt-представление
Не меняйте базовый формат хранения ради экономии токенов. Граф может оставаться в базе или строгом JSON, а компактное представление генерироваться только после retrieval.
Сериализуйте локальный подграф
Сначала выберите релевантные узлы и связи, затем назначьте короткие локальные ID. Не отправляйте глобальные UUID многократно, если они не нужны для ответа.
Не удаляйте семантически важные поля
В первую очередь сокращайте повторяющийся синтаксис. Тип, направление, временной интервал, provenance и отрицание могут быть критичны для правильного пути.
Держите грамматику рядом с данными
Одно короткое определение непосредственно перед графом обычно надёжнее, чем длинная легенда в начале огромного prompt. Но это гипотеза, которую следует проверить на выбранной модели.
Версионируйте формат
GRAPH_FORMAT compact-dsl/1
@ id|name|type
> source|relation|target
Версия позволяет менять экранирование и схему без неявного нарушения старых prompt, кешей и оценочных наборов.
Включите формат в observability
В журнал каждого запроса добавляйте имя и версию сериализации, число переданных узлов и рёбер, входные токены, факт усечения и хеш контекста. Не записывайте чувствительные свойства графа без необходимости.
Определите порог отката
Компактный формат можно принимать только при заранее заданных условиях, например:
- round-trip всегда проходит;
- доля валидных путей не хуже установленного допуска относительно baseline;
- воздержание на неполных путях не ухудшается;
- экономия измерена на полном prompt;
- результат устойчив к перестановке рёбер.
Конкретные пороги зависят от риска продукта. Для аналитической подсказки допустим один компромисс, для юридического или медицинского ответа — другой.
13. Краткий протокол повторения
- Зафиксируйте канонический граф, вопросы, ответы и эталонные пути.
- Реализуйте сериализатор и обратный парсер каждого формата.
- Добейтесь полного round-trip совпадения.
- Зафиксируйте версии Python, библиотек и токенизатора.
- Сохраните сериализованные файлы и их SHA-256.
- Посчитайте токены графа и полного prompt.
- Запустите режим одинакового графа.
- Запустите режим одинакового токен-бюджета.
- Повторите тест с разными порядками рёбер.
- Сохраните сырые ответы и usage из API.
- Проверьте answer exact match, path exact match и непрерывность пути.
- Отдельно оцените отказ при недостатке данных.
- Рассчитайте стоимость из актуальных параметров, переданных во время анализа.
- Выбирайте формат по совокупности метрик, а не только по токенам.
Вывод
Сериализация графа — часть retrieval-интерфейса, а не косметический выбор. JSON покупает явность ценой повторяющегося синтаксиса. Таблицы и списки рёбер экономят контекст, но требуют строгой дисциплины разделителей. Компактный DSL способен убрать значительную избыточность, однако его грамматика сама становится частью задачи для модели.
Правильный вопрос звучит не «какой формат короче?», а «какой формат при нашем токен-бюджете, нашей модели и наших графах чаще возвращает правильный проверяемый путь и умеет отказаться при недостатке данных?». Ответ получается только из теста, где один и тот же граф проходит round-trip проверку, токены считаются для полного запроса, а конечный ответ оценивается вместе с evidence.
Продолжить работу можно в разделе практических гайдов; определения терминов собраны в глоссарии Agent Lab Journal.