fix(rerank): ground entity-miss demotion on head token, not full phrase

The entity-grounding demotion required the full multi-word primary
entity as a contiguous substring, so on-entity items missing a trailing
search descriptor were buried: a 323-pt HN thread "Stripe is friendly
to 'friendly fraud'" scored 0 on a "Stripe payments" query. New
_entity_grounded helper keys on the brand head token; items that never
name the brand still miss it and stay demoted. reddit_keyless
_slot_priority, which had re-implemented the old check while claiming
to mirror rerank's signal, now calls the shared helper so the two
paths cannot diverge.
This commit is contained in:
Trevin Chow
2026-06-09 16:24:16 -07:00
parent fd0e47d99f
commit 6a92f63a56
4 changed files with 71 additions and 22 deletions
@@ -170,9 +170,11 @@ def _slot_priority(topic: str, posts: List[Dict[str, Any]]) -> List[Dict[str, An
posts that rerank later demotes as entity misses starves the on-topic
posts the user actually sees (2026-06-06 "OpenClaw vs Hermes" run:
2,000+ upvote Gemma/GPU threads took every slot, then were demoted to
zero). Mirror rerank's demotion signal the topic's stripped primary
entity contained in the post text — so slots go to posts likely to
survive final ranking. Falls back to token-overlap relevance when the
zero). Mirror rerank's demotion signal via the shared `_entity_grounded`
check (head token of the topic's stripped primary entity present in the
post text) so slots go to posts likely to survive final ranking — keying
on the same head token keeps the two paths from diverging. Falls back to
token-overlap relevance when the
topic yields no usable primary entity. Within each tier the incoming
(score-first) order is preserved. Never raises; on any failure the
incoming order is returned unchanged.
@@ -186,7 +188,7 @@ def _slot_priority(topic: str, posts: List[Dict[str, Any]]) -> List[Dict[str, An
entity = rerank._primary_entity(topic).lower()
if entity:
def _matches(post: Dict[str, Any]) -> bool:
return entity in _post_text(post).lower()
return rerank._entity_grounded(_post_text(post).lower(), entity)
else:
prepared = relevance.PreparedQuery(topic)
+29 -8
View File
@@ -247,6 +247,29 @@ def _candidate_haystack(candidate: schema.Candidate) -> str:
return " ".join(parts).lower()
def _entity_grounded(haystack: str, primary_entity: str) -> bool:
"""True if the candidate text plausibly mentions the primary entity.
Grounds on the HEAD token of the primary entity (the brand / proper-noun
core), not the full multi-word phrase. Trailing tokens are usually category
descriptors the user/planner appended for search ("Stripe payments"), not
part of the entity, so requiring the whole phrase over-demotes on-entity
items that omit the descriptor. Items that never name the brand at all still
miss the head token and stay demoted.
Trade-off: a proper noun with a generic head ("New York Times" -> "new")
under-demotes rather than over-demotes - the safe direction, since the
observed harm was burying real high-engagement signal. Substring (not
word-boundary) matching is likewise deliberate: it catches plurals and
compounds ("stripes"), and vacuous matches from very short heads ("X",
"Go") merely disable the penalty rather than burying good items.
"""
tokens = primary_entity.lower().split()
if not tokens:
return True
return tokens[0] in haystack
def _fallback_tuple(candidate: schema.Candidate, *, primary_entity: str = "") -> tuple[float, str]:
score = (
(candidate.local_relevance * 100.0 * 0.7)
@@ -254,17 +277,15 @@ def _fallback_tuple(candidate: schema.Candidate, *, primary_entity: str = "") ->
+ (candidate.source_quality * 100.0 * 0.1)
)
reason = "fallback-local-score"
# Entity-grounding demotion: if the primary entity (topic minus intent
# modifier) is not present anywhere in the candidate's text surfaces
# Entity-grounding demotion: subtract ENTITY_MISS_PENALTY when the candidate
# never mentions the primary entity's head token, across all text surfaces
# (title, snippet, transcript, transcript highlights, top comments,
# insights), subtract ENTITY_MISS_PENALTY. Skip for candidates with
# NO text anywhere (e.g., image-only TikToks) to avoid penalizing
# thin-text sources unfairly. 2026-04-19 Nate Herk "Managed Agents"
# video ranked #2 on a Hermes query despite zero Hermes mentions
# because the old haystack only checked title + snippet.
# insights). Skip for candidates with NO text anywhere (e.g. image-only
# TikToks) so thin-text sources aren't penalized unfairly. See
# _entity_grounded for why grounding keys on the head token, not the phrase.
if primary_entity:
haystack = _candidate_haystack(candidate)
if haystack.strip() and primary_entity.lower() not in haystack:
if haystack.strip() and not _entity_grounded(haystack, primary_entity):
score -= ENTITY_MISS_PENALTY
reason = "fallback-local-score (entity-miss demotion)"
return max(0.0, min(100.0, score)), reason