feat: filter and cap GraphRAG reranker input across full stack (#1021)

- Filter out RDF/RDFS/OWL schema predicates (rdfs:domain, owl:inverseOf,
  etc.) from hop traversal, keeping rdf:type for data signal
- Skip edges where reranker-visible components are unlabeled IRIs, since
  the cross-encoder cannot meaningfully score raw URIs
- Add max-reranker-input safety cap (default 350) to prevent overloading
  the reranker, applied after filtering for maximum useful candidates
- Expose max-reranker-input as per-request parameter through schema,
  translator, REST API, socket client, CLI, and OpenAPI spec
- Update tests
- Update tech spec
This commit is contained in:
cybermaggedon 2026-07-03 15:51:04 +01:00 committed by GitHub
parent 76c4763b9b
commit 68e816e65c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 198 additions and 43 deletions

View file

@ -34,6 +34,22 @@ logger = logging.getLogger(__name__)
LABEL="http://www.w3.org/2000/01/rdf-schema#label"
RDF_NS = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
RDFS_NS = "http://www.w3.org/2000/01/rdf-schema#"
OWL_NS = "http://www.w3.org/2002/07/owl#"
RDF_TYPE = RDF_NS + "type"
SCHEMA_NAMESPACES = (RDF_NS, RDFS_NS, OWL_NS)
def is_schema_predicate(predicate):
"""Return True if the predicate is an RDF/RDFS/OWL schema predicate.
rdf:type is excluded from filtering as it carries useful data signal.
"""
if predicate == RDF_TYPE:
return False
return predicate.startswith(SCHEMA_NAMESPACES)
def term_to_string(term):
"""Extract string value from a Term object."""
@ -120,7 +136,8 @@ class Query:
def __init__(
self, rag, collection, verbose,
entity_limit=50, triple_limit=30, max_subgraph_size=1000,
max_path_length=2, edge_limit=25, track_usage=None,
max_path_length=2, edge_limit=25, max_reranker_input=350,
track_usage=None,
):
self.rag = rag
self.collection = collection
@ -130,6 +147,7 @@ class Query:
self.max_subgraph_size = max_subgraph_size
self.max_path_length = max_path_length
self.edge_limit = edge_limit
self.max_reranker_input = max_reranker_input
self.track_usage = track_usage
async def extract_concepts(self, query):
@ -346,7 +364,7 @@ class Query:
hop_directions = {}
for triple, direction in triples:
triple_tuple = (str(triple.s), str(triple.p), str(triple.o))
if triple_tuple[1] == LABEL:
if is_schema_predicate(triple_tuple[1]):
continue
if triple_tuple in seen_edges:
continue
@ -385,25 +403,50 @@ class Query:
# The reranker text highlights the NEW information relative
# to the traversal direction: arriving from S means p,o are
# new; from O means s,p are new; from P means s,o are new.
# Edges where the reranker-visible components are unlabeled
# IRIs are skipped — the cross-encoder can't score them.
def is_iri(val):
return val.startswith(("http://", "https://", "urn:"))
filtered_triples = []
labeled_hop = []
documents = []
for s, p, o in hop_triples:
ls = label_map.get(s, s)
lp = label_map.get(p, p)
lo = label_map.get(o, o)
labeled_hop.append((ls, lp, lo))
documents = []
for i, (triple_tuple, (ls, lp, lo)) in enumerate(
zip(hop_triples, labeled_hop)
):
direction = hop_directions[triple_tuple]
direction = hop_directions[(s, p, o)]
if direction == self.FROM_S:
if is_iri(lp) or is_iri(lo):
continue
text = f"{lp} {lo}"
elif direction == self.FROM_O:
if is_iri(ls) or is_iri(lp):
continue
text = f"{ls} {lp}"
else:
if is_iri(ls) or is_iri(lo):
continue
text = f"{ls} {lo}"
documents.append({"id": str(i), "text": text})
idx = len(filtered_triples)
filtered_triples.append((s, p, o))
labeled_hop.append((ls, lp, lo))
documents.append({"id": str(idx), "text": text})
hop_triples = filtered_triples
# Cap the number of candidates sent to the reranker
if len(hop_triples) > self.max_reranker_input:
if self.verbose:
logger.debug(
f"Hop {hop + 1}: truncating {len(hop_triples)} "
f"candidates to {self.max_reranker_input}"
)
hop_triples = hop_triples[:self.max_reranker_input]
labeled_hop = labeled_hop[:self.max_reranker_input]
documents = documents[:self.max_reranker_input]
queries = [
{"id": str(i), "text": c}
@ -588,7 +631,7 @@ class GraphRag:
async def query(
self, query, collection = "default",
entity_limit = 50, triple_limit = 30, max_subgraph_size = 1000,
max_path_length = 2, edge_limit = 25,
max_path_length = 2, edge_limit = 25, max_reranker_input = 350,
streaming = False,
chunk_callback = None,
explain_callback = None, save_answer_callback = None,
@ -642,6 +685,7 @@ class GraphRag:
max_subgraph_size = max_subgraph_size,
max_path_length = max_path_length,
edge_limit = edge_limit,
max_reranker_input = max_reranker_input,
track_usage = track_usage,
)

View file

@ -34,6 +34,7 @@ class Processor(FlowProcessor):
max_subgraph_size = params.get("max_subgraph_size", 150)
max_path_length = params.get("max_path_length", 2)
edge_limit = params.get("edge_limit", 25)
max_reranker_input = params.get("max_reranker_input", 350)
super(Processor, self).__init__(
**params | {
@ -44,6 +45,7 @@ class Processor(FlowProcessor):
"max_subgraph_size": max_subgraph_size,
"max_path_length": max_path_length,
"edge_limit": edge_limit,
"max_reranker_input": max_reranker_input,
}
)
@ -52,6 +54,7 @@ class Processor(FlowProcessor):
self.default_max_subgraph_size = max_subgraph_size
self.default_max_path_length = max_path_length
self.default_edge_limit = edge_limit
self.default_max_reranker_input = max_reranker_input
# Workspace isolation is enforced by the flow layer (flow.workspace).
# Per-request caching (see GraphRag) keeps within-request state
@ -197,6 +200,11 @@ class Processor(FlowProcessor):
else:
edge_limit = self.default_edge_limit
if v.max_reranker_input:
max_reranker_input = v.max_reranker_input
else:
max_reranker_input = self.default_max_reranker_input
async def save_answer(doc_id, answer_text):
await flow.librarian.save_document(
doc_id=doc_id,
@ -226,8 +234,8 @@ class Processor(FlowProcessor):
entity_limit = entity_limit, triple_limit = triple_limit,
max_subgraph_size = max_subgraph_size,
max_path_length = max_path_length,
edge_limit = edge_limit,
max_reranker_input = max_reranker_input,
streaming = True,
chunk_callback = send_chunk,
explain_callback = send_explainability,
@ -242,8 +250,8 @@ class Processor(FlowProcessor):
entity_limit = entity_limit, triple_limit = triple_limit,
max_subgraph_size = max_subgraph_size,
max_path_length = max_path_length,
edge_limit = edge_limit,
max_reranker_input = max_reranker_input,
explain_callback = send_explainability,
save_answer_callback = save_answer,
parent_uri = v.parent_uri,
@ -346,6 +354,13 @@ class Processor(FlowProcessor):
help=f'Max edges selected per hop by cross-encoder (default: 25)'
)
parser.add_argument(
'--max-reranker-input',
type=int,
default=350,
help=f'Max candidate edges sent to the reranker per hop (default: 350)'
)
# Note: Explainability triples are now stored in the request's collection
# with the named graph urn:graph:retrieval (no separate collection needed)