Advanced Search Spec — Role-Aware, Structured-Entity Fanfic Archive Search
0. The root cause (and the one idea that fixes all three of your complaints)
Every pain you listed — and most AO3 complaints generally — traces to one design decision: AO3 tags are flat strings attached to a work with no internal structure and no notion of role.
Harry Potter as a character tag can't say "protagonist" vs "cameo."
Harry/Draco is an opaque string, so the system can't answer "any ship involving Harry."
BAMF Harry Potter fuses an attribute and a character into one token, so the attribute can't be re-bound or role-scoped.
The fix is a single generalization: replace flat tags with structured annotations. Every tag becomes a typed entity, optionally bound to a subject (a character or ship), optionally constrained by a role (main, supporting, POV, primary pairing). Once that's the data model, all three of your requests — and most AO3 gripes — become query operators over the same mechanism.
Everything below assumes we keep the pieces of the current build that already work (boolean query parser, faceted search with dynamic counts, full-text body search with <mark> snippets, trigram typo tolerance, zero-result relaxation, kudos/comment bounds, main_char_attr) and generalize them.
1. Data model
Entities & aliases
entities(id, kind, canonical_name, fandom_id NULL)
-- kind ∈ fandom | character | ship | attribute | warning | category | rating
entity_aliases(entity_id, alias, source) -- "Draco", "Drarry"→ship, spacing/case variants
All search terms resolve through the alias table before matching, so "Draco," "draco malfoy," and "Draco Malfoy" are the same query. Ship names ("Drarry") are aliases of ship entities. Characters are disambiguated per-fandom where needed.
Role-aware characters
work_chars(work_id, char_id, tier, is_pov, confidence, source)
-- tier ∈ main | supporting | cameo | mentioned
-- source ∈ author | order | text | curator | vote
Tier is computed from layered evidence, resolved in this priority order:
| Source |
Signal |
Trust |
author |
Explicit "main character" stars / POV checkboxes on the post form |
Highest — locked against silent override |
order |
Position in the author's character tag list (first-listed = main) — this is exactly the current score-10/score-1 heuristic |
Cheap, decent |
text |
Batch analysis of the cached body: mention-frequency per chapter, POV detection |
High when the work has many chapters |
curator / vote |
Human review queue + community correction |
Overrides order/text |
Structured ships
ships(id, bond_type) -- romantic (/) | platonic (&) | ambiguous | one_sided | past
ship_members(ship_id, char_id) -- n-ary: pairs, polycules
work_ships(work_id, ship_id, is_primary, confidence, source)
Ships are first-class entities decomposed into character members, with the / vs & distinction (romantic vs platonic) made explicit at ingest, and a is_primary flag (main pairing vs background ship — the current "first-listed ship = 5" heuristic seeds this).
Key rule: ship membership implies character presence. A work tagged Harry/Draco but no individual character tags (very common on AO3) automatically gets Harry and Draco at supporting-or-better.
Scoped attributes
work_attrs(work_id, attr_id, subject_kind, subject_char_id, subject_ship_id, confidence, source)
-- subject_kind ∈ work | character | ship
An attribute can be:
- work-level (
Slow Burn, Angst) — applies to the whole fic
- character-bound (
BAMF Harry Potter → attr BAMF, subject Harry)
- ship-bound (
Slow Burn Drarry → attr Slow Burn, subject ship Harry/Draco)
Binding happens three ways: parsing compound tags ("BAMF Harry Potter" → BAMF + Harry, matched against the entity registry), author attaching an attribute to a character in the post form, and inference (attribute + exactly one main character → bind with medium confidence).
This is the current main_char_attr=Character|Attribute generalized from one special-cased filter into the uniform annotation model; the old param becomes syntactic sugar.
2. The three flagship capabilities
2.1 Role-aware character filtering
Filtering by a character gets a role selector: any | main | supporting | main-or-supporting | pov.
char:"Harry Potter"!main → Harry is a protagonist. No more cameo pollution.
- Account-level default: users can set "character filters mean main or supporting unless I say otherwise" — which I'd argue should be the site default, with "include cameos/mentions" as an explicit expansion.
!pov for "narrated by this character" (detectable from chapter headers like Harry's POV and first-person narration; text-pass confidence, surfaced honestly).
Uncertainty is visible, never silent. If a role filter hides works whose roles are low-confidence, the UI says "42 more works with uncertain character roles — include them?" rather than dropping them.
2.2 Ship algebra (the "exclude every ship with A" problem)
Because ships are structured, these all become primitive operators:
| Need |
Operator |
| Exact ship |
ship:Harry/Draco |
| Ship by alias |
ship:Drarry |
| Any ship containing A, any partner |
ship:*Harry |
| No ship containing A — but keep A as a character |
-ship:*Harry |
| A and B appear, not shipped together |
char:Harry char:Draco -ship:Harry/Draco |
| A and B, friends only |
char:Harry char:Draco ship:Harry&Draco (platonic bond) |
| Romantic only / platonic only |
ship_bond:romantic / ship_bond:platonic |
| This ship is the main pairing |
ship:Drarry!main |
| No ships at all (gen) |
-ship:* |
| Poly superset match |
ship_members:Harry+Draco matches Harry/Draco and Harry/Draco/Hermione |
Your exact tired-of-A/{B..Z} query is: char:"Harry Potter" -ship:*Harry — Harry present, shipped with nobody. Or enumerate if only some pairings exhaust you: -ship:Harry/Draco -ship:Harry/Ginny.
2.3 Role-scoped attributes
| Need |
Query |
| BAMF protagonist, whoever they are |
attr:BAMF!main |
| BAMF Harry specifically |
attr:BAMF!char:"Harry Potter" (combine with char:Harry!main if he must also be the lead) |
| Slow burn on the primary pairing |
attr:"Slow Burn"!main_ship |
| Slow burn on a specific ship |
attr:"Slow Burn"!ship:Drarry |
| Attribute anywhere (classic AO3 behavior) |
attr:BAMF (loose — matches work-level or any binding) |
Strict scoping (!main) requires an actual binding; unbound work-level attributes don't silently pass. When ingest couldn't bind confidently, that shows up in the uncertainty bucket, not as false positives.
3. Query language
One search bar, dual-mode: plain faceted clicking for casual users, a real query syntax for power users (extends the existing boolean parser).
query := clause ((AND|OR) clause)* -- whitespace = implicit AND
clause := [NOT | -] term
term := "exact phrase" | field:value | bareword
field := title: author: summary: body: fandom: char: ship: attr: warning:
scope := !main | !supporting | !pov | !main_ship | !char:<entity> | !ship:<entity>
wildcard:= ship:*Harry char:*Potter -- * = any completion
ranges := words:50k-120k chapters:>5 kudos:>100 updated:<30d published:2024..
flags := status:complete|ongoing|hiatus|abandoned | crossover:no | oneshot:yes
Examples:
char:"Harry Potter"!main -ship:*Harry words:50k- status:complete
attr:BAMF!main fandom:"Harry Potter" -crossover
ship:Drarry!main attr:"Slow Burn" kudos:>500 updated:<90d
body:"solemnly swear" title:-"oneshot"
4. The AO3-complaints catalog
| AO3 frustration |
Solution |
| Can't filter "X as main character" |
Role tiers + !main |
| Cameos pollute character filters |
mentioned/cameo tiers; default excludes them |
| Can't exclude all ships involving a character |
-ship:*X over structured ships |
/ vs & ship ambiguity |
Explicit bond_type |
| Can't tell main pairing from background ship |
is_primary + !main_ship |
| "Slow burn" — but between whom? |
Ship-bound attributes |
| Attribute applies to the wrong character |
Character-bound attributes + scope operators |
| Synonym hell / unwrangled tags |
Entity registry + aliases, auto-expanded at query time |
| No date filtering at all |
published:/updated: ranges + updated_within |
| No chapter-count filter |
chapters: range |
| "Complete" is a lie (abandoned fics marked complete, endless WIPs) |
Status enum + inferred abandonment (no update >12 months → likely_abandoned, excludable) |
| Crossovers pollute fandom filters |
crossover:yes/no; specific crossover = fandom AND fandom |
| Full-text search is weak/slow |
Body full-text search with ranked snippets (shipped capability — keep it) |
| Typos return zero |
Trigram fuzzy fallback (shipped capability) |
| No alerts for "new fic matching my search" |
Saved searches + subscription (see §8) |
| Can't search "A and B both appear, not shipped" |
char:A char:B -ship:A/B |
| No POV filter |
!pov |
| Can't exclude an author / hide fics I've read |
Personal filters: hide_read, hide_bookmarked, muted-author exclusion |
| Dead-end zero results |
Restrictiveness-ranked relaxation + alias/did-you-mean + one-click "turn this search into a request" |
5. Wrangling pipeline
- Ingest (scrape or post form): parse tags → resolve against entities/aliases (exact → normalized → trigram candidates). Unresolved tags land in a wrangle queue, never silently dropped.
- Ship parsing: split on
/ and &, resolve members, set bond from separator; known ship-name aliases resolve directly.
- Curation: alias suggestions, merge/split proposals with voting (the curator proposal flow already exists — reuse it for entities), conflicts