Personas from search logs: what your users actually wanted, in their own words

The standard criticism of user personas is that they are fiction. Someone runs a workshop, the team invents "Marketing Mary, 34, values efficiency, enjoys yoga," and it goes on a wall. Nobody can say whether Mary is right, because nothing about her is falsifiable. She was assembled from assumptions, and she reflects them back with the authority of a laminated poster.

Meanwhile, sitting in a database that most sites never look at, is a record of what people typed into the search box when they wanted something and had to say so in their own words. Nobody prompted them. Nobody was watching. They were not being helpful — they were trying to find something.

That is behavioural data about intent, and it is free.

Before anything else: this is personal data

Search queries are user-generated content tied to a session, often an IP, sometimes an account. Under GDPR they are personal data, and they are frequently more revealing than the rest of your analytics combined — people type things into search boxes they would never say aloud.

Practical consequences: analyse them yourself rather than pasting them into a third-party tool, strip session identifiers before the queries reach a spreadsheet, aggregate before sharing internally, and be aware that a rare long query can identify one person. If you retain logs, have a retention period and enforce it.

Everything below can be done with a CSV and thirty lines of Python on your own machine, which is the point.

The sample

The log used throughout this article is synthetic — generated to have realistic shape, not taken from anyone's site. Real numbers from real users are not mine to publish, and invented numbers presented as real would be worse. The generator and the analysis script are both linked at the end so you can reproduce the figures or point the script at your own data.

It contains 431 search events across 84 distinct queries, over about two months, on a technical blog. That is a small site, deliberately — the interesting findings do not require scale.

Finding one: the head and the tail are different populations

Split the log by how often each query recurs:

BandDistinct queriesEventsShare of volume
Head (20+ occurrences)514834.3%
Torso (4–19)2016438.1%
Tail (1–3)5911927.6%

Five queries account for a third of all searching. Fifty-nine queries — 70% of everything distinct anyone ever asked — account for the other quarter between them.

Now the same data cut by phrasing, and this is where it gets useful:

 By event volumeBy distinct query
Navigational (1–2 words)55.5%19.0%
Topical (3–4 words)22.7%27.4%
Question (5+ words or interrogative)18.8%44.0%
Problem (error text, failure language)3.0%9.5%

Those two columns disagree almost completely, and the disagreement is the finding.

Weight by volume and your users look like people typing one or two words — bm25, tmux, ansible. Count distinct queries instead and nearly half of everything ever asked was a full question: how do i tune bm25 k1 and b for short documents, why is my drupal search returning old press releases first.

These are not the same person searching differently. They are two populations, and only one of them shows up if you look at your top queries — which is exactly what most people do.

Finding two: two personas, derived rather than invented

The clustering above gives you the personas directly. No workshop required.

The returner

Types one or two words. Already knows the site has what they want and is using search as navigation rather than discovery. bm25, tmux, puppet ubuntu. They are not evaluating you; they are retrieving something they read before.

What they need: the canonical article to be the first result, every time. Fast autocomplete. Nothing clever. For this persona, relevance work is nearly pointless — they will find it or they will use Google — but a search box that fails on your own article's main topic is embarrassing in a way they will remember.

The stuck practitioner

Types a sentence, often containing the exact error they are looking at: puppet deb.puppetlabs.com 403 forbidden, opcache save_comments drupal error, run_once serial runs multiple times.

They arrived mid-problem. They do not want an introduction, they want the specific paragraph that resolves this. They are the smaller share of volume and the larger share of distinct need — and they are the ones most likely to bounce, because a bag-of-words search handles a nine-word sentence badly, and because a general article often does contain their answer three-quarters of the way down where they will never reach it.

What they need: retrieval that survives long natural-language queries, which is the practical case for hybrid retrieval. Deep-linkable headings so a result can point at the relevant section rather than the top of the page. And crucially, the thing they searched for is often not the thing your article is about — it is a detail inside it.

Finding three: the vocabulary gap is a content brief

Pull the terms that appear frequently in queries and never in your article titles:

TermWeighted occurrences
prefix21
formula14
key14
ndcg11
robots.txt10
danish9
tfidf6
escape-time5
php-fpm5

Read that as a list of things people came looking for using words the site does not use about itself. Each row is one of three situations, and telling them apart is the whole exercise:

The content exists, findable by the wrong name. escape-time and prefix are sections inside an existing article. The fix is not writing anything — it is a synonym entry, a heading rename, or an anchor link.

The content does not exist. ndcg and tfidf are adjacent to what is covered but not covered. That is a content brief written by your users, with demand already demonstrated.

The content exists but is buried. danish appears in queries about stemming and compound words; it is a subsection of a longer article. Buried topics with real query volume are candidates for promotion to their own page.

This is the same phenomenon as the vocabulary mismatch problem in retrieval, seen from the other side. Technically it is a reason to add semantic search. Editorially it is a reason to change what you write and what you call it. Both responses are correct and they address different halves of the problem.

Finding four: failure signals beat click signals

The queries worth the most attention are the ones that failed, because a failure is unambiguous in a way a success never is.

Zero-result queries — someone asked, you had nothing. Every one is either a content gap or a vocabulary gap, and both are actionable.

Reformulations — two or more searches from one session within a minute or so. The first attempt failed, and the pair tells you how they re-described the same need. A sequence like tokenization then how to make search understand synonyms is someone discovering that the word they knew was not the word they needed.

Abandonment — searched, clicked nothing. Results looked wrong enough not to try.

Compare that to clicks, which are heavily position-biased: users click the first result substantially more often largely because it is first. A click tells you your ranking put something at the top. It does not tell you it was right. Reformulation and abandonment have no equivalent confound.

Where this connects to the rest of the stack

The same log feeds several things at once, which is what makes the exercise cheap.

The persona split gives you a sampling frame for a judged set. The search evaluation work needs queries drawn from across the distribution, and the head/torso/tail split is exactly that — with the added benefit that you now know the tail is a different population rather than just rarer.

The vocabulary gap gives you synonym entries and analyser configuration, plus a ranked content backlog.

And the intent mix tells you whether hybrid retrieval is worth it. At 44% of distinct queries being full questions, the case is strong. On a site where that number is 5%, adding vector search would be considerable machinery for very little — which is a conclusion you can only reach by looking.

What query logs cannot tell you

Stating this plainly, because the argument above is one-sided by design.

Only people who arrived and used the search box are in the data. Everyone who never found the site, or found it and left without searching, is invisible. Search logs are silent on your biggest audience problem by construction.

Site search is a minority behaviour. Most visitors arrive from Google onto a specific page and never touch your search. Your log describes a self-selected subset — typically the more engaged and more technical ones.

Behaviour is not motivation. You learn what someone typed, not why they needed it, what they did with the answer, or whether it helped. That question belongs to interviews, and this method does not replace them.

Small samples mislead. At 431 events, a single determined visitor running twenty searches is a visible bump. Treat findings as hypotheses until a second month agrees.

The honest framing is that query logs are strong evidence about intent among people already engaged, and no evidence at all about everyone else. Used that way they are one of the better inputs available, and they are already sitting in your database.

The scripts

Two files: one generates the synthetic log used above, one produces every table in this article. Point the second at a CSV with timestamp,query columns and it will run against your own data, locally, with nothing leaving the machine.

File 1

import csv, collections, re

rows = list(csv.DictReader(open("query_log.csv")))
queries = [r["query"] for r in rows]
freq = collections.Counter(queries)
total = len(queries)
distinct = len(freq)

# --- head / torso / tail by volume share ---
ordered = freq.most_common()
head = [(q,c) for q,c in ordered if c >= 20]
torso = [(q,c) for q,c in ordered if 4 <= c < 20]
tail = [(q,c) for q,c in ordered if c < 4]

def share(bucket): return sum(c for _,c in bucket)/total*100

print(f"TOTAL {total} events / {distinct} distinct\n")
print(f"head   {len(head):>3} distinct  {sum(c for _,c in head):>4} events  {share(head):5.1f}% of volume")
print(f"torso  {len(torso):>3} distinct  {sum(c for _,c in torso):>4} events  {share(torso):5.1f}%")
print(f"tail   {len(tail):>3} distinct  {sum(c for _,c in tail):>4} events  {share(tail):5.1f}%")

# --- query length ---
wl = collections.Counter(len(q.split()) for q in queries)
short = sum(c for w,c in wl.items() if w<=2)
mid   = sum(c for w,c in wl.items() if 3<=w<=4)
long_ = sum(c for w,c in wl.items() if w>=5)
print(f"\nlength  1-2w {short/total*100:5.1f}%   3-4w {mid/total*100:5.1f}%   5+w {long_/total*100:5.1f}%")

# distinct-query view (dedup) -- the tail is where long queries live
dl = collections.Counter(len(q.split()) for q in freq)
print(f"        distinct queries 5+ words: {sum(c for w,c in dl.items() if w>=5)}/{distinct}")

# --- intent classification ---
QUESTION = re.compile(r'\b(how|why|what|which|should|is|does|can|vs|versus)\b')
ERROR    = re.compile(r'\b(403|forbidden|error|not indexed|slow|nothing|failed|multiple times)\b')

def intent(q):
   w = q.split()
   if ERROR.search(q): return "problem"
   if QUESTION.search(q) or len(w)>=5: return "question"
   if len(w)<=2: return "navigational"
   return "topical"

by_intent = collections.Counter(intent(q) for q in queries)
print("\nintent (by event):")
for k,v in by_intent.most_common():
   print(f"  {k:<14} {v:>4}  {v/total*100:5.1f}%")

by_intent_d = collections.Counter(intent(q) for q in freq)
print("intent (by distinct query):")
for k,v in by_intent_d.most_common():
   print(f"  {k:<14} {v:>4}  {v/distinct*100:5.1f}%")

# --- vocabulary gap: terms users use that the site's article titles don't ---
site_vocab = set("""bm25 ranking search results tokenization indexing proximity
crawler polite web opensearch drupal api hybrid embeddings php attributes
plugin discovery tmux emacs keybinding puppet ubuntu install openvox ansible
orchestration docker multistage composer nginx mysql letsencrypt seo""".split())

words = collections.Counter()
for q,c in freq.items():
   for w in re.findall(r'[a-z0-9_.-]+', q.lower()):
       if len(w)>2 and w not in site_vocab:
           words[w]+=c

stop = {"the","and","for","how","why","what","which","does","can","are","not",
       "with","without","from","into","vs","versus","this","that","you","your",
       "index","first","best","using","use","make","get","set","run","runs","its"}
gap = [(w,c) for w,c in words.most_common(40) if w not in stop][:14]
print("\nfrequent terms absent from article titles:")
for w,c in gap: print(f"  {w:<22} {c}")

# --- zero-ish signals ---
print("\nlongest distinct queries (the tail speaks in sentences):")
for q in sorted(freq, key=lambda q:-len(q.split()))[:6]:
   print(f"  [{freq[q]}] {q}")
 

File 2

import random, csv, datetime
random.seed(20260801)

# Synthetic query log for a technical blog about search internals + infra.
# Shape modelled on a Zipf-ish distribution: small head, fat tail.

head = [
   ("bm25", 41), ("tmux", 33), ("puppet ubuntu", 28),
   ("opensearch drupal", 24), ("ansible", 22),
]

torso = [
   ("bm25 formula", 14), ("tmux prefix key", 13), ("search api opensearch", 12),
   ("puppet 7 install", 11), ("emacs tmux", 11), ("docker php", 10),
   ("ndcg", 9), ("hybrid search", 9), ("crawler robots.txt", 8),
   ("php attributes drupal", 8), ("ansible letsencrypt", 7),
   ("multistage docker build", 7), ("openvox", 7), ("tokenization", 6),
   ("composer install docker", 6), ("bm25 vs tfidf", 6), ("k1 b parameters", 5),
   ("drupal 11 attributes", 5), ("nginx php-fpm", 5), ("escape-time tmux", 5),
]

tail = [
   "how do i tune bm25 k1 and b for short documents",
   "why is my drupal search returning old press releases first",
   "opensearch hnsw memory requirements 1024 dimensions",
   "puppet deb.puppetlabs.com 403 forbidden",
   "is open source puppet dead",
   "openvox vs puppet difference",
   "migrate puppet manifests to ansible",
   "ansible rolling deploy haproxy drain",
   "run_once serial runs multiple times",
   "meta flush_handlers health check",
   "certbot nginx ansible chicken and egg",
   "letsencrypt rate limit 5 per week",
   "php 8.3 fpm socket path ubuntu 24.04",
   "composer install cache docker layer",
   "docker vendor stage composer no-autoloader",
   "opcache save_comments drupal error",
   "annotation is not annotated with @annotation",
   "drupal rector annotation to attribute",
   "search api opensearch index name prefix",
   "drupal search api solr vs opensearch",
   "elasticsearch vs opensearch drupal 11",
   "danish stemmer opensearch analyzer",
   "opensearch compound word danish",
   "how to make search understand synonyms",
   "search returns nothing for plural words",
   "vector search without openai",
   "eu hosted embedding model gdpr",
   "self hosted embeddings opensearch",
   "reciprocal rank fusion rank constant",
   "rrf vs normalization opensearch which",
   "hybrid search worth it small site",
   "how many queries for judged set",
   "ndcg vs precision at 10",
   "search relevance ab test significance",
   "click data position bias",
   "polite crawler crawl delay",
   "crawler trap calendar infinite urls",
   "url normalization utm parameters",
   "simhash near duplicate detection",
   "tmux escape time emacs meta key slow",
   "tmux prefix c-o vs c-a emacs",
   "danish keyboard tmux prefix",
   "emacs daemon vs tmux which",
   "vterm instead of tmux",
   "nginx reload vs restart zero downtime",
   "ansible vault best practice password",
   "ansible no_log mysql password",
   "mysql 8 auth_socket ansible login_unix_socket",
   "utf8mb4 vs utf8 mysql drupal",
   "ansible forks default slow",
   "ansible max_fail_percentage batch",
   "how do search engines rank pages",
   "build your own search engine php",
   "inverted index explained simply",
   "what is proximity search",
   "site not indexed by google",
   "crawled currently not indexed fix",
   "robots.txt blocking ai crawlers",
   "should i block gptbot",
]

rows = []
start = datetime.datetime(2026, 6, 1)

def emit(q, n, zero=False):
   for _ in range(n):
       ts = start + datetime.timedelta(
           days=random.randint(0, 60),
           seconds=random.randint(0, 86399))
       rows.append((ts, q))

for q, n in head: emit(q, n)
for q, n in torso: emit(q, n)
for q in tail: emit(q, random.randint(1, 3))

rows.sort()
with open("query_log.csv", "w", newline="") as f:
   w = csv.writer(f)
   w.writerow(["timestamp", "query"])
   for ts, q in rows:
       w.writerow([ts.isoformat(sep=" ", timespec="seconds"), q])

print(f"{len(rows)} query events, {len(set(q for _, q in rows))} distinct")
 

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.