Cognitive-rag / 03_embedding / embed_chunks.py
embed_chunks.py
Raw
#!/usr/bin/env python
"""Embed chunked MTSamples content with NVIDIA NIM embeddings."""

from __future__ import annotations

import argparse
import json
import os
from datetime import datetime, timezone
from typing import Dict, Iterator, List

import numpy as np

def iter_jsonl(path: str) -> Iterator[Dict]:
    """Iterate over JSONL records from a file."""
    with open(path, "r", encoding="utf-8") as handle:
        for line in handle:
            line = line.strip()
            if line:
                yield json.loads(line)


def load_client(model_name: str, api_key: str, truncate: str):
    try:
        from langchain_nvidia_ai_endpoints import NVIDIAEmbeddings
    except Exception as exc:  # pragma: no cover - import guard for missing deps
        raise SystemExit(
            "Failed to import langchain_nvidia_ai_endpoints. "
            "Verify the env and run `python -c \"from langchain_nvidia_ai_endpoints import NVIDIAEmbeddings\"`.\n"
            f"Import error: {exc}"
        ) from exc

    return NVIDIAEmbeddings(model=model_name, api_key=api_key, truncate=truncate)


def normalize_embeddings(embeddings: np.ndarray) -> np.ndarray:
    norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
    return embeddings / np.clip(norms, a_min=1e-12, a_max=None)


def embed_chunks(
    input_path: str,
    output_dir: str,
    model_name: str,
    api_key: str,
    truncate: str,
    batch_size: int,
    normalize: bool,
) -> Dict:
    os.makedirs(output_dir, exist_ok=True)
    output_path = os.path.join(output_dir, "embeddings_mtsamples.jsonl")

    client = load_client(model_name, api_key, truncate)

    total_records = 0
    total_embedded = 0
    skipped = 0

    batch_records: List[Dict] = []
    batch_texts: List[str] = []

    def flush_batch(out_handle) -> None:
        nonlocal total_embedded
        if not batch_records:
            return

        embeddings = client.embed_documents(batch_texts)
        embeddings = np.asarray(embeddings, dtype=float)
        if normalize:
            embeddings = normalize_embeddings(embeddings)
        if embeddings.shape[0] != len(batch_records):
            raise RuntimeError(
                f"Embedding count mismatch: {embeddings.shape[0]} vs {len(batch_records)}"
            )

        for record, embedding in zip(batch_records, embeddings):
            out_record = dict(record)
            out_record["embedding"] = [float(x) for x in embedding.tolist()]
            out_handle.write(json.dumps(out_record, ensure_ascii=True) + "\n")
            total_embedded += 1

        batch_records.clear()
        batch_texts.clear()

    with open(output_path, "w", encoding="utf-8") as out_handle:
        for record in iter_jsonl(input_path):
            total_records += 1
            content = record.get("content", "")
            if not content or not content.strip():
                skipped += 1
                continue

            batch_records.append(record)
            batch_texts.append(content)

            if len(batch_records) >= batch_size:
                flush_batch(out_handle)

        flush_batch(out_handle)

    stats = {
        "input_file": os.path.abspath(input_path),
        "output_file": os.path.abspath(output_path),
        "generated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
        "model": model_name,
        "truncate": truncate,
        "batch_size": batch_size,
        "normalize": normalize,
        "counts": {
            "input_records": total_records,
            "embedded_records": total_embedded,
            "skipped_records": skipped,
        },
    }

    stats_path = os.path.join(output_dir, "embedding_stats.json")
    with open(stats_path, "w", encoding="utf-8") as stats_handle:
        json.dump(stats, stats_handle, indent=2, ensure_ascii=True)

    return stats


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Embed chunked MTSamples JSONL with NVIDIA NIM embeddings."
    )
    base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
    parser.add_argument(
        "--input",
        default=os.path.join(base_dir, "02_chunking", "output", "chunked_mtsamples.jsonl"),
        help="Path to chunked JSONL input file.",
    )
    parser.add_argument(
        "--output-dir",
        default="output",
        help="Output directory for embeddings JSONL and stats.",
    )
    parser.add_argument(
        "--model",
        default="nvidia/llama-3.2-nv-embedqa-1b-v2",
        help="NVIDIA embedding model name.",
    )
    parser.add_argument(
        "--api-key",
        default=os.getenv("NVIDIA_API_KEY"),
        help="NVIDIA API key (or set NVIDIA_API_KEY).",
    )
    parser.add_argument(
        "--truncate",
        default="NONE",
        help="Truncation mode for the NVIDIA endpoint (e.g., NONE, START, END).",
    )
    parser.add_argument(
        "--batch-size",
        type=int,
        default=32,
        help="Batch size for embedding inference.",
    )
    parser.add_argument(
        "--normalize",
        action="store_true",
        help="Normalize embeddings for cosine similarity.",
    )
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    if not args.api_key:
        raise SystemExit(
            "Missing NVIDIA API key. Set NVIDIA_API_KEY or pass --api-key."
        )
    stats = embed_chunks(
        input_path=args.input,
        output_dir=args.output_dir,
        model_name=args.model,
        api_key=args.api_key,
        truncate=args.truncate,
        batch_size=args.batch_size,
        normalize=args.normalize,
    )

    print("Embedding complete!")
    print(f"  Input records:   {stats['counts']['input_records']}")
    print(f"  Embedded:        {stats['counts']['embedded_records']}")
    print(f"  Skipped:         {stats['counts']['skipped_records']}")
    print(f"  Output: {stats['output_file']}")


if __name__ == "__main__":
    main()