import argparse import hashlib import json import math import os import random import re from collections import Counter, defaultdict from typing import Any, Dict, Iterable, List, Tuple import numpy as np def iter_jsonl(path): with open(path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if line: yield json.loads(line) def write_jsonl(path, rows): os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) with open(path, "w", encoding="utf-8") as f: for row in rows: f.write(json.dumps(row, ensure_ascii=False) + "\n") def stable_id(text): return hashlib.md5(text.encode("utf-8")).hexdigest()[:12] def as_list(value): if value is None: return [] if isinstance(value, list): return value return [value] def text_of(value): if value is None: return "" if isinstance(value, str): return value if isinstance(value, dict): for key in ("text", "content", "paragraph_text", "passage_text", "snippet", "body"): if key in value: return str(value[key]) return str(value) def tokenize(text): text = str(text or "").lower() zh = re.findall(r"[\u4e00-\u9fff]", text) words = re.findall(r"[a-z0-9]+", text) return zh + words def normalize_answers(raw): answers = [] for item in as_list(raw): if isinstance(item, dict): for key in ("text", "answer", "value"): if key in item and str(item[key]).strip(): answers.append(str(item[key]).strip()) elif str(item).strip(): answers.append(str(item).strip()) seen = set() dedup = [] for answer in answers: key = answer.lower() if key not in seen: seen.add(key) dedup.append(answer) return dedup def normalize_context_item(item, fallback_id): if isinstance(item, str): return {"id": fallback_id, "title": "", "text": item} doc_id = str( item.get("id") or item.get("doc_id") or item.get("pid") or item.get("passage_id") or item.get("wikipedia_id") or item.get("source") or fallback_id ) title = str(item.get("title") or item.get("document_title") or item.get("name") or "") text = text_of(item) return {"id": doc_id, "title": title, "text": text} def extract_contexts(sample): candidates = ( sample.get("contexts") or sample.get("documents") or sample.get("snippets") or sample.get("search_results") or sample.get("ctxs") or sample.get("passages") or sample.get("positive_ctxs") or sample.get("retrieved_passages") or [] ) contexts = [] for i, item in enumerate(candidates): doc = normalize_context_item(item, f"D{i + 1}") if doc["text"].strip(): contexts.append(doc) return contexts def extract_gold_doc_ids(sample, contexts): explicit = sample.get("gold_doc_ids") or sample.get("citations") or sample.get("supporting_doc_ids") context_ids = {doc["id"] for doc in contexts} if explicit: matched = [str(x) for x in as_list(explicit) if str(x) in context_ids] if matched: return matched ids = [] for doc in contexts: text = doc["text"] if any(answer and answer in text for answer in extract_answers(sample)): ids.append(doc["id"]) if not ids and contexts: ids.append(contexts[0]["id"]) return ids[:3] def keep_gold_contexts_first(contexts, gold_doc_ids, max_contexts): if max_contexts <= 0 or len(contexts) <= max_contexts: return contexts gold = set(str(x) for x in gold_doc_ids) positives = [doc for doc in contexts if doc["id"] in gold] others = [doc for doc in contexts if doc["id"] not in gold] return (positives + others)[:max_contexts] def extract_question(sample): return str( sample.get("question") or sample.get("query") or sample.get("prompt") or sample.get("input") or sample.get("id") or "" ).strip() def extract_answers(sample): raw = sample.get("answers") if raw is None: raw = sample.get("answer") if raw is None: raw = sample.get("short_answers") if raw is None: raw = sample.get("output") return normalize_answers(raw) def normalize_sample(sample, source_name, index): question = extract_question(sample) answers = extract_answers(sample) contexts = extract_contexts(sample) if not question or not answers or not contexts: return None gold_doc_ids = extract_gold_doc_ids(sample, contexts) return { "id": str(sample.get("id") or f"{source_name}_{index}_{stable_id(question)}"), "source": source_name, "question": question, "answer": answers[0], "answers": answers, "gold_doc_ids": gold_doc_ids, "contexts": contexts, } def collect_corpus(samples): corpus = {} for sample in samples: for doc in sample["contexts"]: if doc["id"] not in corpus and doc["text"].strip(): corpus[doc["id"]] = doc return list(corpus.values()) def build_idf(corpus): df = defaultdict(int) for doc in corpus: for tok in set(tokenize(doc["title"] + " " + doc["text"])): df[tok] += 1 n_docs = max(len(corpus), 1) return {tok: math.log((n_docs - freq + 0.5) / (freq + 0.5) + 1.0) for tok, freq in df.items()} def bm25_score(query_tokens, doc_tokens, idf, avgdl, k1=1.2, b=0.75): if not doc_tokens: return 0.0 tf = Counter(doc_tokens) score = 0.0 dl = len(doc_tokens) denom_base = k1 * (1 - b + b * dl / max(avgdl, 1.0)) for tok in query_tokens: freq = tf.get(tok, 0) if freq: score += idf.get(tok, 0.0) * freq * (k1 + 1) / (freq + denom_base) return score def add_bm25_hard_negatives(samples, num_negatives, max_contexts, seed): if num_negatives <= 0: return samples rng = random.Random(seed) corpus = collect_corpus(samples) if not corpus: return samples idf = build_idf(corpus) doc_tokens = {doc["id"]: tokenize(doc["title"] + " " + doc["text"]) for doc in corpus} avgdl = sum(len(toks) for toks in doc_tokens.values()) / max(len(doc_tokens), 1) doc_by_id = {doc["id"]: doc for doc in corpus} for sample in samples: existing = {doc["id"] for doc in sample["contexts"]} gold = set(sample["gold_doc_ids"]) query_tokens = tokenize(sample["question"] + " " + sample["answer"]) scored = [] for doc in corpus: if doc["id"] in existing or doc["id"] in gold: continue score = bm25_score(query_tokens, doc_tokens[doc["id"]], idf, avgdl) if score > 0: scored.append((score, doc["id"])) scored.sort(reverse=True) top_ids = [doc_id for _, doc_id in scored[: num_negatives * 4]] rng.shuffle(top_ids) negatives = [doc_by_id[doc_id] for doc_id in top_ids[:num_negatives]] sample["contexts"] = keep_gold_contexts_first(sample["contexts"] + negatives, sample["gold_doc_ids"], max_contexts) return samples def add_embedding_hard_negatives(samples, num_negatives, max_contexts, seed, model_name, batch_size): if num_negatives <= 0: return samples try: from sentence_transformers import SentenceTransformer except ImportError as exc: raise ImportError("Embedding negative mining requires sentence_transformers. Install project requirements first.") from exc rng = random.Random(seed) corpus = collect_corpus(samples) if not corpus: return samples model = SentenceTransformer(model_name) doc_texts = [(doc["title"] + "\n" + doc["text"]).strip() for doc in corpus] query_texts = [(sample["question"] + "\n" + sample["answer"]).strip() for sample in samples] doc_emb = model.encode( doc_texts, batch_size=batch_size, show_progress_bar=True, normalize_embeddings=True, ) query_emb = model.encode( query_texts, batch_size=batch_size, show_progress_bar=True, normalize_embeddings=True, ) doc_emb = np.asarray(doc_emb, dtype=np.float32) query_emb = np.asarray(query_emb, dtype=np.float32) scores = query_emb @ doc_emb.T for i, sample in enumerate(samples): existing = {doc["id"] for doc in sample["contexts"]} gold = set(sample["gold_doc_ids"]) ranked = np.argsort(-scores[i]) candidate_ids = [] for doc_idx in ranked: doc_id = corpus[int(doc_idx)]["id"] if doc_id in existing or doc_id in gold: continue candidate_ids.append(doc_idx) if len(candidate_ids) >= num_negatives * 4: break rng.shuffle(candidate_ids) negatives = [corpus[int(doc_idx)] for doc_idx in candidate_ids[:num_negatives]] sample["contexts"] = keep_gold_contexts_first(sample["contexts"] + negatives, sample["gold_doc_ids"], max_contexts) return samples def add_hard_negatives(samples, num_negatives, max_contexts, seed, method="bm25", embedding_model="", embedding_batch_size=64): if method == "bm25": return add_bm25_hard_negatives(samples, num_negatives, max_contexts, seed) if method == "embedding": return add_embedding_hard_negatives(samples, num_negatives, max_contexts, seed, embedding_model, embedding_batch_size) raise ValueError(f"Unsupported negative_mining method: {method}") def split_rows(rows, train_size, dev_size, test_size, seed): rng = random.Random(seed) rows = rows[:] rng.shuffle(rows) if train_size: train = rows[:train_size] rest = rows[train_size:] else: train, rest = rows, [] dev = rest[:dev_size] if dev_size else [] rest = rest[dev_size:] if dev_size else rest test = rest[:test_size] if test_size else [] return train, dev, test def load_sources(paths): rows = [] for path in paths: source_name = os.path.splitext(os.path.basename(path))[0] for idx, sample in enumerate(iter_jsonl(path)): row = normalize_sample(sample, source_name, idx) if row is not None: rows.append(row) return rows def main(): parser = argparse.ArgumentParser(description="Build SearchShortQA JSONL from public QA/RAG style datasets") parser.add_argument("--inputs", nargs="+", required=True, help="One or more local JSONL files") parser.add_argument("--output_dir", default="dataset/search_shortqa") parser.add_argument("--train_size", type=int, default=30000) parser.add_argument("--dev_size", type=int, default=1000) parser.add_argument("--test_size", type=int, default=1000) parser.add_argument("--max_contexts", type=int, default=6) parser.add_argument("--num_negatives", type=int, default=3) parser.add_argument("--negative_mining", choices=["bm25", "embedding"], default="bm25") parser.add_argument("--embedding_model", default="BAAI/bge-small-zh-v1.5") parser.add_argument("--embedding_batch_size", type=int, default=64) parser.add_argument("--seed", type=int, default=42) parser.add_argument("--limit", type=int, default=0) args = parser.parse_args() rows = load_sources(args.inputs) if args.limit: rows = rows[: args.limit] rows = add_hard_negatives( rows, args.num_negatives, args.max_contexts, args.seed, method=args.negative_mining, embedding_model=args.embedding_model, embedding_batch_size=args.embedding_batch_size, ) rows = [row for row in rows if row["question"] and row["answers"] and row["contexts"]] train, dev, test = split_rows(rows, args.train_size, args.dev_size, args.test_size, args.seed) os.makedirs(args.output_dir, exist_ok=True) write_jsonl(os.path.join(args.output_dir, "search_shortqa_train.jsonl"), train) write_jsonl(os.path.join(args.output_dir, "search_shortqa_dev.jsonl"), dev) write_jsonl(os.path.join(args.output_dir, "search_shortqa_test.jsonl"), test) manifest = { "inputs": args.inputs, "total_normalized": len(rows), "train": len(train), "dev": len(dev), "test": len(test), "max_contexts": args.max_contexts, "num_negatives": args.num_negatives, "negative_mining": args.negative_mining, "embedding_model": args.embedding_model if args.negative_mining == "embedding" else "", "seed": args.seed, "format": "SearchShortQA v1", } with open(os.path.join(args.output_dir, "manifest.json"), "w", encoding="utf-8") as f: json.dump(manifest, f, ensure_ascii=False, indent=2) print(json.dumps(manifest, ensure_ascii=False, indent=2)) if __name__ == "__main__": main()