Advanced guide
How Graph Serialization Format Affects GraphRAG Cost and Accuracy
A graph can contain exactly the right evidence and still produce a wrong answer because its textual representation consumed the context window, separated related facts, or made edge direction ambiguous. This guide turns that failure mode into a repeatable experiment: serialize one graph several ways, measure the actual tokens sent to a model, test deterministic traversal before involving a model, then score multi-hop answers under fixed context budgets.
The format is part of the retrieval system
GraphRAG combines retrieval with graph structure so that an answer can follow relationships instead of relying only on text similarity. A typical request retrieves a subgraph, serializes it, and places that text in a model prompt. The serializer therefore sits on the critical path between retrieval and reasoning.
The graph database may store compact identifiers and typed relationships, but a prompt often expands them into repeated field names:
{
"source": {"id": "svc-api", "type": "service"},
"relationship": {"type": "depends_on"},
"target": {"id": "svc-auth", "type": "service"}
}
Repeated keys, braces, quotes, commas, type labels, and duplicated node properties all consume tokens. That syntax is not automatically waste: explicit structure can prevent ambiguity. The engineering question is whether each token improves the probability of a correct traversal enough to justify its cost.
A smaller representation can fail too. The line A B C is cheap, but it does not say whether A depends on B, owns B, or replaced B. The useful objective is therefore not minimum tokens. It is the best measured answer quality under a fixed context window, latency target, and budget.
Three distinct accuracy layers
Do not collapse every error into “the model got it wrong.” Measure these layers independently:
- Serialization fidelity: can a parser reconstruct the original nodes, edges, directions, labels, and required properties?
- Traversal fidelity: can the reconstructed graph answer deterministic path queries correctly?
- Answer quality: can a language model use the serialized evidence to produce the expected multi-hop answer?
If layer one fails, changing the prompt cannot repair missing information. If layers one and two pass but layer three fails, ordering, verbosity, model behavior, or instructions are more plausible causes.
Concrete case: dependency impact analysis
Consider an internal service graph. Nodes represent services and databases. Directed depends_on edges point from a consumer to its dependency. A second relationship, owned_by, connects a service to its team.
The operational question is:
Which customer-facing services are transitively affected if db-identity fails, and which teams own those services?
Answering it requires at least two operations: traverse dependency edges in the reverse direction from the failed database, then follow ownership edges from affected services. A representation that drops direction, omits relationship types, or loses the ownership records cannot support the answer even when every remaining line is syntactically valid.
This is also where truncation becomes dangerous. A large JSON document may place the closing edges or ownership section beyond the available budget. The prompt remains readable, but the evidence needed for the last hop is absent.
Formats under test
The benchmark below compares five lossless representations of the same directed, typed graph. All formats retain node identifiers, node types, edge types, and direction.
| Format | Example edge | Expected trade-off |
|---|---|---|
| Pretty JSON | {"source":"api", "relation":"depends_on", "target":"auth"} |
Explicit and easy to inspect; indentation and repeated keys increase size. |
| Compact JSON | {"s":"api","r":"depends_on","t":"auth"} |
Preserves machine-readable structure with shorter keys and no whitespace. |
| JSONL | {"k":"e","s":"api","r":"depends_on","t":"auth"} |
Streamable and independently parseable records; keys still repeat. |
| CSV records | E,api,depends_on,auth |
Compact and conventional; quoting rules matter when values contain delimiters. |
| Typed edge list | api -depends_on-> auth |
Readable and compact; requires a declared grammar and escaping policy. |
Do not include an intentionally lossy format in the main ranking without labeling it. For example, an untyped pair list may win on tokens only because it removed information required by the questions.
Experimental design
Control variables
Keep the following fixed for every format:
- the graph and the order in which records are supplied;
- the query set and expected answers;
- the tokenizer and model version;
- the prompt instructions and output schema;
- the maximum input budget and truncation policy;
- generation settings, including temperature and maximum output tokens;
- the number of repeated runs and the scoring code.
Primary measurements
- Serialized bytes
- UTF-8 byte length. Useful for storage and transport, but not a substitute for model-specific token measurement.
- Token count
- The count produced by the exact tokenizer used for the evaluated model.
- Round-trip fidelity
- Whether decoding the representation yields the canonical node and edge sets.
- Traversal accuracy
- The fraction of deterministic graph queries whose decoded answers equal the gold answers.
- Budget survival
- The fraction of required evidence records remaining after applying the same token budget.
- Multi-hop exact match
- The fraction of model answers that normalize to the expected set of identifiers.
Recommended hypotheses
Write hypotheses before running the test. For example:
- Compact JSON will use fewer tokens than pretty JSON while preserving identical deterministic results.
- A typed edge list will reduce syntax overhead, but may require stronger format instructions.
- Under a tight budget, record-aware truncation will outperform raw character or token slicing.
- Ordering edges by relevance to the query will matter more as the budget becomes smaller.
These are hypotheses, not results. The benchmark is designed to confirm or reject them on your graph, tokenizer, model, and query distribution.
Reference implementation
The following single-file program creates a deterministic synthetic graph, serializes it in five formats, decodes every format, validates round trips, runs reachability queries, and measures tokens. The default graph is deliberately small enough to inspect. Increase --services and --noise-edges for context-pressure tests.
Environment
python -m venv .venv
. .venv/bin/activate
python -m pip install tiktoken
Pin the installed dependency after choosing the version used in your experiment:
python -m pip freeze > requirements-lock.txt
python --version
python -m pip show tiktoken
Save as benchmark_graph_formats.py
#!/usr/bin/env python3
import argparse
import csv
import io
import json
import random
from collections import defaultdict, deque
from dataclasses import dataclass, asdict
import tiktoken
@dataclass(frozen=True, order=True)
class Node:
id: str
type: str
@dataclass(frozen=True, order=True)
class Edge:
source: str
relation: str
target: str
def make_graph(service_count, noise_edges, seed):
rng = random.Random(seed)
nodes = [
Node("db-identity", "database"),
Node("team-core", "team"),
Node("team-growth", "team"),
Node("svc-web", "service"),
Node("svc-api", "service"),
Node("svc-auth", "service"),
Node("svc-profile", "service"),
]
edges = [
Edge("svc-web", "depends_on", "svc-api"),
Edge("svc-api", "depends_on", "svc-auth"),
Edge("svc-profile", "depends_on", "svc-auth"),
Edge("svc-auth", "depends_on", "db-identity"),
Edge("svc-web", "owned_by", "team-growth"),
Edge("svc-api", "owned_by", "team-core"),
Edge("svc-auth", "owned_by", "team-core"),
Edge("svc-profile", "owned_by", "team-growth"),
]
extra_services = [
Node(f"svc-noise-{i:04d}", "service")
for i in range(max(0, service_count - 4))
]
nodes.extend(extra_services)
all_services = [n.id for n in nodes if n.type == "service"]
existing = set(edges)
attempts = 0
while len(existing) < len(edges) + noise_edges and attempts < noise_edges * 50 + 100:
attempts += 1
source = rng.choice(all_services)
target = rng.choice(all_services)
if source != target:
existing.add(Edge(source, "depends_on", target))
edges = sorted(existing)
return sorted(nodes), edges
def canonical(nodes, edges):
return (
tuple(sorted((n.id, n.type) for n in nodes)),
tuple(sorted((e.source, e.relation, e.target) for e in edges)),
)
def encode_pretty_json(nodes, edges):
value = {
"nodes": [asdict(n) for n in nodes],
"edges": [asdict(e) for e in edges],
}
return json.dumps(value, indent=2, ensure_ascii=False)
def decode_pretty_json(text):
value = json.loads(text)
nodes = [Node(x["id"], x["type"]) for x in value["nodes"]]
edges = [
Edge(x["source"], x["relation"], x["target"])
for x in value["edges"]
]
return nodes, edges
def encode_compact_json(nodes, edges):
value = {
"n": [[n.id, n.type] for n in nodes],
"e": [[e.source, e.relation, e.target] for e in edges],
}
return json.dumps(value, separators=(",", ":"), ensure_ascii=False)
def decode_compact_json(text):
value = json.loads(text)
nodes = [Node(*x) for x in value["n"]]
edges = [Edge(*x) for x in value["e"]]
return nodes, edges
def encode_jsonl(nodes, edges):
records = []
records.extend(
json.dumps(
{"k": "n", "id": n.id, "t": n.type},
separators=(",", ":"),
ensure_ascii=False,
)
for n in nodes
)
records.extend(
json.dumps(
{"k": "e", "s": e.source, "r": e.relation, "t": e.target},
separators=(",", ":"),
ensure_ascii=False,
)
for e in edges
)
return "\n".join(records)
def decode_jsonl(text):
nodes, edges = [], []
for line in text.splitlines():
if not line.strip():
continue
value = json.loads(line)
if value["k"] == "n":
nodes.append(Node(value["id"], value["t"]))
elif value["k"] == "e":
edges.append(Edge(value["s"], value["r"], value["t"]))
else:
raise ValueError(f"Unknown record kind: {value['k']}")
return nodes, edges
def encode_csv_records(nodes, edges):
output = io.StringIO(newline="")
writer = csv.writer(output, lineterminator="\n")
writer.writerow(["kind", "a", "b", "c"])
for n in nodes:
writer.writerow(["N", n.id, n.type, ""])
for e in edges:
writer.writerow(["E", e.source, e.relation, e.target])
return output.getvalue()
def decode_csv_records(text):
reader = csv.reader(io.StringIO(text))
header = next(reader)
if header != ["kind", "a", "b", "c"]:
raise ValueError("Unexpected CSV header")
nodes, edges = [], []
for kind, a, b, c in reader:
if kind == "N":
nodes.append(Node(a, b))
elif kind == "E":
edges.append(Edge(a, b, c))
else:
raise ValueError(f"Unknown CSV record kind: {kind}")
return nodes, edges
def escape_atom(value):
return (
value.replace("\\", "\\\\")
.replace("|", "\\p")
.replace("\n", "\\n")
)
def unescape_atom(value):
result = []
i = 0
while i < len(value):
if value[i] != "\\":
result.append(value[i])
i += 1
continue
i += 1
if i >= len(value):
raise ValueError("Trailing escape")
result.append({"p": "|", "n": "\n", "\\": "\\"}[value[i]])
i += 1
return "".join(result)
def split_escaped(line):
fields, current = [], []
escaped = False
for char in line:
if escaped:
current.extend(["\\", char])
escaped = False
elif char == "\\":
escaped = True
elif char == "|":
fields.append(unescape_atom("".join(current)))
current = []
else:
current.append(char)
if escaped:
raise ValueError("Trailing escape")
fields.append(unescape_atom("".join(current)))
return fields
def encode_typed_edges(nodes, edges):
lines = ["#graph-v1"]
lines.extend(
f"N|{escape_atom(n.id)}|{escape_atom(n.type)}"
for n in nodes
)
lines.extend(
f"E|{escape_atom(e.source)}|{escape_atom(e.relation)}|{escape_atom(e.target)}"
for e in edges
)
return "\n".join(lines)
def decode_typed_edges(text):
lines = text.splitlines()
if not lines or lines[0] != "#graph-v1":
raise ValueError("Missing graph format declaration")
nodes, edges = [], []
for line in lines[1:]:
fields = split_escaped(line)
if fields[0] == "N" and len(fields) == 3:
nodes.append(Node(fields[1], fields[2]))
elif fields[0] == "E" and len(fields) == 4:
edges.append(Edge(fields[1], fields[2], fields[3]))
else:
raise ValueError(f"Invalid record: {line!r}")
return nodes, edges
FORMATS = {
"pretty_json": (encode_pretty_json, decode_pretty_json),
"compact_json": (encode_compact_json, decode_compact_json),
"jsonl": (encode_jsonl, decode_jsonl),
"csv": (encode_csv_records, decode_csv_records),
"typed_edges": (encode_typed_edges, decode_typed_edges),
}
def reachable_dependencies(edges, start, max_hops):
adjacency = defaultdict(list)
for edge in edges:
if edge.relation == "depends_on":
adjacency[edge.source].append(edge.target)
seen = {start}
queue = deque([(start, 0)])
answer = set()
while queue:
node, distance = queue.popleft()
if distance == max_hops:
continue
for target in adjacency[node]:
if target not in seen:
seen.add(target)
answer.add(target)
queue.append((target, distance + 1))
return sorted(answer)
def affected_services(nodes, edges, failed_node):
reverse = defaultdict(list)
for edge in edges:
if edge.relation == "depends_on":
reverse[edge.target].append(edge.source)
seen = {failed_node}
queue = deque([failed_node])
affected = set()
service_ids = {n.id for n in nodes if n.type == "service"}
while queue:
node = queue.popleft()
for consumer in reverse[node]:
if consumer not in seen:
seen.add(consumer)
queue.append(consumer)
if consumer in service_ids:
affected.add(consumer)
return sorted(affected)
def owners_of(services, edges):
owners = defaultdict(set)
wanted = set(services)
for edge in edges:
if edge.relation == "owned_by" and edge.source in wanted:
owners[edge.source].add(edge.target)
return {
service: sorted(owners[service])
for service in sorted(wanted)
}
def query_suite(nodes, edges):
affected = affected_services(nodes, edges, "db-identity")
return {
"api_dependencies_3_hops":
reachable_dependencies(edges, "svc-api", 3),
"identity_failure_affected_services":
affected,
"owners_of_affected_services":
owners_of(affected, edges),
}
def token_count(encoding, text):
return len(encoding.encode(text))
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--services", type=int, default=40)
parser.add_argument("--noise-edges", type=int, default=80)
parser.add_argument("--seed", type=int, default=7)
parser.add_argument("--encoding", default="o200k_base")
parser.add_argument("--output", default="benchmark-results.json")
args = parser.parse_args()
encoding = tiktoken.get_encoding(args.encoding)
nodes, edges = make_graph(
args.services,
args.noise_edges,
args.seed,
)
gold_graph = canonical(nodes, edges)
gold_queries = query_suite(nodes, edges)
report = {
"parameters": vars(args),
"graph": {
"nodes": len(nodes),
"edges": len(edges),
},
"gold_queries": gold_queries,
"formats": {},
}
for name, (encoder, decoder) in FORMATS.items():
text = encoder(nodes, edges)
decoded_nodes, decoded_edges = decoder(text)
decoded_graph = canonical(decoded_nodes, decoded_edges)
decoded_queries = query_suite(decoded_nodes, decoded_edges)
query_checks = {
key: decoded_queries[key] == expected
for key, expected in gold_queries.items()
}
report["formats"][name] = {
"utf8_bytes": len(text.encode("utf-8")),
"characters": len(text),
"tokens": token_count(encoding, text),
"round_trip_equal": decoded_graph == gold_graph,
"query_checks": query_checks,
"traversal_accuracy": (
sum(query_checks.values()) / len(query_checks)
),
}
with open(f"graph.{name}.txt", "w", encoding="utf-8") as handle:
handle.write(text)
with open(args.output, "w", encoding="utf-8") as handle:
json.dump(report, handle, indent=2, ensure_ascii=False)
print(json.dumps(report, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
Why the benchmark begins with deterministic parsing
A model is not a parser conformance test. Before spending inference budget, prove that every supposedly lossless format can reconstruct the same graph. The program compares canonical tuples rather than serialized strings, so harmless ordering and whitespace differences do not count as failures.
The query suite then operates on the decoded graph. A traversal score below 1.0 means the serialization or decoder changed usable graph semantics. Stop there and fix the representation before evaluating a language model.
Running the benchmark
Baseline
python benchmark_graph_formats.py \
--services 40 \
--noise-edges 80 \
--seed 7 \
--encoding o200k_base \
--output baseline.json
Scale sweep
for size in 40 100 250 500
do
python benchmark_graph_formats.py \
--services "$size" \
--noise-edges "$((size * 2))" \
--seed 7 \
--encoding o200k_base \
--output "results-${size}.json"
done
Use the encoding required by your selected model. Do not copy an encoding name merely because it appears in this example. Record it alongside the model identifier and dependency lock file.
Inspect the generated representations
wc -c graph.*.txt
sed -n '1,30p' graph.pretty_json.txt
sed -n '1,30p' graph.typed_edges.txt
python -m json.tool baseline.json
Record-aware budget tests
Raw token slicing can cut a JSON object or CSV record in half. That measures corruption as much as compactness. Run two policies and report them separately:
- Raw budget: encode the complete representation, keep the first B
- Record-aware budget: append complete records while the next record still fits. For monolithic JSON, build the largest valid object that fits.
Use several budgets rather than one convenient threshold:
budgets = [512, 1024, 2048, 4096, 8192]
For each budget, store:
- whether the truncated representation parses;
- the retained node and edge counts;
- whether every gold path remains present;
- deterministic traversal accuracy;
- model answer score, if model evaluation is enabled.
Test more than one ordering
Serialization format and record order interact. Compare at least:
- canonical identifier order;
- deterministically shuffled order using recorded seeds;
- query-relevant edges first;
- breadth-first order from the query entities;
- nodes followed by edges, and interleaved node-edge records where supported.
A format should not receive an ordering advantage that other formats are denied. Either use an equivalent order everywhere or treat ordering as a separate experimental factor.
Multi-hop model evaluation
After all formats pass deterministic checks, test whether a model can use them. A multi-hop answer should depend on multiple linked facts, not on a label that directly reveals the result.
Prompt template
You answer only from the graph data below.
Graph semantics:
- "A depends_on B" means A is a consumer of B.
- If B fails, A may be affected.
- Dependency impact propagates transitively in the reverse direction.
- "A owned_by T" identifies the team responsible for A.
Return JSON only:
{
"affected_services": ["service-id"],
"owners": {
"service-id": ["team-id"]
}
}
Question:
If db-identity fails, which services are transitively affected,
and which teams own those services?
GRAPH_FORMAT:
{{format_name}}
GRAPH_DATA:
{{serialized_graph}}
Keep these instructions byte-for-byte identical between formats except for format_name and serialized_graph. If a format needs a grammar explanation, include the grammar in its token accounting. Otherwise, a compact custom format receives an unfair advantage by hiding instructions outside the measured input.
Gold answers
Generate expected answers with deterministic graph code, not with another model. Normalize both gold and candidate outputs before comparison:
- parse JSON strictly;
- reject missing required fields;
- deduplicate and sort identifier arrays;
- compare identifiers exactly rather than using semantic similarity;
- score affected services and ownership mappings separately.
Suggested scoring
For set-valued answers, report precision, recall, and F1. Let P be the predicted identifier set and G the gold set:
precision = |P ∩ G| / |P|
recall = |P ∩ G| / |G|
F1 = 2 × precision × recall / (precision + recall)
Define behavior for empty sets before running the experiment. Also report strict exact match, because an F1 score can hide one unsupported service in an otherwise long correct list.
Repeated trials
Even low-temperature generation may vary. Use the same number of attempts per format and query. Store every raw request, raw response, parse outcome, normalized answer, latency, and usage value returned by the provider. Do not reconstruct token usage later when the API already supplied it.
Result schema
{
"run_id": "locally-assigned-id",
"graph_hash": "sha256-of-canonical-graph",
"format": "typed_edges",
"ordering": "bfs_from_query",
"budget_tokens": 4096,
"model": "exact-model-identifier",
"tokenizer": "exact-tokenizer-identifier",
"prompt_tokens_reported": 0,
"completion_tokens_reported": 0,
"parse_success": false,
"exact_match": false,
"affected_services_f1": 0.0,
"owners_exact_match": false,
"latency_ms": 0,
"raw_response_path": "responses/run-id.txt"
}
The zeros above are placeholders defining fields, not benchmark findings.
Cost calculation
Store token usage first. Apply pricing later from an explicitly recorded price table:
input_cost =
prompt_tokens / 1_000_000 * input_price_per_million
output_cost =
completion_tokens / 1_000_000 * output_price_per_million
total_cost = input_cost + output_cost
Pricing changes, and cached-input rules may differ. Keeping raw usage separate from price assumptions makes historical runs comparable without rewriting the evidence.
Verification and interpretation
Pre-flight verification
- Every format has
round_trip_equal: true. - Every untruncated format has traversal accuracy of 1.0.
- The same canonical graph hash appears in every run.
- The tokenizer corresponds to the evaluated model.
- Grammar instructions are included in measured prompt tokens.
- All random seeds, package versions, budgets, and model settings are recorded.
- Gold answers come from deterministic traversal.
Use a Pareto comparison
A single weighted score can conceal important trade-offs. First identify the Pareto frontier: a format is dominated if another format uses no more tokens and achieves equal or better accuracy at every tested budget, with at least one strict improvement.
Then choose among non-dominated formats using operational constraints. For example, a team may accept a modest token increase for standard parsing, better observability, and fewer escaping bugs.
Report uncertainty
Deterministic token counts need no confidence interval, but model answer scores do. Report the number of queries and repeated trials. For proportions such as exact match, use a suitable binomial interval or bootstrap the query-level results. Avoid presenting a difference of one successful answer as a general format advantage.
Separate these conclusions
- Format efficiency: tokens required for the complete graph.
- Budget robustness: answer quality as the graph is constrained.
- Reasoning usability: quality when all required evidence fits.
- Engineering reliability: parse failures, escaping errors, schema drift, and debugging cost.
Minimum results table
| Format | Tokens | Round trip | Traversal accuracy | Budget | Parse rate | Multi-hop exact match |
|---|---|---|---|---|---|---|
| Pretty JSON | Measured locally | Measured locally | Measured locally | Recorded value | Measured locally | Measured locally |
| Compact JSON | Measured locally | Measured locally | Measured locally | Recorded value | Measured locally | Measured locally |
| JSONL | Measured locally | Measured locally | Measured locally | Recorded value | Measured locally | Measured locally |
| CSV | Measured locally | Measured locally | Measured locally | Recorded value | Measured locally | Measured locally |
| Typed edge list | Measured locally | Measured locally | Measured locally | Recorded value | Measured locally | Measured locally |
Failure cases worth testing explicitly
Duplicate identifiers
If two node records share an identifier but disagree on type or properties, decide whether decoding rejects the graph or applies a documented merge rule. Silent last-write-wins behavior can make format ordering change the answer.
Delimiter collisions
Custom edge lists often work until an identifier contains a pipe, tab, newline, backslash, or arrow-like sequence. Add adversarial fixtures and verify round trips. A compact grammar without an escaping specification is not a production format.
Direction reversal
A depends_on B and B is_dependency_of A describe the same relation from different viewpoints. Mixing the two conventions reverses impact analysis. Put semantics in the prompt and encode direction unambiguously.
Relationship-type collapse
If depends_on, owned_by, and deployed_in are serialized as unlabeled pairs, deterministic reachability may return syntactically connected but semantically invalid paths.
Orphan edges
An edge may reference a node omitted by retrieval or truncation. Decide whether the model needs the node record, whether the identifier alone is sufficient, and whether the decoder rejects or retains the edge.
Broken monolithic JSON
Cutting a JSON document at a token boundary commonly produces invalid syntax. Record-aware construction or JSONL can preserve valid prefixes, but a valid prefix may still omit decisive evidence. Track parse validity and evidence completeness separately.
Schema verbosity disguised as evidence
Repeated natural-language property descriptions may dominate the graph. Measure structural records separately from node content. In production, retrieve long descriptions only for nodes on candidate paths.
Tokenizer mismatch
Byte size, character count, and tokens are correlated but not interchangeable. Punctuation-heavy encodings, long identifiers, Unicode labels, and whitespace can tokenize differently. Measure with the deployed tokenizer.
Identifier leakage
A node named service-affected-by-identity gives away the answer. Use neutral identifiers or test whether labels alone solve the question without traversal.
Random graph shortcuts
Noise edges can accidentally create shorter paths or change the gold affected set. Always compute gold answers after graph generation and store the canonical graph used for the run.
From benchmark to production design
Prefer query-shaped subgraphs
Serialization cannot compensate for retrieving an unnecessarily large graph. Extract candidate paths, retain relationship direction and provenance, and serialize only the evidence required for the question plus a controlled amount of neighboring context.
Use stable dictionaries when repetition is high
A dictionary can map repeated types and relation names to short codes:
REL: d=depends_on, o=owned_by
TYPE: s=service, t=team, b=database
N|svc-api|s
N|svc-auth|s
E|svc-api|d|svc-auth
Include the dictionary and its explanation in token measurements. Version it, reject unknown codes, and avoid codes that are easy to confuse.
Place decisive evidence deliberately
Under hard budgets, order records by relevance or path structure instead of database insertion order. Keep the policy deterministic and evaluate it as its own factor. A useful sequence is query entities, their node records, candidate path edges, intermediate nodes, terminal evidence, then optional context.
Preserve provenance without repeating it everywhere
If every edge cites the same document, assign the source a short identifier and provide a source table once. If provenance varies by edge, retain the mapping required for answer verification. Removing provenance may reduce tokens while making grounded answers impossible to audit.
Version the grammar
Custom formats should begin with a version marker such as #graph-v1. Store the encoder, decoder, grammar description, and benchmark fixtures together. Treat a grammar change as a data-contract change, not a prompt tweak.
Recommended decision rule
Select the simplest representation that meets all of these conditions:
- lossless round trip for required graph semantics;
- perfect deterministic traversal on the test suite;
- acceptable multi-hop quality at the production budget;
- no unacceptable degradation across orderings and seeds;
- documented escaping, schema, and version behavior;
- operationally acceptable cost, latency, and debugging effort.
Limitations
This experiment does not establish a universally best graph format. Results depend on graph density, identifier length, property volume, language, tokenizer, model, prompt, question type, retrieval policy, and context budget.
The reference graph emphasizes directed dependency and ownership queries. Knowledge graphs with qualifiers, temporal validity, confidence scores, hyperedges, nested properties, or multiple provenance records require additional fixtures and possibly different encodings.
Exact-match scoring is appropriate for identifier sets but insufficient for open-ended explanations. If explanations matter, score factual claims against graph-derived evidence and keep that evaluation separate from identifier retrieval.
Synthetic scale tests reveal mechanical behavior but cannot replace a sanitized sample of the production distribution. Real graphs often contain irregular identifiers, missing properties, duplicate facts, skewed degrees, multilingual labels, and long text fields.
Finally, token savings do not guarantee lower end-to-end cost. A format that needs more retries, produces invalid output, or requires a larger model may cost more despite a shorter prompt.
Reproduction checklist
- Freeze one canonical graph and compute its hash.
- Define multi-hop queries and generate gold answers deterministically.
- Implement an encoder and decoder for every format.
- Require lossless round trips before model testing.
- Measure UTF-8 bytes and exact model-tokenizer counts.
- Test multiple budgets, orderings, graph sizes, and seeds.
- Apply both raw and record-aware truncation policies.
- Keep prompts and generation settings fixed.
- Include grammar instructions in token accounting.
- Store raw requests, responses, usage, latency, and parse outcomes.
- Report exact match, precision, recall, F1, and uncertainty.
- Publish configuration and unfilled result templates alongside conclusions.
Conclusion
Graph serialization is not merely an output preference. It determines how much evidence fits, whether direction and relation types survive, how truncation behaves, and how easily a model can recover a path. Benchmark it with the same discipline as retrieval and ranking.
Start with lossless round trips and deterministic queries. Only then add model calls, fixed budgets, repeated trials, and cost calculations. That sequence tells you whether an error came from the graph representation, the retained evidence, or the model’s use of valid evidence—and it produces a result another engineer can actually reproduce.
Continue with the Agent Lab Journal guides, or review terminology in the AI systems glossary.