Skip to content

Commit d885096

Browse files
feat: add multi answer qa generation
1 parent d849e23 commit d885096

11 files changed

Lines changed: 317 additions & 3 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Generate Multi-Answer QAs
2+
3+
Multi-answer question answering (QA) involves generating questions that can have multiple valid answers. This is particularly useful in educational settings, surveys, and research where diverse perspectives are valuable.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
python3 -m graphgen.run \
2+
--config_file examples/generate/generate_multi_answer_qa/multi_answer_config.yaml
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
global_params:
2+
working_dir: cache
3+
graph_backend: kuzu # graph database backend, support: kuzu, networkx
4+
kv_backend: rocksdb # key-value store backend, support: rocksdb, json_kv
5+
6+
nodes:
7+
- id: read_files # id is unique in the pipeline, and can be referenced by other steps
8+
op_name: read
9+
type: source
10+
dependencies: []
11+
params:
12+
input_path:
13+
- examples/input_examples/jsonl_demo.jsonl # input file path, support json, jsonl, txt, pdf. See examples/input_examples for examples
14+
15+
- id: chunk_documents
16+
op_name: chunk
17+
type: map_batch
18+
dependencies:
19+
- read_files
20+
execution_params:
21+
replicas: 4
22+
params:
23+
chunk_size: 1024 # chunk size for text splitting
24+
chunk_overlap: 100 # chunk overlap for text splitting
25+
26+
- id: build_kg
27+
op_name: build_kg
28+
type: map_batch
29+
dependencies:
30+
- chunk_documents
31+
execution_params:
32+
replicas: 1
33+
batch_size: 128
34+
35+
- id: quiz
36+
op_name: quiz
37+
type: map_batch
38+
dependencies:
39+
- build_kg
40+
execution_params:
41+
replicas: 1
42+
batch_size: 128
43+
params:
44+
quiz_samples: 2 # number of quiz samples to generate
45+
46+
- id: judge
47+
op_name: judge
48+
type: map_batch
49+
dependencies:
50+
- quiz
51+
execution_params:
52+
replicas: 1
53+
batch_size: 128
54+
55+
- id: partition
56+
op_name: partition
57+
type: aggregate
58+
dependencies:
59+
- judge
60+
params:
61+
method: ece # ece is a custom partition method based on comprehension loss
62+
method_params:
63+
max_units_per_community: 20 # max nodes and edges per community
64+
min_units_per_community: 5 # min nodes and edges per community
65+
max_tokens_per_community: 10240 # max tokens per community
66+
unit_sampling: max_loss # unit sampling strategy, support: random, max_loss, min_loss
67+
68+
- id: generate
69+
op_name: generate
70+
type: map_batch
71+
dependencies:
72+
- partition
73+
execution_params:
74+
replicas: 1
75+
batch_size: 128
76+
save_output: true # save output
77+
params:
78+
method: multi_answer
79+
num_of_questions: 5
80+
data_format: Alpaca # Alpaca, Sharegpt, ChatML

graphgen/models/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
AggregatedGenerator,
1212
AtomicGenerator,
1313
CoTGenerator,
14+
MultiAnswerGenerator,
1415
MultiChoiceGenerator,
1516
MultiHopGenerator,
1617
QuizGenerator,

graphgen/models/generator/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from .aggregated_generator import AggregatedGenerator
22
from .atomic_generator import AtomicGenerator
33
from .cot_generator import CoTGenerator
4+
from .multi_answer_generator import MultiAnswerGenerator
45
from .multi_choice_generator import MultiChoiceGenerator
56
from .multi_hop_generator import MultiHopGenerator
67
from .quiz_generator import QuizGenerator
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import re
2+
from typing import Any
3+
4+
from graphgen.bases import BaseGenerator
5+
from graphgen.templates import MAQ_GENERATION_PROMPT
6+
from graphgen.utils import compute_content_hash, detect_main_language, logger
7+
8+
9+
class MultiAnswerGenerator(BaseGenerator):
10+
def __init__(self, llm_client, num_of_questions) -> None:
11+
super().__init__(llm_client)
12+
self.num_of_questions = num_of_questions
13+
14+
@staticmethod
15+
def parse_response(response: str) -> Any:
16+
"""
17+
Parse multiple-answer QA pairs from the LLM response.
18+
Each QA pair contains question text, four options, and the correct answers (one or more).
19+
20+
:param response: The LLM response containing XML-formatted QA pairs
21+
:return: Dictionary mapping question hash to question data, where each
22+
value is a dict with "question", "options", "answer", and
23+
"correct_answer_text" keys
24+
"""
25+
qa_pairs = {}
26+
27+
# Extract all QA pair blocks
28+
qa_blocks = re.findall(r"<qa_pair>(.*?)</qa_pair>", response, re.DOTALL)
29+
30+
if not qa_blocks:
31+
logger.warning("No QA pairs found in response: %s", response)
32+
return {}
33+
34+
for block in qa_blocks:
35+
# Extract and clean question text
36+
q_match = re.search(r"<question>(.*?)</question>", block, re.DOTALL)
37+
if not q_match:
38+
logger.warning("Failed to parse question from block: %s", block)
39+
continue
40+
question = q_match.group(1).strip().strip('"').strip("'")
41+
42+
# Extract and parse options (A, B, C, D)
43+
opt_match = re.search(r"<options>(.*?)</options>", block, re.DOTALL)
44+
if not opt_match:
45+
logger.warning("Failed to parse options from block: %s", block)
46+
continue
47+
48+
options = {}
49+
options_text = opt_match.group(1).strip()
50+
for line in options_text.split("\n"):
51+
line = line.strip()
52+
if not line:
53+
continue
54+
# Match patterns like "A. text" or "B. text"
55+
if m := re.match(r"^([A-Z])[.\s]\s*(.*)$", line):
56+
letter, text = m.groups()
57+
options[letter] = text.strip()
58+
59+
# Extract and validate answer
60+
ans_match = re.search(r"<answer>(.*?)</answer>", block, re.DOTALL)
61+
if not ans_match:
62+
logger.warning("Failed to parse answer from block: %s", block)
63+
continue
64+
answer_text = ans_match.group(1).strip().strip('"').strip("'")
65+
answers = [ans.strip().upper() for ans in answer_text.split(",")]
66+
67+
answers = [ans for ans in answers if ans]
68+
invalid_answers = [ans for ans in answers if ans not in options]
69+
if invalid_answers:
70+
logger.warning(
71+
"Answers %s not found in options: %s",
72+
invalid_answers,
73+
list(options.keys()),
74+
)
75+
continue
76+
77+
# Ensure at least one valid answer
78+
if len(answers) == 0:
79+
logger.warning("No valid answers found in: %s", answer_text)
80+
continue
81+
82+
# Build result entry with question hash as key
83+
question_hash = compute_content_hash(question)
84+
qa_pairs[question_hash] = {
85+
"question": question,
86+
"options": options, # Dict like {"A": "text", "B": "text", ...}
87+
"answer": ", ".join(answers),
88+
}
89+
90+
logger.debug("Successfully parsed MCQ: %s", question[:50])
91+
92+
if not qa_pairs:
93+
logger.error("Failed to parse any valid MAQ pairs from response")
94+
95+
return qa_pairs
96+
97+
# pylint: disable=W0221
98+
def build_prompt(
99+
self, batch: tuple[list[tuple[str, dict]], list[tuple[Any, Any, dict]]]
100+
) -> str:
101+
nodes, edges = batch
102+
entities_str = "\n".join(
103+
[
104+
f"{index + 1}. {node[0]}: {node[1]['description']}"
105+
for index, node in enumerate(nodes)
106+
]
107+
)
108+
109+
relationships_str = "\n".join(
110+
[
111+
f"{index + 1}. {edge[0]} -- {edge[1]}: {edge[2]['description']}"
112+
for index, edge in enumerate(edges)
113+
]
114+
)
115+
context = entities_str + "\n" + relationships_str
116+
language = detect_main_language(entities_str + relationships_str)
117+
prompt = MAQ_GENERATION_PROMPT[language].format(
118+
context=context,
119+
num_of_questions=self.num_of_questions,
120+
)
121+
return prompt

graphgen/models/generator/multi_choice_generator.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,6 @@ def parse_response(response: str) -> Any:
8383
"question": question,
8484
"options": options, # Dict like {"A": "text", "B": "text", ...}
8585
"answer": answer, # Single letter: "A", "B", "C", or "D"
86-
"correct_answer_text": options[
87-
answer
88-
], # The actual text of correct answer
8986
}
9087

9188
logger.debug("Successfully parsed MCQ: %s", question[:50])

graphgen/operators/generate/generate_service.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ def __init__(
5050
self.llm_client,
5151
num_of_questions=generate_kwargs.get("num_of_questions", 5),
5252
)
53+
elif self.method == "multi_answer":
54+
from graphgen.models import MultiAnswerGenerator
55+
56+
self.generator = MultiAnswerGenerator(
57+
self.llm_client,
58+
num_of_questions=generate_kwargs.get("num_of_questions", 3),
59+
)
5360
else:
5461
raise ValueError(f"Unsupported generation mode: {method}")
5562

graphgen/templates/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
AGGREGATED_GENERATION_PROMPT,
77
ATOMIC_GENERATION_PROMPT,
88
COT_GENERATION_PROMPT,
9+
MAQ_GENERATION_PROMPT,
910
MCQ_GENERATION_PROMPT,
1011
MULTI_HOP_GENERATION_PROMPT,
1112
VQA_GENERATION_PROMPT,
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from .aggregated_generation import AGGREGATED_GENERATION_PROMPT
22
from .atomic_generation import ATOMIC_GENERATION_PROMPT
33
from .cot_generation import COT_GENERATION_PROMPT
4+
from .multi_answer_generation import MAQ_GENERATION_PROMPT
45
from .multi_choice_generation import MCQ_GENERATION_PROMPT
56
from .multi_hop_generation import MULTI_HOP_GENERATION_PROMPT
67
from .vqa_generation import VQA_GENERATION_PROMPT

0 commit comments

Comments
 (0)