Data

Preparing Training Data From Your Site

⏱ 17 min

Lesson 1 drew the line: fine-tuning is worth it only for a genuine behavior or vocabulary gap, and once you’ve decided that’s your actual problem, the fine-tuning run itself is only as good as the data you feed it. This lesson stays entirely inside WordPress and PHP, since pulling and shaping your own content is the one part of this whole process that’s genuinely a WordPress job. The output is a clean, structured file that Lesson 3’s external Python tooling reads as input, and everything past that point leaves PHP behind.

What you'll learn in this lesson
Pulling real content with WP_Query
Querying published posts by type, taxonomy, and date range so you export exactly the content that reflects the style you want.
Cleaning WordPress markup out of training text
Stripping shortcodes, blocks, and HTML down to the prose a language model should actually learn from.
Structuring examples as prompt/completion pairs
The shape most fine-tuning tooling expects, and how to derive it from real post fields.
Writing a JSONL export
The line-delimited JSON format Lesson 3's tooling reads directly.
Prerequisites

Lesson 1’s decision made honestly, in favor of fine-tuning, and a WordPress site with enough real, representative content, hundreds of posts at minimum, to actually demonstrate the style or vocabulary you’re trying to adapt a model toward. A handful of posts isn’t enough data to fine-tune anything reliably.

Step 1: Decide what “a training example” means for your case

Before writing any export code, be specific about what one training row represents, since this shapes the whole export:

Two common shapes for a WordPress training example
1
Instruction/completion pairs
A prompt like "Write a product description for [name]" paired with a real, published description in your house style, this is the shape for teaching tone and structure.
2
Raw domain text
Long stretches of your own published prose with no prompt attached at all, used for teaching vocabulary and phrasing patterns rather than a specific task.

Most WordPress use cases, adapting a model to write in your voice for a specific content type, fit the first shape better, since it teaches the model both the trigger and the expected response, not just a style in the abstract.

Step 2: Query real content with WP_Query

Pull the specific slice of content that actually reflects the style or domain you’re adapting toward, not your entire database indiscriminately:

// File: my-plugin/class-training-data-export.php
function my_plugin_get_training_source_posts(): array {
	$query = new WP_Query(
		array(
			'post_type'      => 'product',
			'post_status'    => 'publish',
			'posts_per_page' => -1,
			'date_query'     => array(
				array( 'after' => '2024-01-01' ), // Recent content, closer to current voice.
			),
		)
	);

	return $query->posts;
}

Scoping by post type and a recent date range matters here in a way it didn’t for Course 10’s RAG indexing. RAG benefits from indexing everything, since irrelevant content just scores low at retrieval time. A fine-tuning dataset actively teaches the model whatever you feed it, including old, off-brand, or low-quality writing if you don’t filter it out first.

Step 3: Strip WordPress markup down to real prose

Post content in the database is Gutenberg block comments, shortcodes, and HTML, none of which belongs in training text a language model should learn writing style from:

// File: my-plugin/class-training-data-export.php
function my_plugin_clean_post_content( string $raw_content ): string {
	$content = do_blocks( $raw_content );          // Render blocks to plain HTML first.
	$content = strip_shortcodes( $content );        // Remove any remaining shortcode tags.
	$content = wp_strip_all_tags( $content );       // Strip HTML tags, keep the text.
	$content = html_entity_decode( $content );       // & back to &, etc.
	$content = preg_replace( '/\s+/', ' ', $content ); // Collapse whitespace.

	return trim( $content );
}

This is deliberately simpler than Course 10’s chunking, since fine-tuning examples don’t need to be split into retrieval-sized pieces, most tooling handles a full, reasonably-sized post as a single training example directly.

Step 4: Build prompt/completion pairs from real post fields

Derive the “prompt” side from fields that already exist on the post, so each example reflects something a real user would actually ask for:

// File: my-plugin/class-training-data-export.php
function my_plugin_build_training_example( WP_Post $post ): ?array {
	$completion = my_plugin_clean_post_content( $post->post_content );

	if ( strlen( $completion ) < 200 ) {
		return null; // Too short to be a useful style example.
	}

	$product_name = get_the_title( $post );
	$category     = get_the_terms( $post->ID, 'product_cat' );
	$category_name = ( $category && ! is_wp_error( $category ) ) ? $category[0]->name : 'product';

	return array(
		'prompt'     => "Write a product description for {$product_name}, a {$category_name}.",
		'completion' => $completion,
	);
}

Discarding anything under a minimum length matters, a thin, low-effort post teaches the model that thin, low-effort output is what you want, which is the opposite of the goal.

Step 5: Export the full dataset as JSONL

Fine-tuning tooling, Hugging Face’s datasets library included, near-universally reads JSON Lines, one complete JSON object per line, rather than a single large JSON array:

// File: my-plugin/class-training-data-export.php
function my_plugin_export_training_data( string $file_path ): int {
	$posts   = my_plugin_get_training_source_posts();
	$written = 0;
	$handle  = fopen( $file_path, 'w' );

	foreach ( $posts as $post ) {
		$example = my_plugin_build_training_example( $post );

		if ( null === $example ) {
			continue;
		}

		fwrite( $handle, wp_json_encode( $example ) . "\n" );
		++$written;
	}

	fclose( $handle );

	return $written;
}

Test it: export and inspect the result

wp-env run cli wp eval '
$count = my_plugin_export_training_data( "/tmp/training-data.jsonl" );
echo "Wrote {$count} training examples\n";
'
head -n 2 /tmp/training-data.jsonl

You should see a real count in the hundreds, not a handful, and each line should be a complete, valid JSON object with non-empty prompt and completion fields, not truncated markup or an empty string.

A small, biased dataset produces a small, biased model

Fifty examples pulled from a single author’s posts teaches the model that one person’s quirks, not your brand’s actual voice. Pull from your full range of publishers if multiple people write for your site, and hold out a genuine, randomly-selected slice of this export entirely, untouched by training, since Lesson 7’s evaluation depends on having examples the model never saw during fine-tuning.

Recap

Training data preparation is the one step in this whole process that’s genuinely a WordPress job: WP_Query or get_posts() pulls the right slice of real content, stripping blocks, shortcodes, and HTML down to clean prose keeps markup out of what the model learns to write, and structuring the result as prompt/completion pairs in JSONL produces a file any standard fine-tuning tool can read directly. Set aside a held-out slice now, before training starts, Lesson 7 needs it later. Everything from here forward happens outside WordPress entirely.

Resources & further reading

← When Fine-Tuning Beats RAG, and When It Doesn't Fine-Tuning Open-Weight Models →