#3299·deepeval

EvaluationDataset.save_as('csv'/'jsonl') splits context items that contain '|' when reloaded

Author: ppcvoteCreated Sep 16, 2026Updated Sep 16, 2026

Describe the bug

EvaluationDataset.save_as("csv") and save_as("jsonl") flatten context and retrieval_context into a single |-delimited cell. A | inside an item is not escaped, so the matching loaders split that item apart on reload. One chunk comes back as several items, some of them empty or whitespace only. No exception and no warning is raised. save_as("json") is unaffected.

The triggering content is ordinary RAG material such as markdown tables and shell pipelines. Affected paths are add_goldens_from_csv_file, add_goldens_from_jsonl_file, and add_test_cases_from_csv_file after a save_as("csv"/"jsonl"). Synthesizer.save_as("csv") joins through the same helper and is affected too.

To Reproduce

python
import tempfile
from deepeval.dataset import EvaluationDataset, Golden

context = ["cat access.log | grep ERROR", "no pipe here"]
retrieval_context = ["| Plan | Price |\n| --- | --- |\n| Pro | $20 |", "plain chunk"]

for file_type, loader in [
    ("json", "add_goldens_from_json_file"),
    ("jsonl", "add_goldens_from_jsonl_file"),
    ("csv", "add_goldens_from_csv_file"),
]:
    directory = tempfile.mkdtemp()
    dataset = EvaluationDataset(goldens=[Golden(
        input="How much is Pro?", actual_output="$20",
        context=list(context), retrieval_context=list(retrieval_context),
    )])
    path = dataset.save_as(file_type, directory, file_name="probe")

    reloaded = EvaluationDataset()
    getattr(reloaded, loader)(path)
    golden = reloaded.goldens[0]

    print(file_type, "context:", len(context), "->", len(golden.context),
          "items, equal =", golden.context == context)
    print(file_type, "retrieval_context:", len(retrieval_context), "->",
          len(golden.retrieval_context), "items, equal =",
          golden.retrieval_context == retrieval_context)
    print("   ", golden.retrieval_context)

Actual output:

json context: 2 -> 2 items, equal = True
json retrieval_context: 2 -> 2 items, equal = True
    ['| Plan | Price |\n| --- | --- |\n| Pro | $20 |', 'plain chunk']
jsonl context: 2 -> 3 items, equal = False
jsonl retrieval_context: 2 -> 11 items, equal = False
    ['', ' Plan ', ' Price ', '\n', ' --- ', ' --- ', '\n', ' Pro ', ' $20 ', '', 'plain chunk']
csv context: 2 -> 3 items, equal = False
csv retrieval_context: 2 -> 11 items, equal = False
    ['', ' Plan ', ' Price ', '\n', ' --- ', ' --- ', '\n', ' Pro ', ' $20 ', '', 'plain chunk']

The same round-trip under warnings.catch_warnings(record=True) with contextlib.redirect_stderr gives warnings=0 stderr='' for all three formats. save_as("csv", include_test_cases=True) into add_test_cases_from_csv_file splits the same way: context 2 -> 3, retrieval_context 2 -> 11.

ContextualRelevancyMetric scores one node at a time (contextual_relevancy.py:131-133), so the fragments turn into extra judge calls and a different score. With the real metric and a stub judge returning one verdict per node (no network):

before save_as     : score=0.500  nodes judged=2  items=2  blank/whitespace items=0
after json  reload: score=0.500  nodes judged=2  items=2  blank/whitespace items=0
after jsonl reload: score=0.091  nodes judged=11  items=11  blank/whitespace items=4
after csv   reload: score=0.091  nodes judged=11  items=11  blank/whitespace items=4

Expected behavior

The round-trip preserves the list, as deepeval/dataset/utils.py:17-19 states ("a save/load round-trip is lossless") and as the docs promise at docs/content/docs/(concepts)/evaluation-datasets.mdx:1065 ("a saved dataset reloads without any configuration"). save_as("json") already behaves this way.

Environment

  • deepeval 4.2.3, source install of main at 1e4f9e6e9f0bdf01a88f9e4d69d0eb5d0de634c1
  • Python 3.11.6, pandas 2.3.3
  • Windows 10, system code page 950. These paths open files with an explicit utf-8 encoding (dataset.py:1404, dataset.py:1539, and the loaders' encoding_type defaults to "utf-8"), so the code page is not involved. I only tested on Windows.

Additional context

Root cause at 1e4f9e6e. The writers deepeval/dataset/dataset.py:1477-1480 (csv) and :1560-1563 (single-turn jsonl) call join_retrieval_context / join_context, which join on DELIMITER = "|" (deepeval/dataset/utils.py:20, :66-78) without escaping. The loaders split on the same delimiter at dataset.py:344, :351, :554, :561 and :819.

The writers already disagree with each other, which is what makes a small fix possible. For context = ["cat access.log | grep ERROR", "no pipe here"] the on-disk value is:

  • EvaluationDataset.save_as("jsonl"), single-turn: str, 'cat access.log | grep ERROR|no pipe here'
  • EvaluationDataset.save_as("jsonl"), multi-turn: list (dataset.py:1553)
  • Synthesizer.save_as("jsonl"): list

and add_goldens_from_jsonl_file already accepts a native list (dataset.py:816).

Proposed fix: write native lists in the single-turn JSONL branch, matching the multi-turn and Synthesizer JSONL writers; for CSV, write the list as a JSON array cell and have the loaders try JSON first and fall back to the delimiter split, which is the pattern parse_tools already uses for tools columns (dataset.py:542-549) and which keeps existing files loading.

One point for your call before I write it: this changes an on-disk format that the TypeScript SDK deliberately mirrors (typescript/src/dataset/dataset.ts:135, "jsonl flattens the list fields to delimited strings, as Python's does"). The alternative is escaping |, which is worse for hand-edited CSV files.

If you agree with the approach, I am happy to send the PR covering the Python writers and loaders, the TypeScript side, the docs lines, and round-trip tests including a backward-compatibility case for existing |-joined files.