#!/usr/bin/env python3 """Mine hard negatives using the local search API. For each (query, positive_chunk) training pair, queries the search API to find top-K retrieval results. Non-positive results become hard negatives — these are the actual mistakes the retrieval system makes in production. Usage: python mine_hard_negatives.py \ --input training/data/train.jsonl \ --output training/data/train_hn.jsonl \ --num-negatives 7 # With more candidates (slower but better negatives) python mine_hard_negatives.py \ --input training/data/train.jsonl \ --output training/data/train_hn.jsonl \ --num-negatives 7 --n-docs 50 """ import argparse import json import logging import sys import time import requests logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", stream=sys.stdout, ) logger = logging.getLogger(__name__) SEARCH_URL = "http://localhost:30888/search" def search_batch( queries: list[str], n_docs: int = 20, nprobe: int = 128 ) -> list[list[dict]]: """Query the search API with a batch of text queries.""" payload = { "queries": [{"text": q} for q in queries], "n_docs": n_docs, "nprobe": nprobe, } resp = requests.post(SEARCH_URL, json=payload, timeout=120) resp.raise_for_status() results = resp.json()["results"] return [r["hits"] for r in results] def mine_from_search( pairs: list[dict], num_negatives: int = 7, n_docs: int = 20, batch_size: int = 64, nprobe: int = 128, filter_mode: str = "none", margin: float | None = None, ) -> list[dict]: """Mine hard negatives by querying the search API. For each pair, the search API returns top-K results. Any result whose path differs from the positive chunk_path is a hard negative. Args: filter_mode: How to filter likely false negatives: - "article": Skip chunks from the same article (by article_id). Keeps hard negatives that score higher than positive. - "margin": Skip negatives where neg_score > pos_score * margin. Conservative, but may discard useful hard cases. - "skip_top1": Skip the #1 non-positive result (likely false negative), keep #2-#K. Compromise between article and margin. - "none": No filtering, keep all non-positive hits. margin: Only used when filter_mode="margin". Typical: 0.95-0.98. """ # Deduplicate queries for efficiency unique_queries = list(dict.fromkeys(p["query"] for p in pairs)) query_to_idx = {q: i for i, q in enumerate(unique_queries)} logger.info(f"{len(unique_queries)} unique queries (from {len(pairs)} pairs)") # Collect all positive paths per unique query query_positives = {} for pair in pairs: q = pair["query"] if q not in query_positives: query_positives[q] = set() query_positives[q].add(pair["chunk_path"]) # Query search API in batches all_hits = [None] * len(unique_queries) n_batches = (len(unique_queries) + batch_size - 1) // batch_size for i in range(0, len(unique_queries), batch_size): batch_queries = unique_queries[i : i + batch_size] batch_idx = i // batch_size + 1 try: batch_hits = search_batch(batch_queries, n_docs=n_docs, nprobe=nprobe) for j, hits in enumerate(batch_hits): all_hits[i + j] = hits except Exception as e: logger.warning(f"Batch {batch_idx}/{n_batches} failed: {e}") for j in range(len(batch_queries)): all_hits[i + j] = [] done = min(i + batch_size, len(unique_queries)) if batch_idx % 10 == 0 or done == len(unique_queries): logger.info(f" Searched: {done}/{len(unique_queries)}") # Build set of positive article_ids per query for false-negative filtering query_pos_articles = {} for q in unique_queries: pos_paths = query_positives[q] # Extract article_id from hits that match positive paths hits = all_hits[query_to_idx[q]] or [] article_ids = set() for hit in hits: if hit.get("path", "") in pos_paths: article_ids.add(hit.get("article_id")) query_pos_articles[q] = article_ids # Extract hard negatives per unique query query_negatives = {} query_metadata = {} stats = { "total": 0, "with_negs": 0, "avg_negs": 0, "avg_pos_rank": 0, "same_article_filtered": 0, "margin_filtered": 0, "skip_top1_filtered": 0, "harder_than_pos": 0, } pos_ranks = [] pos_rank_distribution = {str(i): 0 for i in range(1, n_docs + 1)} pos_rank_distribution[f">{n_docs}"] = 0 for q in unique_queries: positives = query_positives[q] pos_article_ids = query_pos_articles[q] hits = all_hits[query_to_idx[q]] or [] # Find positive score for stats pos_score = None for hit in hits: if hit.get("path", "") in positives: pos_score = hit["score"] break # Find negatives: exclude positives and same-article chunks (likely false negatives) neg_paths = [] pos_rank = None for rank, hit in enumerate(hits): hit_path = hit.get("path", "") if hit_path in positives: if pos_rank is None: pos_rank = rank else: # Filter likely false negatives if filter_mode == "article": if hit.get("article_id") in pos_article_ids: stats["same_article_filtered"] += 1 continue elif ( filter_mode == "margin" and margin is not None and pos_score is not None ): if hit["score"] > pos_score * margin: stats["margin_filtered"] += 1 continue elif filter_mode == "skip_top1": if len(neg_paths) == 0 and rank < 5: # Skip the first non-positive hit (likely false negative) stats["skip_top1_filtered"] += 1 continue # filter_mode == "none": no filtering if len(neg_paths) < num_negatives: neg_paths.append(hit_path) if pos_score is not None and hit["score"] >= pos_score: stats["harder_than_pos"] += 1 query_negatives[q] = neg_paths query_metadata[q] = { "retrieve_top20": [ { "rank": rank + 1, "path": hit.get("path", ""), "score": hit.get("score", 0.0), "article_id": hit.get("article_id"), } for rank, hit in enumerate(hits) ], "positive_score": pos_score if pos_score is not None else 0.0, "positive_rank": pos_rank + 1 if pos_rank is not None else 0, } stats["total"] += 1 if neg_paths: stats["with_negs"] += 1 if pos_rank is not None: pos_ranks.append(pos_rank) pos_rank_distribution[str(pos_rank + 1)] += 1 else: pos_rank_distribution[f">{n_docs}"] += 1 # Write output output_pairs = [] for pair in pairs: neg_paths = query_negatives.get(pair["query"], []) meta = query_metadata.get(pair["query"], {}) output_pairs.append( { **pair, "neg_chunk_paths": neg_paths, "retrieve_top20": meta.get("retrieve_top20", []), "positive_score": meta.get("positive_score", 0.0), "positive_rank": meta.get("positive_rank", 0), } ) # Stats total_queries = len(unique_queries) stats["avg_negs"] = ( sum(len(query_negatives[q]) for q in unique_queries) / total_queries ) if pos_ranks: stats["avg_pos_rank"] = sum(pos_ranks) / len(pos_ranks) stats["pos_found_rate"] = len(pos_ranks) / total_queries stats["pos_recall@1"] = sum(1 for r in pos_ranks if r == 0) / total_queries stats["pos_recall@10"] = sum(1 for r in pos_ranks if r < 10) / total_queries stats["pos_recall@20"] = sum(1 for r in pos_ranks if r < 20) / total_queries stats["pos_rank_distribution"] = pos_rank_distribution return output_pairs, stats def main(): parser = argparse.ArgumentParser() parser.add_argument( "--input", required=True, help="Input JSONL with {query, chunk_path}" ) parser.add_argument( "--output", required=True, help="Output JSONL with added neg_chunk_paths" ) parser.add_argument("--num-negatives", type=int, default=7) parser.add_argument( "--n-docs", type=int, default=20, help="Number of docs to retrieve per query from search API", ) parser.add_argument( "--filter-mode", choices=["article", "margin", "skip_top1", "none"], default="none", help="False-negative filter: 'article' (skip same article_id), " "'margin' (skip by score margin), 'none' (no filter)", ) parser.add_argument( "--margin", type=float, default=0.95, help="Margin threshold (only for --filter-mode margin). Typical: 0.95-0.98", ) parser.add_argument( "--batch-size", type=int, default=64, help="Batch size for search API queries" ) parser.add_argument( "--chunk-path-prefix", type=str, default="/opt/dlami/nvme/kiwix_tiles/", help="Prefix to prepend to relative chunk_path for matching search API results", ) parser.add_argument( "--nprobe", type=int, default=128, help="FAISS nprobe for search API" ) parser.add_argument( "--stats-output", type=str, default=None, help="Optional JSON path to write mining stats", ) args = parser.parse_args() # Check search API try: resp = requests.get("http://localhost:30888/health", timeout=5) resp.raise_for_status() logger.info("Search API is healthy") except Exception as e: logger.error(f"Search API not available: {e}") sys.exit(1) # Load data pairs = [] with open(args.input) as f: for line in f: pair = json.loads(line) # Normalize chunk_path to absolute path for matching search API results if args.chunk_path_prefix and not pair["chunk_path"].startswith("/"): pair["chunk_path"] = args.chunk_path_prefix + pair["chunk_path"] pairs.append(pair) logger.info(f"Loaded {len(pairs)} pairs") # Mine t0 = time.time() output_pairs, stats = mine_from_search( pairs, num_negatives=args.num_negatives, n_docs=args.n_docs, batch_size=args.batch_size, nprobe=args.nprobe, filter_mode=args.filter_mode, margin=args.margin, ) elapsed = time.time() - t0 # Write with open(args.output, "w") as f: for pair in output_pairs: f.write(json.dumps(pair) + "\n") n_with_negs = sum(1 for p in output_pairs if p["neg_chunk_paths"]) logger.info(f"Wrote {len(output_pairs)} pairs to {args.output}") logger.info( f" {n_with_negs} with negatives ({n_with_negs / len(output_pairs):.1%})" ) logger.info(f" Avg negatives per query: {stats['avg_negs']:.1f}") logger.info(f" Avg positive rank: {stats.get('avg_pos_rank', 'N/A')}") if "pos_recall@1" in stats: logger.info(f" Search API recall@1: {stats['pos_recall@1']:.3f}") logger.info(f" Search API recall@10: {stats['pos_recall@10']:.3f}") logger.info(f" Search API recall@20: {stats['pos_recall@20']:.3f}") if stats.get("same_article_filtered"): logger.info(f" Same-article filtered: {stats['same_article_filtered']}") if stats.get("margin_filtered"): logger.info(f" Margin-filtered: {stats['margin_filtered']}") if stats.get("skip_top1_filtered"): logger.info(f" Skip-top1 filtered: {stats['skip_top1_filtered']}") if stats.get("harder_than_pos"): logger.info(f" Negatives harder than positive: {stats['harder_than_pos']}") logger.info(f" Positive rank distribution: {stats['pos_rank_distribution']}") logger.info(f" Time: {elapsed:.0f}s") if args.stats_output: with open(args.stats_output, "w") as f: json.dump(stats, f, indent=2, sort_keys=True) logger.info(f"Wrote stats to {args.stats_output}") if __name__ == "__main__": main()