concept overlay: synonym expansion + rivalry exclusion
A small knowledge-graph layer that does two distinct jobs:
1. SYNONYM_GROUPS broaden retrieval. A query mentioning "Athlon"
now also matches AMD-titled docs because Athlon IS an AMD product.
Groups currently cover AMD-family, Intel-family, HTTP family,
FTP, Mac, Windows, Linux. Easy to extend.
2. RIVALRIES narrow retrieval. The pair (AMD-group, Intel-group)
means: if the query mentions one side and not the other, drop
docs whose titles contain the OTHER side's tokens. So a "fastest
AMD CPU" question never gets Pentium_4 in the context — even if
FTS5 BM25 ranks it high — because Pentium is in the Intel group
and Intel isn't in the query.
COMPARE_WORDS ("vs", "versus", "compare", "between", ...) suppress
the exclusion. "compare AMD vs Intel" keeps both sides. "what is
the fastest AMD CPU?" does not.
Demos against the 128k Wikipedia 2003-05-16 cur shards:
Q "fastest AMD CPU" → 8 sources, all AMD/CPU titled, NO Intel
Answer: "Athlon XP 3200+" (real AMD chip,
grounded in the AMD article)
Q "compare AMD vs Intel"
→ 8 sources, mix of Intel_8028x, Intel_8048x,
AMD_Duron — both sides preserved
Q "Athlon processor" → AMD, AMD_Duron, AMD_5x86, Athlon all
surfaced via synonym expansion
Phase 1 implementation hand-curates the groups; Phase 2 idea is to
derive them from Wikipedia's link/category graph (dense bidirectional
clusters → synonym groups; same-category-without-cross-links →
rivalry candidates).
66 tests passing.
This commit is contained in:
parent
3b010279fe
commit
c6182ae54e
3 changed files with 209 additions and 19 deletions
109
aborist/qa/concepts.py
Normal file
109
aborist/qa/concepts.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"""Concept overlay: synonyms (broaden retrieval) + rivalries (narrow it).
|
||||
|
||||
A small knowledge-graph layer over the corpus. Two structures:
|
||||
|
||||
- SYNONYM_GROUPS — sets of tokens that retrieve interchangeably. Querying
|
||||
for `athlon` should also pull `AMD`-titled docs because Athlon IS an
|
||||
AMD product. Groups are unordered and case-insensitive at compare time.
|
||||
|
||||
- RIVALRIES — pairs of group-indices that compete. If the query mentions
|
||||
one side and not the other, docs whose titles contain the OTHER side's
|
||||
tokens get filtered out (no Intel pages poisoning AMD answers). If the
|
||||
query mentions BOTH sides — "AMD vs Intel", "compare AMD and Intel" —
|
||||
no filtering: the user wants both sides.
|
||||
|
||||
Phase 1: hand-curated. Phase 2 idea: derive from Wikipedia's category
|
||||
graph or from "See also" sections (articles that link bidirectionally
|
||||
in dense clusters → synonym group; articles in the same category that
|
||||
DON'T cross-link → potential rivalries).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
# Each group is a frozenset of lowercased tokens. Title-token overlap with
|
||||
# any element promotes the doc as topically relevant.
|
||||
SYNONYM_GROUPS: list[frozenset[str]] = [
|
||||
# AMD CPU family
|
||||
frozenset({
|
||||
"amd", "athlon", "duron", "opteron", "ryzen", "epyc",
|
||||
"thunderbird", "palomino", "thoroughbred", "barton",
|
||||
"k6", "k7", "k8", "k10",
|
||||
"5x86", # AMD 5x86
|
||||
}),
|
||||
# Intel CPU family
|
||||
frozenset({
|
||||
"intel", "pentium", "celeron", "xeon", "itanium",
|
||||
"i7", "i5", "i3", "i9",
|
||||
"core2", "coreduo", "skylake", "haswell", "ivy",
|
||||
"8086", "80286", "80386", "80486",
|
||||
}),
|
||||
# HTTP / web protocol family
|
||||
frozenset({"http", "https", "hypertext", "rfc2068", "rfc2616"}),
|
||||
# FTP / file-transfer protocol family (rivalry candidate vs HTTP for some queries)
|
||||
frozenset({"ftp", "sftp", "ftps"}),
|
||||
# Mac vs Windows family
|
||||
frozenset({"macintosh", "mac", "macos", "osx", "apple"}),
|
||||
frozenset({"windows", "microsoft", "win32", "winnt", "win9x"}),
|
||||
# Linux family
|
||||
frozenset({"linux", "gnu", "ubuntu", "debian", "fedora", "redhat", "kernel"}),
|
||||
]
|
||||
|
||||
|
||||
# Pairs of SYNONYM_GROUPS indices that compete. Bidirectional.
|
||||
RIVALRIES: list[tuple[int, int]] = [
|
||||
(0, 1), # AMD ↔ Intel
|
||||
(4, 5), # Mac ↔ Windows
|
||||
]
|
||||
|
||||
|
||||
# Tokens that, if present in the query, mean "the user wants both sides
|
||||
# of any rivalry shown" — comparative phrasing. When ANY of these appears,
|
||||
# rivalry exclusion is suppressed.
|
||||
COMPARE_WORDS: frozenset[str] = frozenset({
|
||||
"vs", "versus", "compare", "compared", "comparison", "compares",
|
||||
"between", "difference", "differences", "or", "either",
|
||||
})
|
||||
|
||||
|
||||
def synonym_expand(tokens: set[str]) -> set[str]:
|
||||
"""Add all synonym-group members for any token that hits a group."""
|
||||
expanded = set(tokens)
|
||||
for t in tokens:
|
||||
for group in SYNONYM_GROUPS:
|
||||
if t in group:
|
||||
expanded |= group
|
||||
break
|
||||
return expanded
|
||||
|
||||
|
||||
def rivalry_excluded(tokens: set[str], compare_phrasing: bool = False) -> set[str]:
|
||||
"""Return tokens whose presence in a doc title means EXCLUDE that doc.
|
||||
|
||||
Logic: for each rivalry pair (A, B), if exactly ONE side is present
|
||||
in the query AND no compare-phrasing was detected, exclude the OTHER
|
||||
side's tokens. If both sides are present, or if the user used
|
||||
comparison language, no exclusion (they wanted both).
|
||||
"""
|
||||
if compare_phrasing:
|
||||
return set()
|
||||
excluded: set[str] = set()
|
||||
for a_idx, b_idx in RIVALRIES:
|
||||
a = SYNONYM_GROUPS[a_idx]
|
||||
b = SYNONYM_GROUPS[b_idx]
|
||||
a_in = bool(tokens & a)
|
||||
b_in = bool(tokens & b)
|
||||
if a_in and not b_in:
|
||||
excluded |= b
|
||||
elif b_in and not a_in:
|
||||
excluded |= a
|
||||
return excluded
|
||||
|
||||
|
||||
def has_compare_phrasing(question: str) -> bool:
|
||||
"""True if the question contains comparison language."""
|
||||
lower = question.lower()
|
||||
# Word-boundary check via simple split on non-alpha
|
||||
import re
|
||||
words = set(re.findall(r"[a-z]+", lower))
|
||||
return bool(words & COMPARE_WORDS)
|
||||
|
|
@ -40,6 +40,11 @@ from aborist import (
|
|||
)
|
||||
from aborist.merkle import MerkleTree
|
||||
from aborist.qa.client import ChatClient
|
||||
from aborist.qa.concepts import (
|
||||
has_compare_phrasing,
|
||||
rivalry_excluded,
|
||||
synonym_expand,
|
||||
)
|
||||
from aborist.qa.keys import (
|
||||
cache_key,
|
||||
conversation_hash,
|
||||
|
|
@ -96,25 +101,29 @@ def _rerank_by_title(
|
|||
|
||||
|
||||
def _filter_by_title_relevance(hits: list, question: str) -> list:
|
||||
"""Hard-filter: when ANY hit's title overlaps the query, drop the rest.
|
||||
"""Concept-aware title filter:
|
||||
- synonym expansion: query 'Athlon' also matches AMD-titled docs
|
||||
- rivalry exclusion: query 'AMD' alone excludes Intel-titled docs;
|
||||
'AMD vs Intel' suppresses the exclusion (compare phrasing)
|
||||
- hard filter: only keep title-matchers when matchers exist
|
||||
|
||||
Reranking by score wasn't strong enough — Pentium_4 with score 7 still
|
||||
ended up in the top-K context window for "fastest AMD CPU" and Hermes
|
||||
conflated it as the AMD answer. Fix: if we have ANY title-matchers, use
|
||||
only those. Off-topic articles never enter the context.
|
||||
|
||||
If zero hits match by title (corpus has nothing topical), keep the top
|
||||
one so the LLM sees a single source and can honestly say "I don't know
|
||||
based on these sources." Empty context produces fabrication.
|
||||
No matchers (corpus has nothing topical) → keep the top one so the
|
||||
LLM sees a source and can honestly say "I don't know."
|
||||
"""
|
||||
qtokens = _title_query_tokens(question)
|
||||
if not qtokens:
|
||||
return hits
|
||||
kept = [
|
||||
h for h in hits
|
||||
if h.title
|
||||
and (qtokens & _title_query_tokens(h.title.replace("_", " ")))
|
||||
]
|
||||
accept = synonym_expand(qtokens)
|
||||
exclude = rivalry_excluded(qtokens, compare_phrasing=has_compare_phrasing(question))
|
||||
kept = []
|
||||
for h in hits:
|
||||
if not h.title:
|
||||
continue
|
||||
ttokens = _title_query_tokens(h.title.replace("_", " "))
|
||||
if exclude & ttokens:
|
||||
continue # rivalry: opposing-side title, drop it
|
||||
if accept & ttokens:
|
||||
kept.append(h)
|
||||
if not kept:
|
||||
return hits[:1] if hits else []
|
||||
return kept
|
||||
|
|
@ -180,6 +189,8 @@ def _search_corpus(
|
|||
out-ranks FTS5 body hits so the actual topic article rises to the top.
|
||||
"""
|
||||
qtokens = _title_query_tokens(question)
|
||||
# Synonym expansion: a query for "athlon" also fetches AMD-titled docs.
|
||||
accept_tokens = synonym_expand(qtokens)
|
||||
paths: list[Path]
|
||||
if shards_dir is not None:
|
||||
paths = discover_shards(shards_dir)
|
||||
|
|
@ -204,12 +215,10 @@ def _search_corpus(
|
|||
str(p.resolve()),
|
||||
)
|
||||
)
|
||||
# Parallel title search. Score is overlap-weighted and
|
||||
# baseline-elevated so an exact title match (HTTP, Stoicism)
|
||||
# comes above body BM25.
|
||||
for r in _search_titles(conn, qtokens, over_fetch):
|
||||
# Parallel title search using synonym-expanded tokens.
|
||||
for r in _search_titles(conn, list(accept_tokens), over_fetch):
|
||||
title_lower = (r["title"] or "").lower().replace("_", " ")
|
||||
overlap = sum(1 for t in qtokens if t.lower() in title_lower)
|
||||
overlap = sum(1 for t in accept_tokens if t in title_lower)
|
||||
if overlap == 0:
|
||||
continue
|
||||
title_score = 50.0 + overlap * 10.0
|
||||
|
|
|
|||
72
tests/test_concepts.py
Normal file
72
tests/test_concepts.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"""Concept overlay: synonym expansion + rivalry exclusion."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aborist.qa.concepts import (
|
||||
has_compare_phrasing,
|
||||
rivalry_excluded,
|
||||
synonym_expand,
|
||||
)
|
||||
|
||||
|
||||
def test_synonym_expand_amd_pulls_athlon_and_back():
|
||||
expanded = synonym_expand({"athlon"})
|
||||
assert "amd" in expanded
|
||||
assert "duron" in expanded
|
||||
assert "thunderbird" in expanded
|
||||
|
||||
expanded = synonym_expand({"amd"})
|
||||
assert "athlon" in expanded
|
||||
|
||||
|
||||
def test_synonym_expand_unrelated_token_unchanged():
|
||||
expanded = synonym_expand({"banana"})
|
||||
assert expanded == {"banana"}
|
||||
|
||||
|
||||
def test_synonym_expand_doesnt_cross_groups():
|
||||
"""AMD and Intel are in different groups — expanding one shouldn't
|
||||
pull in the other."""
|
||||
expanded = synonym_expand({"amd"})
|
||||
assert "intel" not in expanded
|
||||
assert "pentium" not in expanded
|
||||
|
||||
|
||||
def test_rivalry_excluded_amd_query_drops_intel():
|
||||
excluded = rivalry_excluded({"amd"}, compare_phrasing=False)
|
||||
assert "intel" in excluded
|
||||
assert "pentium" in excluded
|
||||
|
||||
|
||||
def test_rivalry_excluded_intel_query_drops_amd():
|
||||
excluded = rivalry_excluded({"intel"}, compare_phrasing=False)
|
||||
assert "amd" in excluded
|
||||
assert "athlon" in excluded
|
||||
|
||||
|
||||
def test_rivalry_excluded_both_sides_no_exclusion():
|
||||
excluded = rivalry_excluded({"amd", "intel"}, compare_phrasing=False)
|
||||
assert excluded == set()
|
||||
|
||||
|
||||
def test_rivalry_excluded_compare_phrasing_suppresses():
|
||||
excluded = rivalry_excluded({"amd"}, compare_phrasing=True)
|
||||
assert excluded == set()
|
||||
|
||||
|
||||
def test_compare_phrasing_detection():
|
||||
assert has_compare_phrasing("compare AMD and Intel")
|
||||
assert has_compare_phrasing("AMD vs Intel")
|
||||
assert has_compare_phrasing("difference between Mac and Windows")
|
||||
assert not has_compare_phrasing("what is the fastest AMD CPU?")
|
||||
assert not has_compare_phrasing("tell me about Athlon")
|
||||
|
||||
|
||||
def test_mac_windows_rivalry():
|
||||
"""Independent rivalry pair — Mac vs Windows."""
|
||||
excluded = rivalry_excluded({"macintosh"}, compare_phrasing=False)
|
||||
assert "windows" in excluded
|
||||
|
||||
excluded = rivalry_excluded({"windows"}, compare_phrasing=False)
|
||||
assert "macintosh" in excluded
|
||||
assert "macos" in excluded
|
||||
Loading…
Add table
Add a link
Reference in a new issue