Skip to content
← All posts

Convert your raw corpus to SFT data: A walkthrough with AfterImage to generate legal research conversations

A guided explanation of the AfterImage pieces used in examples/caselaw_rag/generate.py.

Caselaw RAG generation walkthrough

This walkthrough explains examples/caselaw_rag/generate.py as an AfterImage composition, not just as a script to run. The goal is to make each moving part visible so you can replace the caselaw pieces with your own documents, prompts, storage, retrieval backend, or model provider.

The script generates synthetic legal research conversations. For demonstration, we use a small slice of the free-law/Caselaw_Access_Project_embeddings dataset: cleaned U.S. court opinion text with precomputed BAAI/bge-base-en-v1.5 vectors. This keeps the example easy to run while still showing the full RAG data-generation pattern.

You can use the same structure with your own corpus. The important requirement is that index_corpus.py or your own ingestion job creates a Qdrant collection with document text in a known payload field and vectors that match the embedding model used later for query-time retrieval.

The Mental Model

At a high level, the script wires together two related but different context flows:

FlowAfterImage piecePurpose
Instruction-side contextQdrantDocumentProvider + ContextualInstructionGeneratorCallbackSamples documents so the simulated user asks grounded questions.
Response-side retrievalQdrantRetriever + WithRAGRespondentPromptModifierRetrieves relevant excerpts for the assistant before it answers.

That split is the main idea. The user side gets a sampled briefing so it can ask realistic questions. The assistant side gets retrieval results so it can answer from the corpus instead of inventing facts.

The generation loop then looks like this:

text
Qdrant collection
  -> QdrantDocumentProvider samples context
  -> ContextualInstructionGeneratorCallback creates user instructions
  -> QdrantRetriever retrieves answer context for each instruction
  -> WithRAGRespondentPromptModifier injects retrieved context
  -> ConversationGenerator simulates user/assistant turns
  -> JSONLStorage writes conversations

Imports: The Building Blocks

The afterimage imports are the reusable pieces:

python
from afterimage import (
    ConversationGenerator,
    ContextualInstructionGeneratorCallback,
    EmbeddingProviderFactory,
    GenerationMonitor,
    WithRAGRespondentPromptModifier,
)
from afterimage.providers import QdrantDocumentProvider
from afterimage.retrievers import QdrantRetriever
from afterimage.storage import JSONLStorage

You can read them as roles:

  • ConversationGenerator is the orchestrator-facing facade. It runs the simulated conversation and saves rows.
  • ContextualInstructionGeneratorCallback creates the initial user questions from sampled document context.
  • QdrantDocumentProvider is a document sampler. It gives AfterImage source text for instruction generation.
  • QdrantRetriever is a semantic retriever. It searches Qdrant for passages relevant to the current instruction.
  • WithRAGRespondentPromptModifier adds retrieved passages to the assistant prompt before the assistant answers.
  • EmbeddingProviderFactory creates the query embedding backend used by QdrantRetriever.
  • GenerationMonitor records generation metrics and alerts.
  • JSONLStorage writes the final dataset.

The Qdrant client imports are infrastructure rather than AfterImage concepts:

python
from qdrant_client import AsyncQdrantClient, QdrantClient

This example uses the sync client for document sampling and the async client for retrieval.

Prompts: Defining the Two Actors

The script defines two system prompts:

python
RESPONDENT_PROMPT = """You are a careful senior legal research assistant..."""
CORRESPONDENT_PROMPT = """You are an experienced lawyer or a legally curious client..."""

In AfterImage terms:

  • The respondent is the assistant being trained or simulated.
  • The correspondent is the user side of the conversation.

The respondent prompt is strict about grounding:

  • Use retrieved court opinions and excerpts.
  • Preserve identifiers when available.
  • Explain in plain English.
  • Say what is missing when the context is insufficient.
  • Do not provide real-world legal advice.

The correspondent prompt shapes the simulated user:

  • Ask realistic legal questions.
  • Stay in role.
  • Do not invent docket numbers or citations.
  • Use the provided briefing as inspiration.

When adapting this example, these prompts are usually the first thing to change. For customer support, the respondent might be a support agent and the correspondent might be a customer. For medical education, the respondent might be a study tutor and the correspondent might be a student. The architecture can stay the same while the roles change.

CLI Arguments: Making the Example Reusable

_build_parser() exposes the parts you are likely to tune:

python
p.add_argument("--qdrant-url", ...)
p.add_argument("--collection", ...)
p.add_argument("--num-dialogs", ...)
p.add_argument("--max-turns", ...)
p.add_argument("--max-concurrency", ...)
p.add_argument("--gemini-model", ...)
p.add_argument("--embedding-model", ...)
p.add_argument("--output", ...)

Most flags also read from environment variables. That makes the same script usable in local demos, scheduled jobs, and documentation examples without editing source code.

Important knobs:

FlagWhy it matters
--collectionWhich Qdrant collection to read from.
--content-keyPayload field that contains document text. It must match indexing.
--max-docsHow many documents the document provider can sample.
--num-dialogsTarget number of conversations to generate.
--max-turnsMaximum turns per conversation. AfterImage samples uniformly from 1..max_turns.
--max-concurrencyNumber of concurrent conversation workers. Keep low for tight API quotas.
--gemini-max-retriesRetry count for transient Gemini 429/5xx responses.
--embedding-modelQuery embedding model. It must match the indexed vector family and dimension.
--auto-improveEnables evaluator-based quality retries. This costs additional LLM and embedding calls.

Qdrant Connection

The helper _qdrant_kwargs() builds shared client settings:

python
def _qdrant_kwargs(url: str, api_key: str | None) -> dict:
    kwargs: dict = {"url": url, "timeout": 120.0}
    if api_key:
        kwargs["api_key"] = api_key
    return kwargs

Both local Qdrant and Qdrant Cloud use the same shape. Cloud adds an API key; local Docker usually does not.

In _async_main(), the script creates both clients:

python
qd = QdrantClient(**qd_kw)
qd_async = AsyncQdrantClient(**qd_kw)

The async client is closed in finally:

python
await qd_async.close()

That pattern is worth copying for any script that owns network clients.

Monitoring and Alerts

The monitor records metrics such as generation time, success rate, error rate, token usage, and conversation length:

python
monitor = GenerationMonitor(
    log_dir=str(args.log_dir),
    alert_handlers=[on_alert],
    metrics_interval=60,
)

In this example, alerts are printed:

python
def on_alert(alert) -> None:
    print(f"alert - {alert.name} - {alert.message}")

For production workflows, this could send alerts to a dashboard, Slack, logs, or your own observability pipeline. For documentation and demos, printing is enough because it shows when generation quality or provider reliability is degrading.

Instruction-Side Context

This block creates the document provider:

python
documents = QdrantDocumentProvider(
    client=qd,
    collection_name=args.collection,
    content_key=args.content_key,
    max_docs=args.max_docs,
)

QdrantDocumentProvider gives AfterImage documents to sample from. It is not the same as retrieval. It is used before the conversation starts, so the instruction generator can create grounded user questions.

Then the script creates the instruction callback:

python
instruction_cb = ContextualInstructionGeneratorCallback(
    api_key=api_key,
    documents=documents,
    model_name=args.gemini_model,
    num_random_contexts=1,
    llm_create_extras={...},
)

This callback asks the LLM to produce user instructions from sampled context. Its output is a GeneratedInstructions object containing:

  • instructions: one or more user prompts.
  • context: the sampled source text.
  • context_id / context_ids: metadata for coverage tracking.

In your own project, swap QdrantDocumentProvider for another provider if your source material lives somewhere else:

  • InMemoryDocumentProvider for small examples.
  • DirectoryDocumentProvider for local files.
  • JSONLDocumentProvider for prepared document rows.
  • QdrantDocumentProvider for vector database-backed corpora.

Query Embeddings

The retriever needs to embed user instructions before searching Qdrant:

python
embedding_provider = EmbeddingProviderFactory.create(
    {
        "type": "process",
        "model": args.embedding_model,
        "workers": args.embedding_workers,
    },
)

This example uses a local SentenceTransformer process pool. That is why setup requires:

bash
uv sync --extra embeddings-local

The important rule is that query embeddings must be compatible with indexed vectors. Here, index_corpus.py stores 768-dimensional BAAI/bge-base-en-v1.5 vectors, so generate.py defaults to the same model.

If you index with a different model, change both indexing and querying together. Otherwise Qdrant search may fail or return poor matches.

Response-Side Retrieval

The retriever is built separately from the document provider:

python
retriever = QdrantRetriever(
    client=qd,
    collection_name=args.collection,
    embedding_provider=embedding_provider,
    async_client=qd_async,
    payload_key=args.content_key,
    limit=3,
)

This retrieves the top matching excerpts for a generated instruction. limit=3 means the assistant will see up to three retrieved passages.

Then the retriever is wrapped in a respondent prompt modifier:

python
modifier = WithRAGRespondentPromptModifier(retriever=retriever)

The modifier runs before the assistant answers. It takes the base respondent prompt and adds retrieved context, so the assistant response is grounded in search results.

This is the pattern to copy when you want RAG-style synthetic conversations:

text
DocumentProvider -> helps produce the user question
Retriever        -> helps produce the assistant answer
PromptModifier   -> injects retrieved answer context

Storage

The generated rows are written to JSONL:

python
storage = JSONLStorage(conversations_path=str(args.output))

JSONL is a good default because it is easy to inspect, stream, and convert later. Each row contains the conversation plus metadata such as sampled context, retrieved context, and context ids.

If your workflow needs a database, AfterImage also supports SQL storage. The rest of the generation composition can stay mostly the same.

The ConversationGenerator

This is where the pieces come together:

python
conv_gen = ConversationGenerator(
    respondent_prompt=RESPONDENT_PROMPT,
    correspondent_prompt=CORRESPONDENT_PROMPT,
    api_key=api_key,
    model_name=args.gemini_model,
    monitor=monitor,
    auto_improve=args.auto_improve,
    storage=storage,
    instruction_generator_callback=instruction_cb,
    respondent_prompt_modifier=modifier,
    embedding_provider=embedding_provider if args.auto_improve else None,
    llm_factory_kwargs={...},
)

The most important fields are:

ArgumentMeaning
respondent_promptSystem prompt for the assistant side.
correspondent_promptSystem prompt for the simulated user side.
instruction_generator_callbackProduces the first user message from sampled context.
respondent_prompt_modifierAdds retrieved context before the assistant answers.
storagePersists generated conversations.
monitorTracks metrics, alerts, and plots.
auto_improveRuns evaluator-based retries when enabled.
llm_factory_kwargsPasses provider-specific options, such as Gemini retry settings.

This construction is the reusable recipe. The caselaw dataset is just one instance of it.

Running Generation

The actual generation call is short:

python
await conv_gen.generate(
    num_dialogs=args.num_dialogs,
    max_turns=args.max_turns,
    max_concurrency=args.max_concurrency,
)

num_dialogs controls how many conversations to produce.

max_turns is not "always exactly this many turns." AfterImage samples the actual turn count uniformly from 1 through max_turns. With max_turns=1, every conversation is single-turn.

max_concurrency controls how many conversation workers run at once. Higher values can improve throughput but also increase API pressure. For Gemini free or tight quotas, 1 is the safest starting point.

Cleanup

The script closes resources in a finally block:

python
finally:
    await embedding_provider.aclose()
    await qd_async.close()
    monitor.shutdown()

This matters because generation scripts often run for a long time and own process pools, async HTTP sessions, and monitoring threads.

If you add another resource, such as a database client or custom retriever, close it here too.

How To Adapt This Example

Use this checklist when adapting the example to another domain:

  1. Replace RESPONDENT_PROMPT with the assistant behavior you want.
  2. Replace CORRESPONDENT_PROMPT with the user role you want to simulate.
  3. Choose a document provider for your source material.
  4. Make sure your indexed vectors and query embedding model match.
  5. Choose a retriever and prompt modifier if the assistant should use RAG.
  6. Pick storage: JSONL for files, SQL for database-backed runs.
  7. Start with low num_dialogs and max_concurrency.
  8. Turn on auto_improve only when you are ready to pay for quality retries.
  9. Inspect output JSONL before scaling up.

Common Variations

Use local files instead of Qdrant for instruction context

If you do not need vector-backed sampling, use a file or directory provider for the instruction side. You can still use a retriever for response-side RAG, or skip retrieval entirely and use WithContextRespondentPromptModifier for simpler context injection.

Generate customer support data

Keep the same shape:

text
support articles -> document provider -> customer questions
support articles -> retriever -> agent answers

Change the prompts so the respondent is a support agent and the correspondent is a customer with realistic issues.

Generate domain tutoring dialogs

Use textbook sections, lecture notes, or documentation pages as source material. The correspondent can be a beginner, advanced learner, or examiner. The respondent can be a tutor that explains concepts and asks clarifying questions.

Skip RAG for simpler datasets

If every generated answer can rely on the same sampled context, you can remove QdrantRetriever and WithRAGRespondentPromptModifier, then use a simpler context prompt modifier. The tradeoff is less dynamic answer-time retrieval.

What This Example Teaches

The important lesson is not "caselaw plus Qdrant." The reusable pattern is:

text
source material
  -> instruction generation
  -> optional retrieval
  -> controlled assistant/user simulation
  -> monitored JSONL dataset

Once you understand those pieces, you can assemble your own synthetic dataset pipeline for legal research, customer support, technical documentation, medical education, internal knowledge bases, or any domain where conversations should be grounded in source documents.

Your model. Not theirs.