Skip to content

Retrieval

Before writing, a voice picks a handful of the person's samples to show the writer as exemplars. Which samples it picks shapes the draft more than anything else in the request, so it's worth knowing exactly how they're chosen.

Structure, not subject

Retrieval is about structure, not topic. A request for a homepage hero should get the person's other short, punchy page openings, even ones about a different product, not a README or a tutorial on the same subject. The writer is there to imitate how the person writes a given kind of thing; the facts come from the request.

So metadataRetriever(), the default, ranks samples by how well their labels match the request's, with the kind of writing first and the subject last. It reads labels, not text: it never compares a sample's wording with the request.

Use the corpus's own labels

The most useful thing you can do for retrieval is make the request's type match the corpus's. type carries the most weight of any field (below), and labels are compared as words, not meanings. Two labels match fully when they're the same, ignoring case and surrounding spaces. Otherwise their match is the share of words they have in common: the words both labels contain, over all the distinct words in either. That has a consequence worth spelling out:

Request typeSample typeMatch
tweettweet1
launch-tweettweet0.5
docs-opening-paragraphdocs0.33
homepage-heropage0
blog-postessay0

So a request whose type shares no words with any sample's has lost most of what retrieval can do. Every sample scores 0 on it, and the ranking falls to the other fields, often just length and recency, so every such request gets the same few recent samples whatever it's for.

This is easy to miss with a small, hand-labelled corpus whose types happen to be the labels you'd guess. On a real corpus labelled essay, readme and docs, requests for homepage-hero and docs-opening-paragraph retrieved the same five short blog posts every time. So:

  • Request with the corpus's own vocabulary. Know the types your corpus uses (count them after preparation), and pick the closest in form: page for a homepage, docs for a docs introduction.
  • When a program writes the request, such as a step that turns a chat into a request, give it the list of types and have it choose one. Don't let it invent labels.
  • Label the corpus consistently. A handful of types used everywhere beats a precise label on every sample.

Length

A sample's length bucket comes from its word count: short is up to 30 words, medium up to 120, and long anything more. A request's length matches the same bucket fully and an adjacent one by half, so a short request prefers short samples. Length has a modest weight, so it mostly separates samples of the same type; it doesn't override a type match.

The default retriever picks whole samples; it doesn't excerpt them. To get short exemplars from a corpus of long writing, the corpus needs short writing of that type, or a custom retriever that excerpts. And since the writer tends to follow its exemplars' form, say the length you want in the request's brief as well; see form and length.

How samples are scored

The ranking itself is deterministic arithmetic over labels. Each sample is scored against the request, field by field:

FieldWeightMatch
type5same label, or the share of words in common
intent3"
audience2"
length1.5same length bucket 1, adjacent 0.5
tone1same label, or the share of words in common
recency1newest sample 1, oldest 0
topic0.5same label, or the share of words in common

Only the fields the request sets count, plus recency when any sample has a date. A sample's score is the weighted sum of its matches divided by the sum of those weights, so it runs from 0 to 1. A sample without a label the request asks for scores 0 on that field. The request's brief isn't used at all.

Labels come from each sample's own metadata, falling back to what prepareVoice() inferred. A voice built from a raw corpus only has the corpus's own labels; pass the PreparedVoice as corpus to retrieve with the inferred ones too.

Each exemplar in a WriteResult comes with its score and reasons, the fields that matched at 0.5 or better (and length when the bucket is the same), so you can see why it was picked.

Recency

When any sample has a date, every sample gets a recency score: 1 for the newest, 0 for the oldest, in proportion between, and 0 for samples without a date. It always counts, with weight 1, even when the request sets no fields. Raise it with metadataRetriever({ recency: 3 }) to favor recent writing more, or set it to 0.

Ties and determinism

Retrieval makes no model calls and is deterministic. Ties go to the newer sample, then to the lower id. So the same request against the same corpus always gets the same exemplars, and a request that sets nothing gets the most recent samples, or with no dates, the first by id.

Inspecting retrieval

voice.retrieve(request) returns the exemplars a write would use, without writing anything. Check it whenever drafts read wrong:

import type { Voice } from "june/copy";

declare const fonzie: Voice;

const exemplars = await fonzie.retrieve(
  { type: "tweet", intent: "announce a feature", length: "short" },
  { k: 10 },
);

for (const { sample, score, reasons } of exemplars) {
  console.log(sample.id, score.toFixed(2), reasons.join(", "));
}

Two symptoms are worth knowing:

  • The same samples come back for very different requests. The requests' labels aren't matching the corpus's, so the ranking has fallen back to length and recency. Check reasons: exemplars with no type in them weren't picked for their kind.
  • Right voice, wrong form. A request for a hero gets a draft with a heading and a bulleted list. Retrieve for that request and look at each exemplar's type and length: they're probably READMEs or tutorials. Fix the request's type to the corpus's closest label (page rather than homepage-hero), set length, and if the corpus has little writing of that form, add some.

Changing retrieval

Change the weights with metadataRetriever(). Any weight you leave out keeps its default:

import { openai } from "june/sdk/openai";
import { copyCorpus, metadataRetriever, voice } from "june/copy";

const fonzie = voice({
  name: "fonzie",
  corpus: copyCorpus("./fonzie.jsonl"),
  model: openai("gpt-5-mini"),
  retriever: metadataRetriever({ topic: 2, recency: 0 }),
});

Or pass any Retriever, an object with retrieve(request, samples, k) that returns { sample, score, reasons } for up to k samples, best first. It can be async, so it can use embeddings, a search index or your own rules, such as excerpting long samples for short requests:

import { metadataRetriever, type Retriever } from "june/copy";

const base = metadataRetriever();

/** Only recent writing, ranked by the default rules. */
const recent: Retriever = {
  retrieve: (request, samples, k) =>
    base.retrieve(
      request,
      samples.filter((sample) => (sample.metadata.date ?? "") >= "2023"),
      k,
    ),
};

The defaults may change as evals show what works; pass your own weights if you need them fixed (see compatibility).