Building a Personal Voice Corpus
12 min read · updated August 4, 2026
You do not need to fine-tune anything to get output in your own register. Twenty to forty carefully chosen passages of your own writing, used as few-shot exemplars, will do more than a fine-tune on the same material — and unlike a fine-tune, you can test whether it worked with a measurement rather than a feeling.
Few-shot, not fine-tuning
Three reasons, in order of how much they matter.
- You do not have enough writing. A decade of published work is perhaps a few hundred thousand words. That is a small dataset by fine-tuning standards, and small datasets in style training produce memorisation of your topics rather than generalisation of your voice — output that keeps returning to the three subjects you happen to have written about.
- Style is largely surface, and surface transfers by demonstration. Sentence length distribution, punctuation habits, how you open a paragraph, what you refuse to do. Exemplars in the context demonstrate all of it directly, which is what few-shot prompting is for.
- Fine-tuning changes more than you asked. It moves the whole model, including instruction-following and factual behaviour, and the change is not reversible per request. A prompt is editable in a second; a fine-tune is a project. Fine-tuning becomes the right answer at volume and consistency requirements most individual writers never reach — the decision is covered properly elsewhere.
What goes in the corpus
Selection is the whole method. A corpus of everything you have written produces an average of your registers, which is nobody’s voice.
- One genre per corpus. Essays, documentation and email are three different people. Build three corpora if you need three, and never mix them.
- Your best work, not your representative work. The corpus is a target, not a description. Include the pieces you would want to have written again.
- Only what you actually wrote. Exclude anything heavily edited by somebody else, co-written, or written to a house style that is not yours. This is the rule most often broken and it silently imports an editor’s voice.
- Passages of 150–400 words, not whole pieces. A whole article pulls the model toward reproducing its structure and subject. A passage demonstrates register without dictating shape.
- Twenty to forty passages. Below about fifteen the model generalises from too little; beyond about forty you are paying for context that adds nothing, and you will only use a handful per request anyway.
- Deliberate variety within the genre. An opening, an explanation, an argument, a concession, a technical passage, an ending. If every exemplar is an opening paragraph, everything you generate will read like one.
Extracting and chunking the archive
If your archive is a folder of Markdown, this produces the candidate passages and their statistics so you can choose from a list rather than from memory.
# corpus.py — Python 3.9+, standard library only.
# Usage: python corpus.py ./archive > candidates.tsv
# Emits candidate passages with the numbers you need to pick between them.
import os, re, statistics, sys
MIN_WORDS, MAX_WORDS = 150, 400
def paragraphs(text):
text = re.sub(r"^---.*?^---", "", text, flags=re.S | re.M) # front matter
text = re.sub(r"^#{1,6} .*$", "", text, flags=re.M) # headings
text = re.sub(r"^\s*[-*>|] .*$", "", text, flags=re.M) # lists, quotes
return [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
def window(paras):
"""Consecutive paragraphs grouped into passages of the target length."""
buf, n = [], 0
for p in paras:
w = len(p.split())
if n + w > MAX_WORDS and n >= MIN_WORDS:
yield " ".join(buf); buf, n = [], 0
buf.append(p); n += w
if n >= MIN_WORDS:
yield " ".join(buf)
print("file\twords\tmean_sentence\tsd_sentence\tcommas_per_100\tpreview")
for root, _, files in os.walk(sys.argv[1]):
for name in sorted(f for f in files if f.endswith(".md")):
path = os.path.join(root, name)
text = open(path, encoding="utf-8", errors="replace").read()
for passage in window(paragraphs(text)):
words = passage.split()
sents = [s for s in re.split(r"(?<=[.!?])\s+", passage) if s.strip()]
lens = [len(s.split()) for s in sents] or [0]
sd = statistics.pstdev(lens) if len(lens) > 1 else 0.0
row = [
name,
str(len(words)),
f"{statistics.mean(lens):.1f}",
f"{sd:.1f}",
f"{100 * passage.count(',') / max(len(words), 1):.1f}",
passage[:70].replace("\n", " "),
]
print("\t".join(row))Sort the output by sentence-length standard deviation and read the top of the list first. High variance is the strongest single signal of deliberate prose — it means short sentences are being used for emphasis rather than by accident — and those passages are almost always the ones you want in the corpus.
Choosing exemplars per task
Do not paste all forty into every request. Pick four to six that match the shape of what you are about to write: writing an opening, use openings; writing a technical explanation, use technical passages.
Selection by hand is fine at this scale and you will do it in thirty seconds. If you want it automatic, an embedding-based nearest-neighbour lookup over your passages works, and the retrieval is the same machinery as anything else — but the gain over hand-picking from a list of forty is small, and the list of forty is something you should know by heart anyway.
Order the exemplars with the strongest last. Position matters, and the example nearest the instruction exerts the most pull.
The prompt shape
Below are passages by one author. Study how they are constructed: sentence length and its variation, where clauses are subordinated, punctuation habits, how paragraphs open and close, what the author does not do. PASSAGE 1 ... PASSAGE 2 ... (4 to 6 passages) Now write the following, in the same register. Match the construction, not the subject matter — do not reuse the topics, examples, phrases or opinions in the passages above. Constraints: - If you need a fact the brief does not supply, write [CHECK: ...] and keep going. - Do not summarise at the end. - Do not open with a definition or with the word "In". BRIEF ...
The line about matching construction rather than subject matter is load-bearing. Without it the model borrows your examples, your metaphors and occasionally your opinions, which is both wrong and embarrassing when it reaches a reader who has seen the original.
A quality test that is not your opinion
Everyone thinks their exemplars worked, because output that shares your topic feels like it shares your voice. Two tests, one subjective and done properly, one measured.
The delayed blind test
- Collect ten paragraphs of generated output and ten of your own from the same genre, none of which you have read this week.
- Strip all formatting, shuffle, number them, and put the key in a file you do not open.
- Wait a week. This is the part people skip and it is the part that makes the test worth anything.
- Label each one yours or not, then score. Above about fifteen out of twenty you can still tell the difference; at ten you are guessing, which is the result you wanted.
The function-word test
Stylometry has an old and well-validated result: authors are identifiable by the relative frequencies of their function words — articles, prepositions, conjunctions, pronouns — because those are used below the level of conscious choice and do not vary with subject. Burrows’s Delta, published in 2002, is the standard measure built on that observation, and a usable version of it is short.
# delta.py — Python 3.9+, standard library only.
# Usage: python delta.py corpus_dir candidate.txt
# A simplified Burrows's Delta: mean absolute z-score of function-word
# frequencies, with the z-scores taken against your own corpus.
import os, re, statistics, sys
FUNCTION_WORDS = """the of and to a in that it is was for as with but his
they be at one have this from or had by not word are all were we when your
can said there use an each which she do how their if will up other about
out many then them these so some her would make like him into time has look
two more write go see number no way could people my than first been call who
its now find long down day did get come made may part i you what our over
new very just any most us also very much still even""".split()
def freqs(text):
words = re.findall(r"[a-z']+", text.lower())
total = max(len(words), 1)
counts = {w: 0 for w in FUNCTION_WORDS}
for w in words:
if w in counts:
counts[w] += 1
return {w: c / total for w, c in counts.items()}, len(words)
docs = []
for name in sorted(os.listdir(sys.argv[1])):
path = os.path.join(sys.argv[1], name)
if os.path.isfile(path):
f, n = freqs(open(path, encoding="utf-8", errors="replace").read())
if n >= 300:
docs.append((name, f))
if len(docs) < 5:
sys.exit("need at least 5 corpus documents of 300+ words")
mean, sd = {}, {}
for w in FUNCTION_WORDS:
series = [f[w] for _, f in docs]
mean[w] = statistics.mean(series)
sd[w] = statistics.pstdev(series) or 1e-9
def delta(f):
return statistics.mean(abs((f[w] - mean[w]) / sd[w]) for w in FUNCTION_WORDS)
# Leave-one-out: how far are your own documents from your own centre?
own = []
for name, f in docs:
own.append((delta(f), name))
own.sort()
cand, n = freqs(open(sys.argv[2], encoding="utf-8", errors="replace").read())
d = delta(cand)
print(f"your own documents: delta {own[0][0]:.2f} to {own[-1][0]:.2f}")
print(f" median {statistics.median(x for x, _ in own):.2f}")
print(f"candidate ({n} words): delta {d:.2f}")
print("VERDICT:", "within your range" if d <= own[-1][0] else "outside your range")Read the result correctly, because it is easy to over-claim. This does not say the text is good, and it does not prove authorship. What it says is whether the candidate’s function-word profile falls inside the spread of your own documents. A candidate well outside that spread is measurably not in your register, whatever it feels like; a candidate inside it has cleared a floor, not a ceiling.
Doing it without handing over the archive
Your archive is a corpus of everything you have thought, and the exemplars go into a prompt on somebody else’s hardware. Three controls, in increasing order of effort.
- Check the retention and training terms of the specific endpoint you are calling, which frequently differ between a consumer product and the API of the same provider — the question is worth asking precisely.
- Keep the corpus local and send only the four to six exemplars a request actually needs. Most of the archive then never leaves your machine, and the difference in exposure is substantial for no loss of quality.
- Run a local model for drafting if the material is genuinely sensitive. Style transfer from exemplars is one of the tasks small local models do relatively well, because it is imitation rather than knowledge.
- Never put unpublished work in a corpus you send anywhere. Use published passages: they demonstrate your register just as well and they are already public.
The honest limit
Everything on this page reproduces surface. Sentence rhythm, punctuation, register, the shape of a paragraph — all of it is measurable and all of it transfers from examples.
What does not transfer is the part that made the exemplars worth imitating. Voice is a record of judgement: what you decided was worth saying, what you refused to claim, the example you cut because it was doing your argument a favour it had not earned. A corpus contains the consequences of those decisions and none of the deciding, so what you get back is prose that sounds like you and asserts nothing you would have chosen to assert.
Which puts this technique exactly where the rest of this cluster puts everything else: use it after you know what you are saying, never instead of knowing.