Skip to content

Commit f6e8c64

Browse files
Merge remote-tracking branch 'origin/dataset-generator' into DAGE-18/Persistent-Storage-JSON
2 parents dbec2c0 + 0415abb commit f6e8c64

19 files changed

Lines changed: 1124 additions & 120 deletions

rre-dataset-generator/README.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ output_destination: "output/generated_dataset.json"
4545
4646
### 3. Running the Generator
4747
48+
Before running the command below, you need to have running search engine instance (`solr`/`opensearch`/`elasticsearch`/`vespa`).
49+
50+
4851
Execute the main script via the `argparse` CLI, pointing to your configuration file:
4952
```bash
5053
uv run dataset_generator.py --config_file config.yaml
@@ -98,4 +101,28 @@ docker compose up --build
98101

99102
This will start 2 services:
100103
- `solr`, available at http://localhost:8983/solr
101-
- `solr-init`, loads documents from solr/data/dataset.json only if Solr doesn't index any documents.
104+
- `solr-init`, loads documents from solr-init/data/dataset.json.
105+
106+
107+
##### Running OpenSearch (Single Node)
108+
109+
To run a local OpenSearch test environment using docker-compose:
110+
```bash
111+
cd tests/integration/
112+
```
113+
114+
Depending on your Docker version, you may need to use `docker compose` instead of `docker-compose`.
115+
If you have Docker Compose v1 installed, use:
116+
117+
```bash
118+
docker-compose -f docker-compose.opensearch.yml up --build
119+
```
120+
If you have Docker Compose v2 installed, use:
121+
```bash
122+
docker compose -f docker-compose.opensearch.yml up --build
123+
```
124+
125+
This will start 2 services:
126+
- `opensearch`, available at http://localhost:9200/
127+
- `opensearch-init`, loads documents (`bulk indexing`) from opensearch-init/data/dataset.jsonl.
128+

rre-dataset-generator/config.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@
22
# dataset-generator module
33
# Default: "q=#$query##"
44
# Example (solr):
5-
query_template: "q=#$query##&df=description&rows=3&q.op=OR&wt=json"
5+
query_template: "q=#$query##&df=description&rows=2&q.op=OR&wt=json"
66

77
# The type of search engine to use
88
# Accepted values: solr, elasticsearch, opensearch, vespa
99
search_engine_type: "solr"
1010

1111
# Endpoint URL of the search engine collection or index
12+
# opensearch: http://localhost:9200/testcore/
1213
search_engine_collection_endpoint: "http://localhost:8983/solr/testcore/"
1314

1415
# (Optional) Filter query to restrict the set of documents used to generate queries.
Lines changed: 66 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
# configuration params
2+
from src.config import Config
3+
from src.utils import parse_args
4+
from src.llm.llm_config import LLMConfig
5+
6+
# data types
7+
from typing import List
28
from langchain_core.language_models import BaseChatModel
9+
from src.llm.llm_service import LLMService
10+
from src.model.document import Document
311
from src.writers.abstract_writer import AbstractWriter
412
from src.search_engine.search_engine_base import BaseSearchEngine
5-
from src.utils import parse_args
6-
from src.config import Config
7-
from src.llm.llm_service import LLMService
8-
from src.llm.llm_config import LLMConfig
13+
from src.model.query_response import LLMQueryResponse
14+
from src.model.score_response import LLMScoreResponse
915

10-
# data structures
16+
# data structure
1117
from src.search_engine.data_store import DataStore
1218

1319
# build factories
@@ -17,70 +23,86 @@
1723

1824
# logging
1925
from src.logger import configure_logging
20-
import logging
21-
22-
23-
if __name__ == "__main__":
24-
args = parse_args()
26+
from logging import Logger, getLogger, DEBUG, INFO
2527

26-
config = Config.load(args.config_file)
2728

28-
if args.verbose:
29-
configure_logging(logging.DEBUG)
29+
def get_and_setup_logging(verbose: bool = False) -> Logger:
30+
if verbose:
31+
configure_logging(DEBUG)
3032
else:
31-
configure_logging(logging.INFO)
32-
log = logging.getLogger(__name__)
33+
configure_logging(INFO)
3334

34-
search_engine: BaseSearchEngine = SearchEngineFactory.build(search_engine_type=config.search_engine_type,
35-
endpoint=config.search_engine_collection_endpoint)
36-
data_store = DataStore()
35+
return getLogger(__name__)
3736

38-
num_queries = 0
37+
def add_user_queries(config: Config, data_store: DataStore):
3938
if config.queries is not None:
4039
with open(config.queries, 'r', encoding='utf-8') as file:
4140
for line in file:
4241
if line.strip():
4342
data_store.add_query(line)
44-
num_queries += 1
4543

46-
# retrieval of the documents needed to generate the queries
47-
docs_to_generate_queries = search_engine.fetch_for_query_generation(documents_filter=config.documents_filter,
48-
doc_number=config.doc_number,
49-
doc_fields=config.doc_fields)
50-
log.debug(f"Number of documents retrieved for generation: {len(docs_to_generate_queries)}")
51-
llm: BaseChatModel = LLMServiceFactory.build(LLMConfig.load(config.llm_configuration_file))
52-
service = LLMService(chat_model=llm)
44+
def generate_and_add_queries(llm_service: LLMService, config: Config, data_store: DataStore) -> None:
45+
num_queries_per_doc: int = int(((config.num_queries_needed - len(data_store.get_queries())) // config.doc_number) * 1.5)
5346

54-
num_queries_per_doc = int(( (config.num_queries_needed - num_queries) // config.doc_number) * 1.5)
55-
56-
# query generation step
57-
all_queries_generated = False
5847
for doc in docs_to_generate_queries:
5948
data_store.add_document(doc.id, doc)
60-
queries = service.generate_queries(doc, num_queries_per_doc)
61-
for query_text in queries:
49+
query_response: LLMQueryResponse = llm_service.generate_queries(doc, num_queries_per_doc)
50+
for query_text in query_response.get_queries():
6251
if len(data_store.get_queries()) >= config.num_queries_needed:
63-
all_queries_generated = True
64-
break
65-
query_id = data_store.add_query(query_text, doc.id)
52+
return
53+
query_id: str = data_store.add_query(query_text, doc.id)
6654
data_store.add_rating_score(query_id, doc.id, max(config.relevance_label_set))
67-
if all_queries_generated:
68-
break
6955

70-
log.debug(f"Number of documents evaluated: {len(docs_to_generate_queries)}")
7156

72-
# loop looking at all docs not rated in the data_store for that query
57+
def retrieve_and_add_documents(config: Config, data_store: DataStore) -> None:
58+
for query_rating_context in data_store.get_queries():
59+
docs_eval: List[Document] = search_engine.fetch_for_evaluation(keyword=query_rating_context.get_query(),
60+
query_template=config.query_template,
61+
doc_fields=config.doc_fields)
62+
for doc in docs_eval:
63+
if not data_store.has_document(doc.id):
64+
data_store.add_document(doc.id, doc)
65+
66+
def add_cartesian_product_scores(llm_service: LLMService, config: Config, data_store: DataStore) -> None:
7367
for query_rating_context in data_store.get_queries():
7468
for doc in data_store.get_documents():
7569
if not data_store.has_rating_score(query_rating_context.get_query_id(), doc.id):
76-
score = service.generate_score(data_store.get_document(doc.id),
77-
query_rating_context.get_query(),
78-
config.relevance_scale)
70+
score_response: LLMScoreResponse = llm_service.generate_score(data_store.get_document(doc.id),
71+
query_rating_context.get_query(),
72+
config.relevance_scale)
7973
data_store.add_rating_score(query_rating_context.get_query_id(),
8074
doc.id,
81-
score)
75+
score_response.get_score())
76+
77+
78+
if __name__ == "__main__":
79+
# configuration and logger definition
80+
args = parse_args()
81+
config: Config = Config.load(args.config_file)
82+
log: Logger = get_and_setup_logging(args.verbose)
8283

84+
# setup
85+
data_store: DataStore = DataStore()
86+
search_engine: BaseSearchEngine = SearchEngineFactory.build(search_engine_type=config.search_engine_type,
87+
endpoint=config.search_engine_collection_endpoint)
88+
llm: BaseChatModel = LLMServiceFactory.build(LLMConfig.load(config.llm_configuration_file))
89+
service: LLMService = LLMService(chat_model=llm)
8390
writer: AbstractWriter = WriterFactory.build(config.output_format, data_store)
91+
92+
# pipeline starts
93+
add_user_queries(config, data_store)
94+
95+
docs_to_generate_queries: List[Document] = search_engine.fetch_for_query_generation(documents_filter=config.documents_filter,
96+
doc_number=config.doc_number,
97+
doc_fields=config.doc_fields)
98+
log.debug(f"Number of documents retrieved for generation: {len(docs_to_generate_queries)}")
99+
100+
generate_and_add_queries(service, config, data_store)
101+
102+
retrieve_and_add_documents(config, data_store)
103+
104+
add_cartesian_product_scores(service, config, data_store)
105+
84106
writer.write(config.output_destination)
85107

86108
log.info(f"Synthetic Dataset has been generated in: {config.output_destination}")
Lines changed: 38 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1+
import json
12
from json import JSONDecodeError
2-
from typing import List, Union
33

44
from langchain_core.language_models import BaseChatModel
55
from langchain_core.messages import HumanMessage, SystemMessage
6-
import json
6+
from src.model.query_response import LLMQueryResponse
7+
from src.model.score_response import LLMScoreResponse
78
import logging
89

910
from src.model.document import Document
10-
from typing import Union, List
1111

1212
log = logging.getLogger(__name__)
1313

@@ -16,7 +16,7 @@ class LLMService:
1616
def __init__(self, chat_model: BaseChatModel):
1717
self.chat_model = chat_model
1818

19-
def generate_queries(self, document: Document, num_queries_generate_per_doc: int) -> Union[List[str], str]:
19+
def generate_queries(self, document: Document, num_queries_generate_per_doc: int) -> LLMQueryResponse:
2020
"""
2121
Generate queries based on the given document and num_queries_generate_per_doc and
2222
Returns a list of generated queries or just a generated string in case of LLM hallucination
@@ -35,32 +35,35 @@ def generate_queries(self, document: Document, num_queries_generate_per_doc: int
3535
HumanMessage(content=f"Document:\n{doc_json}")
3636
]
3737

38-
raw = self.chat_model.invoke(messages).content.strip()
38+
# The response from invoke is an AIMessage object which contains all the needed info
39+
response = self.chat_model.invoke(messages)
3940

40-
# in case LLM hallucinates
4141
try:
42-
return json.loads(raw)
43-
except JSONDecodeError:
44-
log.warning("LLM hallucinated and its response: %s", raw)
45-
return raw
42+
output = LLMQueryResponse(response_content=response.content)
43+
except (KeyError, JSONDecodeError, ValueError) as e:
44+
log.warning(f"LLM unexpected response. Raw output: {response.content}")
45+
raise ValueError(f"Invalid LLM response: {e}")
4646

47-
def generate_score(self, document: Document, query: str, relevance_scale: str) -> int:
47+
return output
48+
49+
50+
def generate_score(self, document: Document, query: str, relevance_scale: str) -> LLMScoreResponse:
4851
"""
4952
Generates a relevance score for a given document-query pair using a specified relevance scale.
5053
"""
5154
if relevance_scale == "binary":
52-
scale = {0, 1}
53-
description = (" - 0: the query is NOT relevant to the given document"
54-
" - 1: the query is relevant to the given document")
55+
allowed = {0, 1}
56+
description = (" - 0: the query is NOT relevant to the given document\n"
57+
" - 1: the query is relevant to the given document")
5558
elif relevance_scale == "graded":
56-
scale = {0, 1, 2}
57-
description = (" - 0: the query is NOT relevant to the given document"
58-
" - 1: the query may be relevant to the given document"
59-
" - 2: the document proposed is the answer to the query")
59+
allowed = {0, 1, 2}
60+
description = (" - 0: the query is NOT relevant to the given document\n"
61+
" - 1: the query may be relevant to the given document\n"
62+
" - 2: the document proposed is the answer to the query")
6063
else:
61-
error_msg = "The relevance scale must be either 'binary' or 'graded'"
62-
log.error(error_msg)
63-
raise ValueError(error_msg)
64+
msg = f"Invalid relevance scale: {relevance_scale}"
65+
log.error(msg)
66+
raise ValueError(msg)
6467

6568
messages = [
6669
SystemMessage(
@@ -76,16 +79,19 @@ def generate_score(self, document: Document, query: str, relevance_scale: str) -
7679
)
7780
]
7881

79-
#response = self.chat_model.with_structured_output(method="json_mode").invoke(messages)
8082
raw = self.chat_model.invoke(messages).content.strip()
83+
8184
try:
82-
response = int(json.loads(raw)['score'])
83-
if response not in scale:
84-
error_msg = f"LLM hallucinated the value of the scale. Returned: {response}"
85-
log.warning(error_msg)
86-
raise ValueError(error_msg)
87-
return response
88-
except JSONDecodeError:
89-
error_msg = f"LLM hallucinated and its response: {raw}"
90-
log.warning(error_msg)
91-
raise ValueError(error_msg)
85+
score = json.loads(raw)['score']
86+
except (JSONDecodeError, KeyError) as e:
87+
log.debug(f"LLM unexpected response. Raw output: {raw}")
88+
raise ValueError(f"Invalid LLM response: {e}")
89+
90+
try:
91+
parsed = LLMScoreResponse(score=score, scale=relevance_scale)
92+
return parsed
93+
except ValueError as e:
94+
log.warning(f"Validation error for score '{score}' on scale '{relevance_scale}': {e}")
95+
raise e
96+
97+
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import json
2+
from typing import List
3+
4+
class LLMQueryResponse:
5+
"""
6+
Parses and validates a JSON response containing a list of queries.
7+
"""
8+
_queries: List[str]
9+
10+
def __init__(self, response_content: str):
11+
"""
12+
Initializes the object by parsing and validating the JSON string.
13+
14+
Args:
15+
response_content: A string containing a JSON-encoded list of strings.
16+
17+
Raises:
18+
ValueError: If the content is not a valid JSON list of non-empty strings.
19+
"""
20+
try:
21+
parsed_content = json.loads(response_content)
22+
except json.JSONDecodeError as e:
23+
raise ValueError(f"Invalid JSON in `response_content`: {e}")
24+
25+
if not isinstance(parsed_content, list):
26+
raise ValueError("`response_content` must be a JSON list.")
27+
28+
if not all(isinstance(item, str) for item in parsed_content):
29+
raise ValueError("All items in `response_content` must be strings.")
30+
31+
if any(not item.strip() for item in parsed_content):
32+
raise ValueError("Items in `response_content` must not be empty or only whitespace.")
33+
34+
self._queries = parsed_content
35+
36+
def get_queries(self) -> List[str]:
37+
"""
38+
Returns the validated list of queries.
39+
"""
40+
return self._queries
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
class LLMScoreResponse:
2+
"""
3+
Parses and validates an LLM score response.
4+
"""
5+
def __init__(self, score: int, scale: str = "graded"):
6+
"""
7+
Initializes the object by validating the score.
8+
9+
Args:
10+
score: The relevance score.
11+
scale: The relevance scale, either 'binary' (0-1) or 'graded' (0-2).
12+
13+
Raises:
14+
ValueError: If the score is not valid for the given scale.
15+
"""
16+
if scale not in ["binary", "graded"]:
17+
raise ValueError(f"Invalid scale: {scale}. Must be 'binary' or 'graded'.")
18+
19+
if scale == "binary" and score not in {0, 1}:
20+
raise ValueError(f"Score must be 0 or 1 for binary scale, got {score}")
21+
elif scale == "graded" and score not in {0, 1, 2}:
22+
raise ValueError(f"Score must be 0, 1, or 2 for graded scale, got {score}")
23+
24+
self.score = score
25+
26+
def get_score(self) -> int:
27+
"""
28+
Returns the validated score.
29+
30+
Returns:
31+
The validated score.
32+
"""
33+
return self.score

0 commit comments

Comments
 (0)