Files
wehub-resource-sync 542cfa195c
CI / Frontend build (push) Failing after 9m6s
CI / Plugin validate (push) Failing after 9m27s
CI / Python lint (push) Failing after 16m1s
CI / Tests (push) Successful in 18m0s
Deploy / deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:27 +08:00

358 lines
13 KiB
Python

#!/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()