Anonymized1/Interacting-With-LLMs
0
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)