| """ |
| Map gold sentences to chunk ids produced by semantic chunking. |
| |
| Run AFTER ingestion (reingest_directory on data/eval_corpus/) so the chunk |
| registry reflects the current index. |
| |
| Reads: evaluation/data/eval_raw.json, data/chunk_registry.json |
| Writes: evaluation/data/eval_dataset.json |
| """ |
| import json |
| import re |
| from pathlib import Path |
|
|
| from core.config import REGISTRY_PATH |
|
|
| |
| _PACKAGE_DIR = Path(__file__).resolve().parent |
|
|
| RAW_EVAL_PATH = _PACKAGE_DIR / "data" / "eval_raw.json" |
| FINAL_EVAL_PATH = _PACKAGE_DIR / "data" / "eval_dataset.json" |
|
|
| SHORT_SENTENCE_THRESHOLD = 40 |
| MATCH_PREFIX_LENGTH = 80 |
|
|
| FALLBACK_GROUND_TRUTH = ( |
| "The ingested documents do not contain information to answer this question." |
| ) |
|
|
|
|
| def normalize(text: str) -> str: |
| """Lowercase and collapse all whitespace runs to single spaces.""" |
| return re.sub(r"\s+", " ", text.lower()).strip() |
|
|
|
|
| def build_chunk_index(registry: dict) -> list[tuple[str, str]]: |
| """ |
| Return a list of (chunk_id, normalized_page_content) from the registry. |
| |
| Uses the by_chunk_id index for direct access. |
| """ |
| index = [] |
| for chunk_id, chunk in registry["by_chunk_id"].items(): |
| normalized_content = normalize(chunk["page_content"]) |
| index.append((chunk_id, normalized_content)) |
| return index |
|
|
|
|
| def find_matching_chunks(sentence: str, chunk_index: list[tuple[str, str]]) -> list[str]: |
| """ |
| Return chunk_ids whose normalized content contains the gold sentence. |
| |
| Short sentences (<40 chars normalized) require an exact full-sentence match. |
| Longer sentences match on the first 80 normalized chars to tolerate |
| chunk-boundary splits. |
| """ |
| normalized_sentence = normalize(sentence) |
| is_short = len(normalized_sentence) < SHORT_SENTENCE_THRESHOLD |
|
|
| if is_short: |
| search_token = normalized_sentence |
| else: |
| search_token = normalized_sentence[:MATCH_PREFIX_LENGTH] |
|
|
| matched_ids = [] |
| for chunk_id, normalized_content in chunk_index: |
| if search_token in normalized_content: |
| matched_ids.append(chunk_id) |
| return matched_ids |
|
|
|
|
| def build_notes(record: dict, matched_titles: list[str]) -> str: |
| """Build the notes string for a final eval record.""" |
| source = record.get("source", "") |
| record_type = record.get("type", "") |
|
|
| if source == "hotpotqa": |
| titles_str = " | ".join(matched_titles) if matched_titles else "(none)" |
| parts = [f"hotpotqa multi-hop; gold titles: {titles_str}"] |
| if record_type: |
| parts[0] += f"; type: {record_type}" |
| return parts[0] |
| else: |
| return "squad_v2 unanswerable; topic absent from corpus" |
|
|
|
|
| def main() -> None: |
| print(f"Loading raw eval spec from {RAW_EVAL_PATH} ...") |
| raw_records = json.loads(RAW_EVAL_PATH.read_text(encoding="utf-8")) |
| print(f" Loaded {len(raw_records)} records.") |
|
|
| print(f"Loading chunk registry from {REGISTRY_PATH} ...") |
| registry = json.loads(Path(REGISTRY_PATH).read_text(encoding="utf-8")) |
| chunk_index = build_chunk_index(registry) |
| print(f" Indexed {len(chunk_index)} chunks.") |
|
|
| final_records = [] |
| zero_match_cases = [] |
| total_supporting_facts = 0 |
| total_unmatched_facts = 0 |
|
|
| for record in raw_records: |
| expected_behavior = record["expected_behavior"] |
| question = record["question"] |
|
|
| if expected_behavior == "fallback": |
| final_records.append({ |
| "question": question, |
| "ground_truth": FALLBACK_GROUND_TRUTH, |
| "relevant_chunk_ids": [], |
| "expected_behavior": "fallback", |
| "notes": build_notes(record, []), |
| }) |
| continue |
|
|
| |
| supporting_facts = record.get("supporting", []) |
| relevant_chunk_ids: list[str] = [] |
| matched_titles: list[str] = [] |
| unmatched_in_record = 0 |
|
|
| for fact in supporting_facts: |
| total_supporting_facts += 1 |
| title = fact["title"] |
| sentence = fact["sentence"] |
|
|
| matched = find_matching_chunks(sentence, chunk_index) |
| if matched: |
| for chunk_id in matched: |
| if chunk_id not in relevant_chunk_ids: |
| relevant_chunk_ids.append(chunk_id) |
| if title not in matched_titles: |
| matched_titles.append(title) |
| else: |
| unmatched_in_record += 1 |
| total_unmatched_facts += 1 |
|
|
| notes = build_notes(record, matched_titles) |
| if unmatched_in_record > 0 and len(relevant_chunk_ids) == 0: |
| notes += " (WARNING: gold sentences not found in any chunk)" |
|
|
| final_records.append({ |
| "question": question, |
| "ground_truth": record["answer"], |
| "relevant_chunk_ids": relevant_chunk_ids, |
| "expected_behavior": "answer", |
| "notes": notes, |
| }) |
|
|
| if len(relevant_chunk_ids) == 0: |
| zero_match_cases.append(question) |
|
|
| FINAL_EVAL_PATH.write_text( |
| json.dumps(final_records, indent=2, ensure_ascii=False), |
| encoding="utf-8", |
| ) |
| print(f"\nWrote {len(final_records)} records to {FINAL_EVAL_PATH}") |
|
|
| |
| answer_cases = [r for r in final_records if r["expected_behavior"] == "answer"] |
| fallback_cases = [r for r in final_records if r["expected_behavior"] == "fallback"] |
| total_chunk_ids = sum(len(r["relevant_chunk_ids"]) for r in answer_cases) |
| avg_chunks = total_chunk_ids / len(answer_cases) if answer_cases else 0.0 |
|
|
| print("\n--- Summary ---") |
| print(f" Total records: {len(final_records)}") |
| print(f" Answer cases: {len(answer_cases)}") |
| print(f" Fallback cases: {len(fallback_cases)}") |
| print(f" Avg relevant chunks/case: {avg_chunks:.2f}") |
| print(f" Supporting facts total: {total_supporting_facts}") |
| print(f" Supporting facts unmatched: {total_unmatched_facts}") |
| print(f" Answer cases with 0 matched chunks: {len(zero_match_cases)}") |
|
|
| if zero_match_cases: |
| print("\n First 5 zero-match questions (label failures to investigate):") |
| for q in zero_match_cases[:5]: |
| print(f" - {q[:100]}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|