RetroFed Forum
Your home away from modern life.
Advanced Search Spec — Role-Aware, Structured-Entity Fanfic Archive Search
Post Reply
AuthorMessage
♠️ Critical Nexus ✊🏿 reddthat.com PumpkinDrama@reddthat.com
Joined: 2023-09-23
Quote

I'm building a fanfic archive and I've been thinking a lot about the limitations of AO3's tagging system lately. For me, the biggest issue is the lack of distinction between "main" and "side" characters/relationships.

If I want to read a fic where Character A is the protagonist, I have to filter for Character A—but then I get hundreds of fics where they're just a background cameo. Same goes for relationships.

I also wish I could filter out any relationship involving a character without filtering out the character themselves. Sometimes I'm too tired of seeing ships with A/{B, C, D...Z, X, Y}, but I don't want to exclude Character A altogether.

And sometimes I'd like to search for attributes like "BAMF" that apply to the main character, regardless of who that character is. If I search for "BAMF Character A," I can get fics where A is just a side character.

prompt: spec out the best possible advanced search as if you were building the site from scratch with most the capabilities of the one we implemented but solving all these issues, as well as common frustrations of other ao3 users

2   Original
♠️ Critical Nexus ✊🏿 Moderator PumpkinDrama@reddthat.com
Joined: 2023-09-23
#2 · Quote Reply

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
1
Post Reply
Back to top