Search log guide: what to look for in your export
TensorOpt wrote the Search Eval Logger plugin for Liferay DXP. This guide is for whoever opens the export. It needs no relevance labels, no email address and no tools beyond jq, a free command-line JSON processor. The commands below were tested with jq 1.7.1.
A search log answers some questions well and others not at all. It records what people typed and what the search showed them. It cannot say whether the results were good. The sections below follow that line: first what you can read directly, then where you need more.
Before you start
Unzip the export. It holds three files: events.jsonl (one search request per line), manifest.json (settings and coverage) and README.md (the schema). Run the commands from the folder that holds them. Each search has a query, a created_at time, a total_hits count, requested_size and logged_hit_count, an audience_type, a cohort_hash, applied_facets with a facet_capture_status, requested_from (the offset of the page requested), and a list of hits. Each hit has a rank. Ranks are zero-based and absolute: the top result of the first page is rank 0, and with ten results per page the first result of page two is rank 10.
Paging matters for counting. A search page that lets people move to page two can write one row per page request, each with the same query, and requested_from shows which page the row is. The commands below count searches, not page views, so they keep only rows where requested_from is 0, the first page of each search. Without that filter, a query that people page through would look like several searches.
The commands also lowercase and trim each query before counting, so "Holiday" and "holiday " count as one query. The lowercasing covers ASCII letters only: on a portal with accented text, "Ügy" and "ügy" are still counted as two queries. Merge such variants by hand when you read the lists.
1. What the log is and is not
The log holds queries and the results shown. It holds no relevance judgments: nothing says a result was good, and no clicks are recorded. Two limits shape every number below.
First, capture is bounded by what the search page asked for. A widget that shows ten results per page logs ten, even when thousands matched. Compare total_hits with logged_hit_count to see how often the logged list is shorter than the match count. The second part shows which page sizes your search pages request:
# M3: how often is the logged list shorter than the match count?
jq -s 'map(select(.requested_from == 0))
| {searches: length,
list_shorter_than_total: (map(select(.total_hits > .logged_hit_count)) | length),
requested_size: (group_by(.requested_size)
| map({size: .[0].requested_size, searches: length}))}' events.jsonlSecond, Liferay filters results by each user's permissions, so the same query can return different lists to different people, and a figure averaged over all users mixes those lists.
2. Zero-result queries and their causes
A search with a total_hits of 0 returned nothing. Start with the rate, overall and for guests and signed-in users separately. Results depend on permissions, so the two groups can be shown different result sets, which is why Z1 reports them apart.
# Z1: zero-result rate, overall and by audience_type
jq -s 'map(select(.requested_from == 0))
| def zero: map(select(.total_hits == 0)) | length;
{searches: length, zero_result: zero, rate: (zero / length),
by_audience: (group_by(.audience_type)
| map({audience_type: .[0].audience_type, searches: length,
zero_result: zero, rate: (zero / length)}))}' events.jsonlWhat counts as a high rate? Published zero-result rates mostly come from e-commerce vendors, and there is no reliable public norm for portal or intranet search. Do not compare your rate with anyone else's. Record it now, record it again after each change to content or search settings, and watch the trend.
Then read the queries behind it. Z2 lists the twenty zero-result queries that were searched most often, with the number of searches for each.
# Z2: most frequent zero-result queries
jq -s '[.[] | select(.requested_from == 0 and .total_hits == 0)
| .query | ascii_downcase | gsub("^\\s+|\\s+$"; "")]
| group_by(.) | map({query: .[0], searches: length})
| sort_by(-.searches) | .[:20]' events.jsonlSort each into one of four causes:
- Vocabulary gap. Users say "holiday", your content says "annual leave".
- Typo. A misspelling that keeps coming back.
- Missing content. Nothing in the index answers the question.
- Permission filtering. The content exists, but this user may not see it. Z3 lists the queries that returned nothing for a guest and something for a signed-in user. It cannot tell permissions from a filter the guest applied, which can also leave a list empty:
# Z3: zero results for a guest, results for a signed-in user
jq -s 'map(select(.requested_from == 0)
| . + {q: (.query | ascii_downcase | gsub("^\\s+|\\s+$"; ""))})
| group_by(.q)
| map(select(any(.[]; .audience_type == "GUEST" and .total_hits == 0)
and any(.[]; .audience_type == "AUTHENTICATED" and .total_hits > 0))
| .[0].q)' events.jsonlFor vocabulary gaps, the Liferay tool is Synonym Sets. Per Liferay Learn, a Synonym Set is a group of words or phrases with the same meaning, and when a user searches one of them the others are searched too. You manage them in the Global Menu under Applications, Search Tuning, Synonyms. Synonym Sets are not supported when Solr is the search engine. The export does not contain your Synonym Sets; the Synonyms screen shows what already exists.
3. Head versus tail
A few queries make up the head of your traffic; the long tail is everything searched rarely. This command shows how concentrated your traffic is: the share held by the ten most frequent queries, and how many distinct queries appeared only once.
# H1: concentration of traffic
jq -s 'map(select(.requested_from == 0))
| length as $n
| (map(.query | ascii_downcase | gsub("^\\s+|\\s+$"; ""))
| group_by(.) | map({query: .[0], searches: length})
| sort_by(-.searches)) as $q
| {searches: $n, distinct_queries: ($q | length),
top_10_share: (($q[:10] | map(.searches) | add) / $n),
seen_once: ($q | map(select(.searches == 1)) | length),
top_queries: $q[:10]}' events.jsonlH2 prints the titles at ranks 0, 1 and 2 for the first logged first-page search of each of your five most frequent queries. Other users may have been shown different lists, so treat it as one example, not the whole picture. Read it as a user would, and note which page is first for each query:
# H2: top three results of the first logged first-page search of the five most frequent queries
jq -s 'map(select(.requested_from == 0)
| . + {q: (.query | ascii_downcase | gsub("^\\s+|\\s+$"; ""))})
| group_by(.q) | sort_by(-length) | .[:5]
| map({query: .[0].q, searches: length,
top_results: (.[0].hits | map(select(.rank < 3) | .title))})' events.jsonlThe Liferay tool for the top of a result list is Result Rankings. Per Liferay Learn, they let you pin results to the top of the list for specific keywords, or hide results. A ranking applies only when the user's search phrase exactly matches its keyword or one of its aliases, and it affects only searches that use the newer search widgets, such as Search Bar and Search Results. Liferay's documentation also says Result Rankings do not work well with Search Blueprints: a result you pinned, but that the Blueprint filters out, still appears at the top. Result Rankings are not in the export either.
The tail is too long to read query by query, so H1 counts it instead: distinct_queries and seen_once. The log records what was typed and what was shown, not whether those searches were answered well.
4. Reformulations
A reworded search appears as two searches in a row from the same user with different text. There is no session ID, so approximate: take consecutive searches by the same cohort_hash within five minutes where the query text changed.
# R1: a search followed within 5 minutes by a different one, same signed-in cohort
jq -s '[.[] | select(.requested_from == 0 and .audience_type == "AUTHENTICATED")
| {cohort_hash, t: (.created_at | fromdateiso8601),
q: (.query | ascii_downcase | gsub("^\\s+|\\s+$"; ""))}] as $s
| ($s | group_by(.cohort_hash)
| map(sort_by(.t) | [., .[1:]] | transpose
| map(select(.[1] != null and .[1].t - .[0].t <= 300
and .[1].q != .[0].q)))
| add // []) as $pairs
| {signed_in_searches: ($s | length), reworded_within_5_min: ($pairs | length),
examples: ($pairs[:5] | map({from: .[0].q, to: .[1].q,
seconds: (.[1].t - .[0].t)}))}' events.jsonlTwo cautions. Use signed-in users only: guests cannot be told apart, so their searches cannot be linked. And cohort_hash is a salted hash whose salt rotates weekly by default, so a pair that straddles a rotation is never found. The result is a rough signal. It also counts unrelated searches made close together. Read the examples to see what changed between the two queries; the recipe shows what changed, not why.
5. Facet use
Facets are the filters people apply to a result list. They are visible only where facet_capture_status is CAPTURED. NONE_APPLIED means the user applied none. UNAVAILABLE means the selections could not be read on that search path, and it must not be read as "none".
# F1: how many searches fall in each facet_capture_status?
jq -s 'map(select(.requested_from == 0))
| group_by(.facet_capture_status)
| map({facet_capture_status: .[0].facet_capture_status, searches: length})' events.jsonl# F2: most used facet values, where capture worked
jq -s '[.[] | select(.requested_from == 0 and .facet_capture_status == "CAPTURED")
| .applied_facets | to_entries[] | {facet: .key, value: .value[]}]
| group_by([.facet, .value])
| map({facet: .[0].facet, value: .[0].value, searches: length})
| sort_by(-.searches) | .[:10]' events.jsonlCheck F1 before you read F2. If most rows are UNAVAILABLE, F2 describes only a slice of your traffic and says nothing about the rest. The other recipes include UNAVAILABLE rows. That is fine for overall counts, but a filter the user applied can itself empty a result list, and on those rows you cannot see it. So drop them before you compare the same query across rows.
Then read F2. It prints the ten facet values applied on the most searches, with the number of searches for each, and only from CAPTURED rows. Deleting | .[:10] from the end lists every value applied on captured searches; that full list can be set beside the facets your search pages offer. The export README adds a warning: a Blueprint can force a fixed tag or category on a search page, and that filter is not in applied_facets, so two identical-looking rows can come from unrelated result sets.
6. Query form
Queries come in different forms. This command sorts queries into rough shapes: questions, single tokens that contain a digit (identifier-like: part numbers, ticket IDs, policy numbers), other single words, and everything else.
# Q1: rough query shapes
jq -s 'map(select(.requested_from == 0) | .query | gsub("^\\s+|\\s+$"; ""))
| map(if test("\\?$") or test("^(how|what|why|when|where|who|can|does|do|is|are|should)\\s"; "i")
then "question"
elif test("^\\S+$") and test("[0-9]") then "identifier_like"
elif test("^\\S+$") then "one_word"
else "multi_word" end)
| group_by(.) | map({form: .[0], searches: length})' events.jsonlThe rules are crude, so change them to fit your vocabulary. The split between identifier-like queries and natural-language questions hints at where semantic search might help. That is a hypothesis to measure, not a conclusion: a log shows what people typed, not whether another approach would answer better.
7. Field coverage from the manifest
Read manifest.json before you trust anything above. It records the counts, the settings in force, the dropped events and the share of hits that carry a title or a snippet:
# M1: counts, coverage and dropped events from the manifest
jq '{counts, field_coverage_rates,
dropped_events: .admission_counters.dropped_event_count}' manifest.jsonTitles and snippets are recorded only where the search response already contained them, and snippets generally appear only where the search UI switched highlighting on. Where coverage is low, the search response did not carry those fields, so the export has not lost them. This command recomputes the figures from the rows, so you can check the manifest and your own reading of the file. It uses every row, including later pages, because the manifest counts every hit:
# M2: recompute counts and coverage from events.jsonl
jq -s '[.[].hits[]] as $h
| {events: length, hits: ($h | length),
title_rate: (($h | map(select(.title != null)) | length) / ($h | length)),
snippet_rate: (($h | map(select(.snippet != null)) | length) / ($h | length))}' events.jsonlThe admission counters in the manifest are process-wide and count since the plugin last started, so they bound the loss rather than measure it. Before you plan any labelling, check title and snippet coverage: a judge needs something to read.
8. Where a log stops being enough
A log lists what people typed and what they were shown. Three questions sit beyond it, and nothing in the file answers them:
- What do the numbers mean for your decision?
- Which layer of your system should you measure first?
- How large should a labelling sample be?
Judging whether a change helped needs two more things the log does not hold: labels, which are relevance judgments on a sample of logged queries made by people who know your content, and a paired comparison of two configurations on the same queries, because two averages can differ by chance.
A Readout is the paid first step that works through those three questions on your data. If you have an export and want that help, this page explains what an evaluation of your export involves.
Sources for the Liferay behaviour above
Checked on Liferay Learn on 22 October 2026. Your DXP version may differ, so check the pages for your release: Synonym Sets and Result Rankings.