Skip to content

Commit 87c2d7f

Browse files
Merge pull request #318 from aghassel/feat/topic-beam-search
feat(retrieval): add TopicBeamSearch retriever (TopicGraphRAG)
2 parents 5515492 + 24a8998 commit 87c2d7f

10 files changed

Lines changed: 909 additions & 4 deletions

File tree

docs-site/src/content/docs/lexical-graph/traversal-based-search.mdx

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,35 @@ An entity network context consists of a filtered and ranked network of entities
8989

9090
### Retrievers
9191

92-
Traversal-based search provides three different retrievers:
92+
Traversal-based search provides four different retrievers:
9393

9494
- The `ChunkBasedSearch` retriever uses a vector similarity search to find information that is similar to the original query. The retriever first finds relevant chunks using vector similarity search. From these chunks, the retriever traverses topics, statements, and facts. Chunk-based search tends to return a narrowly-scoped set of results based on the statement and fact neighbourhoods of chunks that match the original query.
9595
- The `EntityBasedSearch` retriever uses as its starting points the entities in an entity network context. From these entities, the retriever traverses facts, statements and topics. Entity-based search tends to return a broadly-scoped set of results, based on the neighbourhoods of individual entities and the facts that connect entities.
9696
- The `EntityNetworkSearch` retriever uses textual transcriptions of an entity network context to drive vector searches for information that is dissimilar to the original query but nonetheless structurally relevant for creating an accurate and full response. These vector searches return chunks that are similar to 'something different from the question being asked'. From these chunks, the retriever traverses topics, statements, and facts to explore the structurally relevant space of dissimilar content.
97-
98-
By default, the traversal-based search is configured to use a combination of `ChunkBasedSearch` and `EntityNetworkSearch`. Together, these two retrievers provide access to content that is similar to the question being asked, plus content that is similar to 'something different from the question being asked'.
97+
- The `TopicBeamSearch` retriever starts from the most relevant topics (found by a vector similarity search over the topic index) and then performs a beam search across the topic graph to gather additional related topics, before expanding the winning topics into their statements. Because it is topic-first, it tends to assemble a focused, topic-coherent set of statements and works well on multi-hop, legal, and temporal questions.
98+
99+
By default, the traversal-based search is configured to use a combination of `ChunkBasedSearch` and `EntityNetworkSearch`. Together, these two retrievers provide access to content that is similar to the question being asked, plus content that is similar to 'something different from the question being asked'. `TopicBeamSearch` is used as a standalone, topic-first alternative.
100+
101+
#### TopicBeamSearch options
102+
103+
`TopicBeamSearch` accepts a notable option:
104+
105+
- `topic_reranker` (default `'none'`): optionally reranks the gathered topics by relevance to the query before their statements are collected, keeping the top `max_topics`. It mirrors the statement `reranker` option and accepts `'tfidf'` or `'bedrock'` (Cohere). Topic reranking is an opt-in, per-corpus tuning lever: it can help when answers are concentrated in a few topics, but is not enabled by default.
106+
107+
The beam's neighbour-edge strategy and other tuning are configured through `ProcessorArgs`
108+
(mirroring the `chunk_beam_*` parameters), so they can be passed to
109+
`for_traversal_based_search(...)`:
110+
111+
- `use_same_chunk_neighbors` (default `True`) and `use_adjacent_chunk_neighbors`
112+
(default `True`): expand the beam to topics co-occurring in the same chunk and in the
113+
adjacent (next) chunk. Adjacent-chunk expansion is on by default because it improved
114+
accuracy across datasets with no regressions in our benchmarks.
115+
- `use_entity_neighbors` (default `False`): expand to topics linked through shared
116+
entities. This has a large fan-out and tends to add noise, so it is off by default;
117+
when enabled, `max_entity_neighbors` (default `100`) caps the neighbours per topic,
118+
ranked by entity-connection strength.
119+
- `topic_beam_width` (default `100`), `topic_beam_max_depth` (default `6`),
120+
`topic_top_k` (default `50`): beam breadth, walk depth, and number of seed topics.
99121

100122
### Search results
101123

lexical-graph/src/graphrag_toolkit/lexical_graph/retrieval/post_processors/bedrock_context_format.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,16 @@ def _postprocess_nodes(
8888
if not nodes:
8989
return [NodeWithScore(node=TextNode(text='No relevant context'))]
9090

91-
# Group nodes by source
91+
# Traversal-based retrievers (e.g. TopicBeamSearch, TopicBasedSearch) return one
92+
# node per SearchResult, carrying the full source->topics->statements tree in
93+
# metadata['result']. Format those as nested source/topic/statement XML so the
94+
# topic grouping is preserved, with statements kept in document order within
95+
# each topic.
96+
result_nodes = [n for n in nodes if n.node.metadata.get('result')]
97+
if result_nodes:
98+
return self._format_search_result_nodes(result_nodes)
99+
100+
# Legacy per-statement nodes: group by source, emit per-statement XML.
92101
sources: Dict[str, List[NodeWithScore]] = {}
93102
for node in nodes:
94103
source_id = node.node.metadata['source']['sourceId']
@@ -124,3 +133,51 @@ def _postprocess_nodes(
124133
formatted_sources.append("\n".join(source_output))
125134

126135
return [NodeWithScore(node=TextNode(text=formatted_source)) for formatted_source in formatted_sources]
136+
137+
@staticmethod
138+
def _doc_order_key(statement: dict):
139+
"""Document-order proxy for a statement: chunk sequence, then statement id."""
140+
return (str(statement.get('chunkId') or ''), str(statement.get('statementId') or ''))
141+
142+
@staticmethod
143+
def _format_statement_text(statement: dict) -> str:
144+
text = statement.get('statement', '') or ''
145+
details = statement.get('details')
146+
if details:
147+
details = details.strip().replace('\n', ', ')
148+
return f"{text} (details: {details})"
149+
return text
150+
151+
def _format_search_result_nodes(self, nodes: List[NodeWithScore]) -> List[NodeWithScore]:
152+
"""Format traversal SearchResult nodes as nested source/topic/statement XML.
153+
154+
One node = one source. Topic grouping is preserved; statements within a topic
155+
are ordered by document position (chunkId, statementId) so the generator reads
156+
them in their original narrative order.
157+
"""
158+
formatted_sources = []
159+
for source_count, node in enumerate(nodes, 1):
160+
result = node.node.metadata.get('result') or {}
161+
source = result.get('source', {}) or {}
162+
out = [f"<source_{source_count}>"]
163+
164+
metadata = source.get('metadata', {}) or {}
165+
if metadata:
166+
out.append(f"<source_{source_count}_metadata>")
167+
for key, value in sorted(metadata.items()):
168+
out.append(f"\t<{key}>{value}</{key}>")
169+
out.append(f"</source_{source_count}_metadata>")
170+
171+
for topic_count, topic in enumerate(result.get('topics', []), 1):
172+
topic_name = (topic.get('topic') or '').replace('"', "'")
173+
out.append(f'<topic_{source_count}.{topic_count} name="{topic_name}">')
174+
statements = sorted(topic.get('statements', []), key=self._doc_order_key)
175+
for st_count, statement in enumerate(statements, 1):
176+
tag = f"statement_{source_count}.{topic_count}.{st_count}"
177+
out.append(f"<{tag}>{self._format_statement_text(statement)}</{tag}>")
178+
out.append(f"</topic_{source_count}.{topic_count}>")
179+
180+
out.append(f"</source_{source_count}>")
181+
formatted_sources.append("\n".join(out))
182+
183+
return [NodeWithScore(node=TextNode(text=fs)) for fs in formatted_sources]

lexical-graph/src/graphrag_toolkit/lexical_graph/retrieval/processors/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from .prune_statements import PruneStatements
1616
from .remove_versioning_metadata import RemoveVersioningMetadata
1717
from .rerank_statements import RerankStatements
18+
from .rerank_topics import RerankTopics
1819
from .rescore_results import RescoreResults
1920
from .simplify_single_topic_results import SimplifySingleTopicResults
2021
from .sort_results import SortResults

lexical-graph/src/graphrag_toolkit/lexical_graph/retrieval/processors/processor_args.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,28 @@ def __init__(self, **kwargs):
8888
self.chunk_cosine_top_k = kwargs.get('chunk_cosine_top_k', 50)
8989
self.chunk_beam_width = kwargs.get('chunk_beam_width', 10)
9090
self.chunk_beam_max_depth = kwargs.get('chunk_beam_max_depth', 3)
91+
# TopicBeamSearch tuning (mirrors the chunk_beam_* parameters above).
92+
self.topic_beam_width = kwargs.get('topic_beam_width', 100)
93+
self.topic_beam_max_depth = kwargs.get('topic_beam_max_depth', 6)
94+
self.topic_top_k = kwargs.get('topic_top_k', 50)
95+
# Beam neighbour-edge strategies. adjacent-chunk co-occurrence is enabled by
96+
# default: benchmarks showed it improves accuracy across datasets with no
97+
# regressions, whereas entity-overlap (off by default) tends to add noise.
98+
self.use_entity_neighbors = kwargs.get('use_entity_neighbors', False)
99+
self.use_same_chunk_neighbors = kwargs.get('use_same_chunk_neighbors', True)
100+
self.use_adjacent_chunk_neighbors = kwargs.get('use_adjacent_chunk_neighbors', True)
101+
# Cap on entity-overlap neighbours per topic (ranked by connection strength) to
102+
# bound the fan-out when use_entity_neighbors is enabled.
103+
self.max_entity_neighbors = kwargs.get('max_entity_neighbors', 100)
104+
# Defensive cap on chunk-based neighbour strategies (same-chunk + adjacent-chunk),
105+
# mirroring max_entity_neighbors. Bounds the neighbour set per topic in case a chunk
106+
# is referenced by an unusually large number of topics.
107+
self.max_chunk_neighbors = kwargs.get('max_chunk_neighbors', 100)
108+
# Topic-level reranking (mirrors `reranker`): 'none' | 'tfidf' | 'bedrock'.
109+
# When set, RerankTopics scores each topic (name + statements) against the query
110+
# and keeps the top `max_topics` before statement selection.
111+
self.topic_reranker = kwargs.get('topic_reranker', 'none')
112+
self.max_topics = kwargs.get('max_topics', 40)
91113
self.no_cache = kwargs.get('no_cache', None)
92114

93115

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""
5+
RerankTopics processor.
6+
7+
Follows the same convention as RerankStatements: the reranking strategy is selected by a
8+
string arg (``ProcessorArgs.topic_reranker`` -> 'tfidf' | 'bedrock' | 'none') and
9+
dispatched here, rather than via ad-hoc flags. Where RerankStatements reranks statements
10+
*within* a topic, this processor reranks whole topics by scoring each topic's
11+
``name + all its statements`` against the query, keeps the top ``max_topics``, and drops the
12+
rest before the token-budget truncation runs.
13+
14+
It also propagates each surviving topic's relevance score down to any statement that does not
15+
already carry a score. This lets the statement reranker be turned off (``reranker='none'``)
16+
so that statement selection is driven purely by topic relevance -- the ablation that answers
17+
"if a topic ranks high by the reranker, is statement-level tfidf still needed?".
18+
"""
19+
import logging
20+
import time
21+
from typing import List, Dict
22+
23+
import boto3
24+
25+
from graphrag_toolkit.lexical_graph import GraphRAGConfig
26+
from graphrag_toolkit.lexical_graph.retrieval.processors.processor_base import ProcessorBase
27+
from graphrag_toolkit.lexical_graph.retrieval.processors.processor_args import ProcessorArgs
28+
from graphrag_toolkit.lexical_graph.retrieval.model import Topic
29+
from graphrag_toolkit.lexical_graph.utils.reranker_utils import score_values_with_tfidf
30+
from graphrag_toolkit.lexical_graph.metadata import FilterConfig
31+
32+
from llama_index.core.schema import QueryBundle
33+
34+
logger = logging.getLogger(__name__)
35+
36+
37+
class RerankTopics(ProcessorBase):
38+
"""Reranks and prunes whole topics by query relevance, using the strategy named in
39+
``ProcessorArgs.topic_reranker``. No-op when topic_reranker is unset/'none'."""
40+
41+
def __init__(self, args: ProcessorArgs, filter_config: FilterConfig):
42+
super().__init__(args, filter_config)
43+
44+
def _topic_text(self, topic: Topic) -> str:
45+
stmts = ' '.join([s.statement_str or '' for s in topic.statements])
46+
return (f'{topic.topic}\n{stmts}')[:4000] # cap per-doc length for the reranker
47+
48+
def _score_with_tfidf(self, texts: List[str], query: QueryBundle) -> List[float]:
49+
scored = score_values_with_tfidf(texts, [query.query_str], len(texts))
50+
return [scored.get(t, 0.0) for t in texts]
51+
52+
def _score_with_bedrock(self, texts: List[str], query: QueryBundle) -> List[float]:
53+
region = boto3.Session().region_name or getattr(GraphRAGConfig, 'aws_region', None) or 'us-east-1'
54+
client = boto3.client('bedrock-agent-runtime', region_name=region)
55+
model_arn = f'arn:aws:bedrock:{region}::foundation-model/{GraphRAGConfig.bedrock_reranking_model}'
56+
sources = [{'type': 'INLINE', 'inlineDocumentSource': {'type': 'TEXT', 'textDocument': {'text': t}}}
57+
for t in texts]
58+
resp = client.rerank(
59+
queries=[{'type': 'TEXT', 'textQuery': {'text': query.query_str}}],
60+
sources=sources,
61+
rerankingConfiguration={
62+
'type': 'BEDROCK_RERANKING_MODEL',
63+
'bedrockRerankingConfiguration': {
64+
'numberOfResults': len(texts),
65+
'modelConfiguration': {'modelArn': model_arn},
66+
},
67+
},
68+
)
69+
scores = [0.0] * len(texts)
70+
for r in resp['results']:
71+
scores[r['index']] = r['relevanceScore']
72+
return scores
73+
74+
def _process_results(self, search_results, query: QueryBundle):
75+
mode = (self.args.topic_reranker or 'none').lower()
76+
if mode == 'none':
77+
return search_results
78+
79+
# Flatten (result_index, topic) pairs and build one document per topic.
80+
pairs = []
81+
texts = []
82+
for ri, sr in enumerate(search_results.results):
83+
for topic in sr.topics:
84+
pairs.append((ri, topic))
85+
texts.append(self._topic_text(topic))
86+
if not texts:
87+
return search_results
88+
89+
start = time.time()
90+
if mode == 'tfidf':
91+
scores = self._score_with_tfidf(texts, query)
92+
elif mode == 'bedrock':
93+
scores = self._score_with_bedrock(texts, query)
94+
else:
95+
logger.warning(f'Unknown topic_reranker "{mode}", skipping topic rerank')
96+
return search_results
97+
logger.debug(f'Topic rerank ({mode}) of {len(texts)} topics: {(time.time()-start)*1000:.0f}ms')
98+
99+
# Rank topics globally; keep the top max_topics.
100+
order = sorted(range(len(pairs)), key=lambda i: scores[i], reverse=True)
101+
keep = set(order[: self.args.max_topics])
102+
103+
kept_topics_by_result: Dict[int, List[Topic]] = {}
104+
for i, (ri, topic) in enumerate(pairs):
105+
if i not in keep:
106+
continue
107+
# Propagate topic relevance to statements that have no score yet, so selection can
108+
# run on topic relevance alone when the statement reranker is disabled.
109+
for stmt in topic.statements:
110+
if stmt.score is None or stmt.score == 0.0:
111+
stmt.score = scores[i]
112+
kept_topics_by_result.setdefault(ri, []).append(topic)
113+
114+
# Rebuild results, dropping topics (and now-empty sources) that did not survive.
115+
new_results = []
116+
for ri, sr in enumerate(search_results.results):
117+
kept = kept_topics_by_result.get(ri)
118+
if not kept:
119+
continue
120+
sr.topics = kept
121+
new_results.append(sr)
122+
search_results = search_results.with_new_results(results=new_results)
123+
return search_results

lexical-graph/src/graphrag_toolkit/lexical_graph/retrieval/retrievers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
from .query_mode_retriever import QueryModeRetriever
1515
from .chunk_cosine_search import ChunkCosineSimilaritySearch
1616
from .semantic_chunk_beam_search import SemanticChunkBeamGraphSearch
17+
from .beam_search_base import BeamSearch
18+
from .topic_beam_search import TopicBeamSearch
1719

1820
# Mapping of deprecated class names to their module paths within the deprecated sub-package
1921
_DEPRECATED_NAMES = {

0 commit comments

Comments
 (0)