CoolFace
Apppublic

Anonymized1/Interacting-With-LLMs

sourceHugging Facecc-by-4.0updated 3d agoView on Hugging Face
0likes
create_rag_retriever.py686 linesDownload Raw Back to root
1import logging2import math3import sys4import os5import re6import numpy as np7import pandas as pd8from tqdm import tqdm9import ast10from collections import defaultdict11from datasets import load_dataset, Dataset12from langchain_community.vectorstores import FAISS13from langchain_community.embeddings import HuggingFaceEmbeddings14from langchain_text_splitters import RecursiveCharacterTextSplitter15# BM25 for lexical search16from rank_bm25 import BM25Okapi17import nltk18nltk.download('punkt', quiet=True)19nltk.download('punkt_tab', quiet=True)20from nltk.tokenize import word_tokenize21from langchain_core.documents import Document22 23logging.basicConfig(24    level=logging.INFO,25    format='%(asctime)s | %(levelname)s | %(message)s',26    datefmt='%H:%M:%S',27    handlers=[28        logging.StreamHandler(sys.stdout)29    ]30)31 32logger = logging.getLogger(__name__)33 34 35# Embedding model36EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2"37 38# Hybrid retrieval weights39SEMANTIC_WEIGHT = 0.440BM25_WEIGHT = 0.341ENTITY_WEIGHT = 0.342 43# Retrieval parameters44CHUNK_SIZE = 25645CHUNK_OVERLAP = 10046DEFAULT_K = 1047DEFAULT_FETCH_K = 1000048 49# Dataset settings50DATASET_USERS_NAME = "srirxml/PANORAMA-Plus"51DATASET_TEXTS_NAME = "srirxml/PANORAMA"52SPLIT = "train"53MAX_USERS = 100054MAX_TEXTS_PER_USER = None55 56# Column mappings57USER_ID_COL_USERS = "Unique ID"58USER_ID_COL_TEXTS = "id"59TEXT_COL = "text"60MIN_CHARS = 1061 62# Locale-to-location mapping63LOCALE_TO_LOCATION = {64    "en_PH": "Philippines",65    "en_CA": "Canada",66    "en_US": "United States",67    "en_IE": "Ireland",68    "en_NZ": "New Zealand",69    "en_IN": "India",70    "en_AU": "Australia",71    "en_GB": "United Kingdom",72    "en_IL": "Israel",73    "en_DE": "Germany",74    "en_IT": "Italy",75    "en_FR": "France",76}77 78# Sensitive attributes for privacy analysis79SENSITIVE_ATTRIBUTES = ["Age bin", "Gender", "Marital Status", "Finance Status", "Education", "Locale"]80ATTRIBUTE_VALUES_MAP = {81    "Gender": ["Female", "Male"],82    "Age bin": ["0-17", "18-29", "30-44", "45-59", "60+"],83    "Marital Status": ["Single", "Married", "Divorced", "Widowed"],84    "Finance Status": ["Low", "Medium", "High"],85    "Locale": [LOCALE_TO_LOCATION["en_PH"], LOCALE_TO_LOCATION["en_CA"], LOCALE_TO_LOCATION["en_US"],86               LOCALE_TO_LOCATION["en_IE"], LOCALE_TO_LOCATION["en_NZ"], LOCALE_TO_LOCATION["en_IN"],87               LOCALE_TO_LOCATION["en_AU"], LOCALE_TO_LOCATION["en_GB"], LOCALE_TO_LOCATION["en_IL"],88                LOCALE_TO_LOCATION["en_DE"], LOCALE_TO_LOCATION["en_IT"], LOCALE_TO_LOCATION["en_FR"]],89    "Education": ["High School", "Bachelor's", "Master's", "PhD"]90}91 92def strip_special_chars(text):93    """Strip digits and special characters, keeping only letters."""94    return re.sub(r'[^a-zA-Z]', '', text.lower())95 96 97def age_to_bin(age):98    if pd.isna(age):99        return pd.NA100    try:101        age = int(age)102        if age < 18:103            return "0-17"104        elif age < 30:105            return "18-29"106        elif age < 45:107            return "30-44"108        elif age < 60:109            return "45-59"110        else:111            return "60+"112    except:113        return pd.NA114 115 116def safe_parse_handles(x):117    """Parse social media handles from stringified dict."""118    if x is None or (isinstance(x, float) and pd.isna(x)):119        return {}120    s = str(x).strip()121    if not s or s.lower() == "nan":122        return {}123    try:124        v = ast.literal_eval(s)125        return v if isinstance(v, dict) else {}126    except Exception:127        return {}128 129 130def build_persona(row):131    """Build a persona string from user profile."""132    first = str(row.get("First Name", "") or "").strip()133    last = str(row.get("Last Name", "") or "").strip()134 135    handles_dict = safe_parse_handles(row.get("Social Media Handles"))136    handles = [str(v).strip() for v in handles_dict.values() if v and str(v).strip()]137 138    name_part = (first + " " + last).strip()139    handle_part = ", ".join(handles)140 141    if name_part and handle_part:142        return f"{name_part}; {handle_part}"143    elif name_part:144        return name_part145    elif handle_part:146        return handle_part147    else:148        return str(row.get(USER_ID_COL_USERS, "")).strip()149 150 151def load_panorama_data():152    """Load and merge PANORAMA datasets."""153    print("\n" + "=" * 80)154    print("LOADING DATA")155    print("=" * 80)156 157    ds_users = load_dataset(DATASET_USERS_NAME, split=SPLIT)158    ds_texts = load_dataset(DATASET_TEXTS_NAME, split=SPLIT)159 160    users_df = ds_users.to_pandas()161    texts_df = ds_texts.to_pandas()162 163    # Select first N users164    first_user_ids = (165        users_df[USER_ID_COL_USERS]166        .dropna()167        .astype(str)168        .drop_duplicates()169        .head(MAX_USERS)170        .tolist()171    )172 173    users_df_small = users_df[users_df[USER_ID_COL_USERS].astype(str).isin(first_user_ids)].copy()174    texts_df_small = texts_df[texts_df[USER_ID_COL_TEXTS].astype(str).isin(first_user_ids)].copy()175 176    # Clean texts177    texts_df_small[TEXT_COL] = texts_df_small[TEXT_COL].astype(str).str.strip()178    texts_df_small = texts_df_small[texts_df_small[TEXT_COL].str.len() >= MIN_CHARS].copy()179    texts_df_small = texts_df_small.drop_duplicates(subset=[USER_ID_COL_TEXTS, TEXT_COL]).copy()180 181    # Optional: cap texts per user182    if MAX_TEXTS_PER_USER is not None:183        texts_df_small = (184            texts_df_small185            .groupby(USER_ID_COL_TEXTS, as_index=False, sort=False)186            .head(int(MAX_TEXTS_PER_USER))187            .copy()188        )189 190    users_df_small["Age bin"] = users_df_small["Age"].apply(age_to_bin)191    users_df_small["Finance Status"] = users_df_small["Finance Status"].apply(192        lambda x: "High" if "high" in x.lower() else (193            "Medium" if "medium" in x.lower() else ("Low" if "low" in x.lower() else pd.NA))194    )195    users_df_small["Education"] = users_df_small["Education Info"].apply(196        lambda x: "Bachelor's" if x in ["Bachelor's", "Some College", "Diploma", "Associate's",197                                        "Professional Certificate", "Vocational Training"]198        else ("High School" if x in ["Less than High School", "High School"] else x)199    )200    users_df_small["Locale"] = users_df_small["Locale"].apply(201        lambda x: LOCALE_TO_LOCATION[x] if x in LOCALE_TO_LOCATION else x)202 203    # Merge204    merged = users_df_small.merge(205        texts_df_small,206        left_on=USER_ID_COL_USERS,207        right_on=USER_ID_COL_TEXTS,208        how="left",209    )210 211    print(f"✓ Users selected: {len(first_user_ids)}")212    print(f"✓ Texts after per-user dedup: {len(texts_df_small)}")213    print(f"✓ Merged rows (users x texts): {len(merged)}")214 215    return merged, users_df_small216 217 218class HybridRetriever:219    """220    Hybrid retriever combining semantic (FAISS), lexical (BM25), and221    entity-based search with Reciprocal Rank Fusion (RRF).222 223    The ``retrieve`` method accepts an optional ``min_similarity``224    threshold.  When set, only documents whose normalised semantic225    similarity to the query meets or exceeds that value are eligible for226    retrieval.  This makes the system sensitive to Input-DP perturbation:227    a heavily noised query will drift away from the persona corpus and228    return fewer — or zero — documents, so the system's output correctly229    reflects the privacy protection that is active.230    """231 232    def __init__(233            self,234            documents,235            embedding_model=EMBEDDING_MODEL,236            semantic_weight=SEMANTIC_WEIGHT,237            bm25_weight=BM25_WEIGHT,238            entity_weight=ENTITY_WEIGHT,239            chunk_size=CHUNK_SIZE,240            chunk_overlap=CHUNK_OVERLAP,241    ):242        self.semantic_weight = semantic_weight243        self.bm25_weight = bm25_weight244        self.entity_weight = entity_weight245 246        # Text splitter247        self.splitter = RecursiveCharacterTextSplitter(248            chunk_size=chunk_size,249            chunk_overlap=chunk_overlap,250            length_function=len,251        )252 253        print("Building retriever components...")254 255        # Build FAISS index for semantic search256        self.embeddings = HuggingFaceEmbeddings(model_name=embedding_model)257        self.vectorstore = FAISS.from_documents(documents, self.embeddings)258        print(f"  ✓ FAISS index built ({len(documents)} docs)")259 260        # Build BM25 index for lexical search261        self.bm25_corpus = [doc.page_content for doc in documents]262        tokenized_corpus = [word_tokenize(doc.lower()) for doc in self.bm25_corpus]263        self.bm25 = BM25Okapi(tokenized_corpus)264        print(f"  ✓ BM25 index built")265 266        # Store documents for entity matching267        self.documents = documents268 269        # Extract entities (usernames, identifiers) from metadata270        # Strip digits and special characters for better matching271        self.entity_index = defaultdict(list)272        for i, doc in tqdm(enumerate(documents)):273            persona = doc.metadata.get("persona", "")274            if persona:275                # Extract potential identifiers and strip special chars276                tokens = re.split(r'[;,\s]+', persona.lower())277                for token in tokens:278                    # Strip special characters and digits279                    clean_token = strip_special_chars(token)280                    if len(clean_token) > 2:  # Skip very short tokens281                        self.entity_index[clean_token].append(i)282        print(f"  ✓ Entity index built ({len(self.entity_index)} unique entities)")283 284        # ── Content-to-corpus-index lookup (used by threshold filtering) ──────285        # Maps page_content → list of corpus indices so that FAISS results286        # (which are Document objects, not indices) can be mapped back to287        # their position in self.documents.  Duplicate page_content values288        # are handled by storing all matching indices.289        self._content_to_indices = defaultdict(list)290        for i, doc in enumerate(self.documents):291            self._content_to_indices[doc.page_content].append(i)292 293    def _reciprocal_rank_fusion(self, rankings, k=60):294        """Combine multiple rankings using RRF."""295        scores = defaultdict(float)296        sources = defaultdict(dict)297 298        for source_name, ranking in rankings.items():299            for rank, doc_id in enumerate(ranking, start=1):300                scores[doc_id] += 1.0 / (k + rank)301                sources[doc_id][source_name] = rank302 303        return scores, sources304 305    def retrieve_semantic_only(self, query, k=10):306        """Retrieve using only semantic search (for comparison)."""307        return self.vectorstore.similarity_search(query, k=k)308 309    def retrieve(self, query, k=10, fetch_k=100, min_similarity=None):310        """Hybrid retrieval combining semantic, BM25, and entity matching.311 312        Parameters313        ----------314        query : str315            The search query (may be DP-perturbed).316        k : int317            Maximum number of documents to return.318        fetch_k : int319            Candidate pool size passed to FAISS.320        min_similarity : float or None321            When set, only documents whose normalised semantic similarity322            to the query meets or exceeds this value are eligible.323            Documents below the threshold are excluded from ALL three324            ranking components (semantic, BM25, entity) before RRF.325            Pass None (default) to disable filtering and preserve the326            original behaviour.327 328        Returns329        -------330        list[Document]331            Up to k documents, potentially fewer (or empty) when332            min_similarity is strict relative to the query.333        """334        # ── 1. Semantic search WITH scores ───────────────────────────────────335        try:336            candidates = self.vectorstore.similarity_search_with_score(337                query, k=fetch_k338            )339        except Exception:340            # Fallback: vectorstore does not expose scores → no threshold341            docs = self.vectorstore.similarity_search(query, k=fetch_k)342            candidates = [(doc, 0.0) for doc in docs]343 344        if not candidates:345            return []346 347        raw_docs, raw_scores = zip(*candidates)348        raw_scores = list(raw_scores)349 350        # ── Normalise scores to similarity ∈ [0, 1] ─────────────────────────351        # FAISS/IndexFlatL2 returns L2 distances (ascending: closer = smaller).352        # IndexFlatIP on normalised vectors returns cosine scores (descending).353        if len(raw_scores) >= 2 and raw_scores[0] <= raw_scores[-1]:354            # L2 distances: invert so that higher = more similar355            max_dist = max(raw_scores) + 1e-10356            similarities = [1.0 - s / max_dist for s in raw_scores]357        else:358            # Already similarity scores; clamp to [0, 1]359            similarities = [max(0.0, min(1.0, s)) for s in raw_scores]360 361        # ── Apply similarity threshold ────────────────────────────────────────362        if min_similarity is not None:363            passing_pairs = [364                (doc, sim)365                for doc, sim in zip(raw_docs, similarities)366                if sim >= min_similarity367            ]368            if not passing_pairs:369                logger.info(370                    "  HybridRetriever: 0/%d candidates above threshold %.3f"371                    " — returning []",372                    len(raw_docs), min_similarity,373                )374                return []375 376            # Build the set of *corpus* indices that passed the threshold.377            # This is used to restrict BM25 and entity rankings to the378            # same document subset, ensuring all three components only379            # vote for threshold-passing documents.380            valid_corpus_indices = set()381            for doc, _ in passing_pairs:382                for idx in self._content_to_indices.get(doc.page_content, []):383                    valid_corpus_indices.add(idx)384 385            # Semantic ranking: use actual corpus indices for filtered docs386            # so they are consistent with BM25/entity namespace.387            semantic_ranking = []388            for doc, _ in passing_pairs:389                for idx in self._content_to_indices.get(doc.page_content, []):390                    semantic_ranking.append(idx)391                    break  # one representative index per doc is enough for RRF392        else:393            # No threshold: preserve original behaviour (range-based indices).394            valid_corpus_indices = None395            semantic_ranking = list(range(len(raw_docs)))396 397        # ── 2. BM25 search ───────────────────────────────────────────────────398        query_tokens = word_tokenize(query.lower())399        bm25_scores = self.bm25.get_scores(query_tokens)400        bm25_ranking = np.argsort(bm25_scores)[::-1][:fetch_k].tolist()401        if valid_corpus_indices is not None:402            bm25_ranking = [i for i in bm25_ranking if i in valid_corpus_indices]403 404        # ── 3. Entity matching ───────────────────────────────────────────────405        query_lower = query.lower()406        entity_matches = set()407        for query_token in re.split(r'[;,\s.!?]+', query_lower):408            clean_query_token = strip_special_chars(query_token)409            if len(clean_query_token) > 2:410                for entity, doc_ids in self.entity_index.items():411                    if clean_query_token in entity or entity in clean_query_token:412                        entity_matches.update(doc_ids)413        entity_ranking = list(entity_matches)[:fetch_k]414        if valid_corpus_indices is not None:415            entity_ranking = [i for i in entity_ranking if i in valid_corpus_indices]416 417        # ── 4. Reciprocal Rank Fusion ─────────────────────────────────────────418        weighted_rankings = {}419        if self.semantic_weight > 0:420            weighted_rankings["semantic"] = semantic_ranking421        if self.bm25_weight > 0:422            weighted_rankings["bm25"] = bm25_ranking423        if self.entity_weight > 0 and entity_ranking:424            weighted_rankings["entity"] = entity_ranking425 426        scores, sources = self._reciprocal_rank_fusion(weighted_rankings)427        sorted_doc_ids = sorted(428            scores.keys(), key=lambda x: scores[x], reverse=True429        )[:k]430 431        result = [self.documents[i] for i in sorted_doc_ids]432        logger.info(433            "  HybridRetriever: returning %d/%d docs (min_similarity=%s)",434            len(result), len(candidates),435            f"{min_similarity:.3f}" if min_similarity is not None else "None",436        )437        return result438 439 440# ==============================================================================441# DIFFERENTIAL PRIVACY RETRIEVER442# ==============================================================================443 444class DPRetriever:445    """Differentially private retriever using the Exponential Mechanism.446 447    Sensitivity is computed *empirically* from the actual utility range of448    the candidate pool rather than a fixed constant.  This data-adaptive449    approach (Dwork & Roth 2014; Koga et al. 2024) gives a tighter bound,450    improving the privacy-utility trade-off without weakening the formal451    ε-DP guarantee.452 453    For the exponential mechanism the sensitivity Δu of utility function u is:454        Δu = max_{d, d'} |u(d, q) − u(d', q)|455    When u(d, q) = max_dist − dist(d, q) (distance-to-similarity inversion),456    Δu equals the range of utilities across the candidate pool.  We clamp it457    to ``sensitivity_cap`` to guard against degenerate cases where all458    candidates are equidistant from the query.459 460    Parameters461    ----------462    base_retriever : HybridRetriever463        The underlying retriever whose vectorstore provides FAISS scores.464    epsilon : float465        DP privacy budget.  Smaller ε → stronger privacy.466    sensitivity_cap : float467        Floor value for the empirical sensitivity (default 1e-3).  For468        most corpora the empirical range will be far larger than this cap469        so it has no practical effect.470    """471 472    def __init__(self, base_retriever, epsilon=1.0, sensitivity_cap=1e-3):473        self.base_retriever = base_retriever474        self.epsilon = epsilon475        self.sensitivity_cap = sensitivity_cap476 477    def retrieve(self, query, k=10, fetch_k=100, seed=None):478        """Retrieve up to k documents with differential privacy.479 480        Uses the Exponential Mechanism: each candidate document is sampled481        with probability proportional to exp(ε · u(d) / 2Δu), where u is482        the normalised similarity utility and Δu is the empirical range.483 484        Parameters485        ----------486        query : str487            Search query (may be DP-perturbed).488        k : int489            Number of documents to return.490        fetch_k : int491            Candidate pool size fetched from FAISS before sampling.492        seed : int or None493            Optional random seed for reproducibility.494 495        Returns496        -------497        list[Document]498            k documents sampled with DP-weighted probabilities.499        """500        if seed is not None:501            np.random.seed(seed)502 503        # Fetch candidate pool504        try:505            candidates = self.base_retriever.vectorstore.similarity_search_with_score(506                query, k=fetch_k507            )508        except Exception:509            # Fallback: vectorstore does not expose scores510            docs = self.base_retriever.vectorstore.similarity_search(query, k=fetch_k)511            return docs[:k]512 513        if not candidates:514            return []515 516        docs, base_scores = zip(*candidates)517        base_scores = np.array(base_scores)518 519        # ── Convert raw scores to a utility where higher = better ────────────520        # FAISS with L2: scores are distances (ascending) → invert521        # FAISS with IP: scores are similarities (descending) → use as-is522        if base_scores[0] >= base_scores[-1]:523            utilities = base_scores.copy()          # already similarity scores524        else:525            max_dist = base_scores.max() + 1e-10526            utilities = max_dist - base_scores       # invert L2 distances527 528        # ── Empirical sensitivity (Dwork & Roth 2014; Koga et al. 2024) ─────529        u_range = float(utilities.max() - utilities.min())530        sensitivity = max(u_range, self.sensitivity_cap)531 532        # ── Exponential mechanism: P(d) ∝ exp(ε · u(d) / 2Δu) ──────────────533        probabilities = np.exp((self.epsilon * utilities) / (2.0 * sensitivity))534        probabilities = probabilities / probabilities.sum()535 536        # Sample k documents without replacement537        selected_indices = np.random.choice(538            len(docs), size=min(k, len(docs)), replace=False, p=probabilities539        )540        return [docs[i] for i in selected_indices]541 542 543def save_retriever_components(retriever, save_path):544    """545    Save retriever components (documents + config) instead of entire object.546    Avoids FAISS serialization issues.547    """548    import pickle549    import os550 551    logger.info(f"💾 Saving retriever components to {save_path}...")552 553    # Extract all components needed to rebuild the retriever554    components = {555        'documents': retriever.documents,556        'config': {557            'embedding_model': retriever.embeddings.model_name,558            'semantic_weight': retriever.semantic_weight,559            'bm25_weight': retriever.bm25_weight,560            'entity_weight': retriever.entity_weight,561            'chunk_size': retriever.splitter._chunk_size,562            'chunk_overlap': retriever.splitter._chunk_overlap,563        },564        'metadata': {565            'num_documents': len(retriever.documents),566            'num_entities': len(retriever.entity_index),567        }568    }569 570    # Save to pickle571    os.makedirs(os.path.dirname(save_path) if os.path.dirname(save_path) else '.', exist_ok=True)572 573    with open(save_path, 'wb') as f:574        pickle.dump(components, f, protocol=pickle.HIGHEST_PROTOCOL)575 576    logger.info(f"  ✓ Saved {len(components['documents'])} documents")577    logger.info(f"✅ Retriever components saved to {save_path}")578 579 580def load_retriever_components(load_path):581    """582    Load components and rebuild HybridRetriever from scratch.583    Rebuilds FAISS, BM25, and entity indices in current environment.584    """585    import pickle586 587    logger.info(f"📁 Loading retriever components from {load_path}...")588 589    # Load components590    with open(load_path, 'rb') as f:591        components = pickle.load(f)592 593    documents = components['documents']594    config = components['config']595 596    logger.info(f"  ✓ Loaded {len(documents)} documents")597    logger.info(f"  ✓ Config: semantic_weight={config['semantic_weight']}, "598                f"bm25_weight={config['bm25_weight']}, entity_weight={config['entity_weight']}")599 600    # Rebuild the retriever from scratch using current environment's packages601    logger.info("🔨 Rebuilding retriever indices (this takes ~30-60 seconds)...")602 603    retriever = HybridRetriever(604        documents=documents,605        embedding_model=config['embedding_model'],606        semantic_weight=config['semantic_weight'],607        bm25_weight=config['bm25_weight'],608        entity_weight=config['entity_weight'],609        chunk_size=config['chunk_size'],610        chunk_overlap=config['chunk_overlap'],611    )612 613    logger.info("✅ Retriever rebuilt successfully")614    return retriever615 616 617def build_documents(merged, users_df):618    """Build documents and metadata for RAG."""619    print("\n" + "=" * 80)620    print("BUILDING DOCUMENTS")621    print("=" * 80)622 623    texts = []624    metas = []625 626    user_meta_cols = [c for c in users_df.columns if c != USER_ID_COL_USERS]627 628    for _, row in tqdm(merged.iterrows(), total=len(merged), desc="Building documents"):629        t = row.get(TEXT_COL, "")630        if not isinstance(t, str):631            continue632        t = t.strip()633        if len(t) < MIN_CHARS:634            continue635 636        persona = build_persona(row)637 638        texts.append(t)639 640        meta = {c: row.get(c) for c in user_meta_cols}641        meta["user_id"] = str(row.get(USER_ID_COL_USERS))642        meta["persona"] = persona643 644        if "source" in merged.columns:645            meta["source"] = row.get("source")646        if "content_type" in merged.columns:647            meta["content_type"] = row.get("content_type")648 649        metas.append(meta)650 651    print(f"✓ Documents created: {len(texts)}")652    return texts, metas653 654 655if __name__ == "__main__":656    retriever = load_retriever_components("./faiss_panorama_retriever_components.pkl")657    save_retriever_components(retriever, "./faiss_panorama_retriever_components.pkl")658 659    RETRIEVER_SAVE_PATH = r"C:\Users\user\Datasets\Panorama Synthetic Data RAG\faiss_panorama_retriever_components.pkl"660    if os.path.exists(RETRIEVER_SAVE_PATH):661        retriever = load_retriever_components(RETRIEVER_SAVE_PATH)662        print(retriever.retrieve("Hi, I am Raymond Phillips", 10)[0:5])663    else:664        merged, users_df = load_panorama_data()665 666        # -------------------------------------------------------------------------667        # STEP 2: Build Documents668        # -------------------------------------------------------------------------669        texts, metas = build_documents(merged, users_df)670 671        # Create Document objects672        documents = [673            Document(page_content=text, metadata=meta)674            for text, meta in zip(texts, metas)675        ]676        # Saving (in your local environment):677        retriever = HybridRetriever(678            documents,679            embedding_model=EMBEDDING_MODEL,680            semantic_weight=SEMANTIC_WEIGHT,681            bm25_weight=BM25_WEIGHT,682            entity_weight=ENTITY_WEIGHT,683            chunk_size=CHUNK_SIZE,684            chunk_overlap=CHUNK_OVERLAP,685        )686        save_retriever_components(retriever, RETRIEVER_SAVE_PATH)