Skip to content

Commit 1a0b18a

Browse files
committed
Improves data ingestion by pulling the data either suing GCP or by using hugging face
1 parent 21b6b14 commit 1a0b18a

6 files changed

Lines changed: 334 additions & 67 deletions

File tree

‎examples/opik_vs_elastic/Instructions.md‎

Lines changed: 68 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,26 @@
11
# Instructions
22

3-
Setup and run the Opik vs elastic-evals-sdk-python PoC.
3+
This PoC demonstrates how Agent Builder can use the `kbn/evals` Python SDK for dataset
4+
management, experiment tracing, and evaluator score ingestion—capabilities previously
5+
handled through Opik—and how to build custom evaluators.
46

5-
## 1. Python environment
7+
## 1. Dependencies
68

7-
From the SDK repo root:
8-
9-
```bash
10-
cd elastic-evals-sdk-python
11-
uv sync --extra dev --extra runner --extra poc
12-
source .venv/bin/activate
13-
```
14-
15-
## 2. Dependencies
16-
17-
```bash
18-
uv add --optional poc datasets pandas python-dotenv ipykernel elasticsearch opik loguru
19-
uv sync --extra poc
20-
```
21-
22-
Orca must be importable. Clone the `orca` repo as a sibling of `elastic-evals-sdk-python/` so the layout is:
9+
`run2.py` uses external Orca evaluators. Clone the `orca` repo as a sibling of
10+
`elastic-evals-sdk-python/` so the layout is:
2311

2412
```
2513
── elastic-evals-sdk-python/
2614
── orca/
2715
```
2816

29-
Then install it editable and register it under the `poc` extra:
17+
## 2. Python environment
18+
19+
From the SDK repo root:
3020

3121
```bash
32-
uv add --optional poc --editable ../orca
22+
uv sync --group dev --extra runner --extra poc
23+
source .venv/bin/activate
3324
```
3425

3526
Register the venv as a Jupyter kernel. Only needed if your notebook/IDE doesn't pick up `.venv` automatically:
@@ -40,21 +31,50 @@ uv run python -m ipykernel install --user --name elastic-evals-poc
4031

4132
## 3. Secrets
4233

43-
Retrieve the API keys (Opik, OpenRouter, HuggingFace) from Vault, then fill them into `.env`:
34+
Create `.env` next to `.env.example`:
35+
36+
```bash
37+
cp examples/opik_vs_elastic/.env.example examples/opik_vs_elastic/.env
38+
```
39+
40+
Set the local URLs, `ELASTICSEARCH_API_KEY`, `KIBANA_API_KEY`, `CONNECTOR_ID`,
41+
and `EVALUATION_CONNECTOR_ID`. The Opik variables are used when `run2.py` runs
42+
the tracked external Orca evaluators. Retrieve internal credentials from Vault
43+
when needed:
4444

4545
```bash
4646
VAULT_ADDR=https://secrets.elastic.co:8200 vault login --method oidc
4747
```
4848

49-
## 4. GCP access
49+
The public Hugging Face dataset does not require an API key.
50+
51+
## 4. Data source and sample size
52+
53+
Set these values near the top of the script before running it:
54+
55+
```python
56+
USE_ENTIRE_DATASET = False
57+
DATASET_SAMPLE_SIZE = 10
58+
USE_GCP = False
59+
```
60+
61+
With `USE_GCP = False`, the scripts load the public `Wix/WixQA` dataset and
62+
knowledge base from Hugging Face. Set `USE_GCP = True` to use the internal GCS
63+
files. `DATASET_SAMPLE_SIZE` is ignored when `USE_ENTIRE_DATASET` is `True`.
64+
The entire knowledge base is always indexed.
65+
66+
The available examples and their order may differ between Hugging Face and GCS.
67+
`run.py` defaults to 10 examples and `run2.py` defaults to 3.
68+
69+
## 5. GCP access
5070

51-
Only needed if you import data from a GCP bucket (e.g. `gs://agent-builder-data-science-datasets/...`). Authenticate with your `@elastic.co` account:
71+
Only needed when `USE_GCP = True`. Authenticate with your `@elastic.co` account:
5272

5373
```bash
5474
gcloud auth application-default login
5575
```
5676

57-
## 5. Local stack
77+
## 6. Local stack
5878

5979
Use a separate terminal for each service and leave it running.
6080

@@ -128,25 +148,40 @@ curl --silent --show-error \
128148
http://localhost:5601/dev/api/status
129149
```
130150

131-
## 6. Elasticsearch API key
151+
## 7. Elasticsearch API key
132152

133-
`run.py` authenticates to Elasticsearch with `ELASTICSEARCH_API_KEY` (and reuses the
134-
same key for Kibana, which validates Elasticsearch API keys). Create one against your
135-
cluster and paste the `encoded` field into `ELASTICSEARCH_API_KEY` in `.env`:
153+
Both scripts authenticate to Elasticsearch and Kibana. Create an API key against
154+
the local cluster and paste the `encoded` field into both
155+
`ELASTICSEARCH_API_KEY` and `KIBANA_API_KEY` in `.env`:
136156

137157
```bash
138158
curl -u elastic:changeme -XPOST http://localhost:9200/_security/api_key \
139159
-H 'Content-Type: application/json' -d '{"name":"evals-poc"}'
140160
```
141161

142-
This step is **optional** — skip it if you already have a valid `ELASTICSEARCH_API_KEY`
143-
for the cluster in `ES_URL`. It's required when the key is missing or invalid, e.g.
144-
after starting a fresh local Elasticsearch (`yarn es snapshot`): API keys are
145-
cluster-specific, so a key from a previous cluster returns `401`.
162+
This step is optional if both variables already contain a valid key for the
163+
current cluster. API keys are cluster-specific, so a key from a previous local
164+
Elasticsearch snapshot returns `401`.
146165

147-
## 7. Run the PoC
166+
## 8. Run the PoC
167+
168+
### `run.py`: managed workflow
169+
170+
Demonstrates the higher-level workflow. It uses
171+
`ElasticEvalsClient.run_experiment()` to execute the selected WixQA examples,
172+
run SDK-side and custom evaluators, and ingest their scores.
148173

149174
```bash
150175
cd /Users/mafaldasavelho/Documents/work-repos/kibana-fork/evals-python-sdk/elastic-evals-sdk-python
151176
uv run --extra poc python examples/opik_vs_elastic/run.py
152177
```
178+
179+
### `run2.py`: granular workflow
180+
181+
Demonstrates the lower-level workflow without `run_experiment()`. It directly
182+
coordinates the Dataset, Evaluators, and Score Ingestion APIs, runs the custom
183+
Document Recall evaluator, and attaches external Orca scores.
184+
185+
```bash
186+
uv run --extra poc python examples/opik_vs_elastic/run2.py
187+
```
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
2+
# or more contributor license agreements. Licensed under the Elastic License 2.0;
3+
# you may not use this file except in compliance with the Elastic License 2.0.
4+
5+
"""Load and normalize WixQA data from GCS or Hugging Face."""
6+
7+
from __future__ import annotations
8+
9+
from collections.abc import Iterable, Mapping
10+
from typing import Any
11+
12+
import pandas as pd
13+
from datasets import load_dataset
14+
15+
from examples.opik_vs_elastic.helpers.helpers import (
16+
GROUND_TRUTH_COLUMN,
17+
WIX_KNOWLEDGE_BASE_PATH,
18+
WIX_QA_DATASET_PATH,
19+
_parse_relevant_doc_ids, # noqa: PLC2701
20+
)
21+
22+
HUGGING_FACE_DATASET = "Wix/WixQA"
23+
HUGGING_FACE_QA_CONFIG = "wixqa_expertwritten"
24+
HUGGING_FACE_KB_CONFIG = "wix_kb_corpus"
25+
26+
27+
def _require_columns(dataframe: pd.DataFrame, columns: set[str], *, source: str) -> None:
28+
missing = sorted(columns - set(dataframe.columns))
29+
if missing:
30+
raise ValueError(f"{source} is missing required columns: {', '.join(missing)}")
31+
32+
33+
def _to_document_ids(value: Any) -> list[str]:
34+
if isinstance(value, str) or not isinstance(value, Iterable) or isinstance(value, Mapping):
35+
return []
36+
return list(dict.fromkeys(str(item) for item in value if item is not None))
37+
38+
39+
def _load_hugging_face_config(config: str) -> pd.DataFrame:
40+
dataset = load_dataset(HUGGING_FACE_DATASET, config, split="train")
41+
return dataset.to_pandas()
42+
43+
44+
def _normalize_gcs_qa(dataframe: pd.DataFrame) -> pd.DataFrame:
45+
required = {
46+
"meta_query_id",
47+
"input_question",
48+
"output_expected",
49+
GROUND_TRUTH_COLUMN,
50+
}
51+
_require_columns(dataframe, required, source="GCS WixQA dataset")
52+
53+
normalized = dataframe.copy()
54+
normalized["relevant_doc_ids"] = normalized[GROUND_TRUTH_COLUMN].apply(_parse_relevant_doc_ids)
55+
return normalized
56+
57+
58+
def _normalize_hugging_face_qa(dataframe: pd.DataFrame) -> pd.DataFrame:
59+
dataframe = dataframe.reset_index(drop=True)
60+
_require_columns(
61+
dataframe,
62+
{"question", "answer", "article_ids"},
63+
source="Hugging Face WixQA dataset",
64+
)
65+
66+
document_ids = dataframe["article_ids"].apply(_to_document_ids)
67+
return pd.DataFrame(
68+
{
69+
"meta_query_id": [f"wixqa_expertwritten_{index + 1}" for index in range(len(dataframe))],
70+
"input_question": dataframe["question"].astype(str),
71+
"output_expected": dataframe["answer"].astype(str),
72+
GROUND_TRUTH_COLUMN: document_ids.apply(lambda ids: {document_id: True for document_id in ids}),
73+
"relevant_doc_ids": document_ids,
74+
}
75+
)
76+
77+
78+
def _normalize_knowledge_base(dataframe: pd.DataFrame, *, source: str) -> pd.DataFrame:
79+
_require_columns(dataframe, {"id", "contents"}, source=source)
80+
normalized = dataframe.copy()
81+
normalized["id"] = normalized["id"].astype(str)
82+
return normalized
83+
84+
85+
def load_wix_data(*, use_gcp: bool) -> tuple[pd.DataFrame, pd.DataFrame]:
86+
"""Load WixQA examples and their knowledge-base corpus."""
87+
if use_gcp:
88+
qa = _normalize_gcs_qa(pd.read_csv(WIX_QA_DATASET_PATH))
89+
knowledge_base = _normalize_knowledge_base(
90+
pd.read_csv(WIX_KNOWLEDGE_BASE_PATH),
91+
source="GCS Wix knowledge base",
92+
)
93+
else:
94+
qa = _normalize_hugging_face_qa(_load_hugging_face_config(HUGGING_FACE_QA_CONFIG))
95+
knowledge_base = _normalize_knowledge_base(
96+
_load_hugging_face_config(HUGGING_FACE_KB_CONFIG),
97+
source="Hugging Face Wix knowledge base",
98+
)
99+
100+
return qa, knowledge_base
101+
102+
103+
def select_qa_examples(
104+
dataframe: pd.DataFrame,
105+
*,
106+
use_entire_dataset: bool,
107+
sample_size: int,
108+
) -> pd.DataFrame:
109+
"""Return the full QA dataset or its first configured examples."""
110+
if use_entire_dataset:
111+
return dataframe.reset_index(drop=True).copy()
112+
if sample_size < 1:
113+
raise ValueError("DATASET_SAMPLE_SIZE must be at least 1")
114+
return dataframe.head(sample_size).reset_index(drop=True).copy()

‎examples/opik_vs_elastic/run.py‎

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
from typing import Any
1010

1111
import httpx
12-
import pandas as pd
1312
from dotenv import load_dotenv
1413

1514
from elastic_evals.agent_builder import (
@@ -46,6 +45,7 @@
4645
EvaluatorParams,
4746
Example,
4847
)
48+
from examples.opik_vs_elastic.helpers.data import load_wix_data, select_qa_examples
4949
from examples.opik_vs_elastic.helpers.helpers import (
5050
AGENT_ID,
5151
AGENT_INSTRUCTIONS,
@@ -55,14 +55,18 @@
5555
INDEX_NAME,
5656
SEARCH_TOOL_DESCRIPTION,
5757
SEARCH_TOOL_ID,
58-
WIX_KNOWLEDGE_BASE_PATH,
59-
WIX_QA_DATASET_PATH,
6058
_extract_retrieved_doc_ids, # noqa: PLC2701
61-
_parse_relevant_doc_ids, # noqa: PLC2701
6259
_to_string_list, # noqa: PLC2701
6360
)
6461
from examples.opik_vs_elastic.helpers.indexing import get_elasticsearch_client
6562

63+
USE_ENTIRE_DATASET = False
64+
DATASET_SAMPLE_SIZE = 10
65+
USE_GCP = False
66+
67+
DATASET_NAME = "wix_qa_managed_workflow"
68+
DATASET_DESCRIPTION = "WixQA golden Q&A pairs for the managed evaluation workflow."
69+
6670
WIX_RESPONSE_CRITERIA = [
6771
"The response directly addresses the user's Wix support question.",
6872
"The response provides clear and actionable guidance.",
@@ -152,16 +156,15 @@ async def main() -> None:
152156
load_dotenv(ENV_PATH)
153157

154158
# (1) Prepare data:
155-
print("\nLoading QA pairs from GCS (wix_qa dataset)...")
156-
qa_wix = pd.read_csv(WIX_QA_DATASET_PATH)
157-
qa_wix["relevant_doc_ids"] = qa_wix[GROUND_TRUTH_COLUMN].apply(_parse_relevant_doc_ids)
158-
print(f"Dataset shape: {qa_wix.shape}") # output: (52, 5)
159-
# print(qa_wix.head())
160-
161-
print("\nLoading knowledge base from GCS (wix_knowledge_base dataset)...")
162-
kb_wix = pd.read_csv(WIX_KNOWLEDGE_BASE_PATH)
163-
print(f"Dataset shape: {kb_wix.shape}") # output: (6222, 4)
164-
# print(kb_wix.head())
159+
source = "GCS" if USE_GCP else "Hugging Face"
160+
print(f"\nLoading WixQA data from {source}...")
161+
all_qa_wix, kb_wix = load_wix_data(use_gcp=USE_GCP)
162+
qa_wix = select_qa_examples(
163+
all_qa_wix,
164+
use_entire_dataset=USE_ENTIRE_DATASET,
165+
sample_size=DATASET_SAMPLE_SIZE,
166+
)
167+
print(f"Selected {len(qa_wix)} of {len(all_qa_wix)} QA examples; knowledge base contains {len(kb_wix)} documents")
165168

166169
# (2) Indexing the knowledge base into elasticsearch (if not indexed already):
167170
print("Indexing knowledge base into Elasticsearch...")
@@ -200,8 +203,8 @@ async def main() -> None:
200203
]
201204

202205
examples_dataset = EvaluationDataset(
203-
name="wix_qa_smoke",
204-
description="Small sample of WixQA golden Q&A pairs for smoke-testing the pipeline.",
206+
name=DATASET_NAME,
207+
description=DATASET_DESCRIPTION,
205208
examples=[
206209
Example(
207210
input={"question": row["input_question"]},
@@ -213,8 +216,7 @@ async def main() -> None:
213216
},
214217
)
215218
for _, row in qa_wix.iterrows()
216-
if _ < 10
217-
], # take only the first 10 examples for a quick iteration
219+
],
218220
)
219221

220222
# [IMPORTANT] NOTE: if the dataset doesn't exist, then it creates a new one with the given name. If the dataset already exists,
@@ -223,8 +225,8 @@ async def main() -> None:
223225
# UpsertDatasetResponse(dataset_id='0b5ee7b6-9f4a-5c66-b196-6b8cc5154eec', added=0, removed=47, unchanged=5)
224226

225227
upsert_dataset_response = await elastic_evals_client._datasets_client.upsert(
226-
name="wix_qa_smoke",
227-
description="Small sample of WixQA golden Q&A pairs for smoke-testing the pipeline.",
228+
name=DATASET_NAME,
229+
description=DATASET_DESCRIPTION,
228230
examples=examples,
229231
)
230232
print(

0 commit comments

Comments
 (0)