import os # Belt-and-suspenders against any accidental Hugging Face Hub network calls - # every model below is loaded from a local path, this just makes sure # transformers/sentence-transformers never try to phone home. os.environ.setdefault("HF_HUB_OFFLINE", "1") os.environ.setdefault("TRANSFORMERS_OFFLINE", "1") import re import pickle import datetime import numpy as np import faiss import torch from threading import Thread from sentence_transformers import SentenceTransformer, CrossEncoder from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer try: from dateutil import parser as _dateutil_parser _HAS_DATEUTIL = True except ImportError: _HAS_DATEUTIL = False # ================================================== # CONFIG # ================================================== INDEX_PATH = "hbl_vector_store/hbl_faiss.index" META_PATH = "hbl_vector_store/hbl_metadata.pkl" EMBED_MODEL = "./bge-m3" RERANKER_MODEL_PATH = "./bge-reranker-v2-m3" # local only, no HF downloads LLM_MODEL_PATH = "./qwen2.5-0.5b-instruct" # point this at your local download folder MIN_SCORE = 0.58 # bi-encoder recall gate (coarse - the cross-encoder does the real filtering) HISTORY_TURNS = 3 # number of past user/assistant exchanges kept for classifier context MAX_NEW_TOKENS = 512 # --- retrieval pipeline config ----------------------------------------- # Query -> BGE-M3 embedding -> FAISS top 30 -> cross-encoder rerank -> top 5 -> LLM CANDIDATE_K = 30 # chunks pulled from FAISS before cross-encoder reranking FINAL_K = 5 # chunks handed to the LLM after reranking CE_MIN_SCORE = 0.15 # sigmoid-normalized cross-encoder score floor; below this # a chunk is treated as "not actually relevant" (tune against # your own data - this is what now decides "no relevant docs found") RECENCY_BOOST_WEIGHT = 0.05 # default small tie-break nudge toward newer docs STRONG_RECENCY_BOOST_WEIGHT = 0.30 # applied for current/latest/management/designation queries # Field names to look for when pulling a document's date out of chunk # metadata. Add/reorder these to match your actual hbl_metadata.pkl schema. DATE_FIELD_CANDIDATES = [ "date", "doc_date", "document_date", "effective_date", "published_date", "publish_date", "last_updated", "updated_at", "version_date", "revision_date", ] # Field names used to identify "the same underlying document" across # versions (e.g. Tariff Schedule 2022 vs Tariff Schedule 2024). Adjust to # match whatever unique-ish identifier your crawler/chunker attaches. DOC_KEY_FIELD_CANDIDATES = [ "doc_id", "document_id", "parent_doc_id", "source_file", "filename", "file_name", "source", "url", "title", ] # Patterns stripped out when building a canonical doc key from a filename, # so e.g. "kfs_savings_account_2023.pdf" and "kfs_savings_account_v2.pdf" # collapse to the same key ("kfs savings account"). _VERSION_STRIP_PATTERNS = [ r'[_\-\s]v(?:er(?:sion)?)?\.?\s*\d+(\.\d+)?', r'\((?:revised|updated|new|old|draft|superseded|archive[d]?)\)', r'[_\-\s](?:revised|updated|superseded|archived)', r'(19|20)\d{2}[-_/](0[1-9]|1[0-2])[-_/](0[1-9]|[12]\d|3[01])', r'(19|20)\d{2}[-_/](0[1-9]|1[0-2])', r'(19|20)\d{2}', r'\.(pdf|html|htm|docx?)$', ] # Recurring HBL document series, matched against title/filename/url text. # When a chunk's metadata matches one of these, it's grouped by the series # name instead of the raw filename - this is what makes "Annual Report # 2025" correctly outrank "Annual Report 2023/2021" even if the filenames # don't share an obviously-strippable version suffix. DOCUMENT_SERIES_PATTERNS = [ (re.compile(r'annual report', re.I), 'series:annual_report'), (re.compile(r'\b(board of directors|board members)\b', re.I), 'series:board_of_directors'), (re.compile(r'\b(management (team|profile)|senior management|executive (team|management|committee))\b', re.I), 'series:management_profile'), (re.compile(r'\borganizational? (chart|structure)\b', re.I), 'series:org_structure'), (re.compile(r'\bcorporate profile\b', re.I), 'series:corporate_profile'), (re.compile(r'\bcode of conduct\b', re.I), 'series:code_of_conduct'), (re.compile(r'\bschedule of (charges|tariff)|tariff\b', re.I), 'series:tariff_schedule'), (re.compile(r'\bkey fact statement|\bkfs\b', re.I), 'series:kfs'), ] YEAR_PATTERN = re.compile(r'(? 0 gen_kwargs = dict( **inputs, max_new_tokens=max_new_tokens, do_sample=do_sample, pad_token_id=llm_tokenizer.eos_token_id, ) if do_sample: gen_kwargs["temperature"] = temperature gen_kwargs["top_p"] = 0.9 gen_kwargs["repetition_penalty"] = 1.1 if not stream: with torch.no_grad(): output_ids = llm_model.generate(**gen_kwargs) new_tokens = output_ids[0][inputs["input_ids"].shape[1]:] return llm_tokenizer.decode(new_tokens, skip_special_tokens=True) streamer = TextIteratorStreamer( llm_tokenizer, skip_prompt=True, skip_special_tokens=True, ) gen_kwargs["streamer"] = streamer thread = Thread(target=llm_model.generate, kwargs=gen_kwargs) thread.start() full_text = "" for token in streamer: print(token, end="", flush=True) full_text += token thread.join() print() return full_text # ================================================== # BANK ENTITY CHECK (word-boundary matching, not naive substring) # ================================================== HBL_TERMS = [ "hbl", "habib bank limited", "habib bank ltd", "habib bank", "habib islamic", ] OTHER_BANK_TERMS = [ "meezan bank", "meezan", "ubl", "united bank limited", "united bank", "mcb bank", "mcb", "bank alfalah", "faysal bank", "askari bank", "allied bank limited", "allied bank", "abl", "bank al habib", "al habib", "js bank", "silkbank", "standard chartered", "soneri bank", ] def _has_term(text, term): pattern = r"(? latest_date_by_key[key]: latest_date_by_key[key] = date survivors = [] for c in candidates: key, date = c["doc_key"], c["date"] if key is not None and key in latest_date_by_key: if date is not None and date < latest_date_by_key[key]: continue # older version of a doc we have a newer copy of if date is None: continue # undated chunk from a doc that has dated siblings survivors.append(c) known_dates = [c["date"] for c in survivors if c["date"] is not None] newest = max(known_dates) if known_dates else None oldest = min(known_dates) if known_dates else None span_days = (newest - oldest).days if (newest and oldest and newest != oldest) else 0 def final_score(c): if c["date"] is not None and span_days > 0: recency = (c["date"] - oldest).days / span_days elif c["date"] is not None: recency = 1.0 else: recency = -0.02 # tiny penalty for unknown date, not exclusion return c["score"] + weight * recency survivors.sort(key=final_score, reverse=True) return [c["meta"] for c in survivors[:FINAL_K]] def _rank_historical(candidates, requested_years): """Keep every version in play. If the user named a specific year, surface matching chunks first; otherwise rank by relevance across all versions.""" def year_match(c): if not requested_years or c["date"] is None: return False return c["date"].year in requested_years candidates.sort(key=lambda c: (year_match(c), c["score"]), reverse=True) return [c["meta"] for c in candidates[:FINAL_K]] # ================================================== # RETRIEVAL # Query -> BGE-M3 embedding -> FAISS top 30 -> cross-encoder rerank -> top 5 # ================================================== def retrieve(query): vector = embedder.encode([query], normalize_embeddings=True) vector = vector.astype("float32") scores, ids = index.search(vector, CANDIDATE_K) pool = [] for score, idx in zip(scores[0], ids[0]): if idx == -1: continue if score < MIN_SCORE: continue pool.append((float(score), chunks[idx])) if not pool: return [] # --- cross-encoder rerank ------------------------------------- pairs = [(query, meta["text"]) for _, meta in pool] raw_ce_scores = reranker.predict(pairs) ce_scores = 1 / (1 + np.exp(-np.asarray(raw_ce_scores, dtype=float))) # sigmoid -> 0..1 candidates = [] for (bi_score, meta), ce_score in zip(pool, ce_scores): if ce_score < CE_MIN_SCORE: continue # cross-encoder says this really isn't relevant candidates.append({ "meta": meta, "score": float(ce_score), "bi_score": bi_score, "doc_key": canonicalize_doc_key(meta), "date": get_doc_date(meta), }) if not candidates: return [] # --- recency-aware final selection ------------------------------ if is_historical_query(query): return _rank_historical(candidates, extract_years_from_query(query)) weight = STRONG_RECENCY_BOOST_WEIGHT if is_recency_sensitive_query(query) else RECENCY_BOOST_WEIGHT return _rank_latest_only(candidates, weight=weight) # ================================================== # GENERATION # ================================================== SYSTEM_PROMPT = ( "You are Habib Bank Limited Assistant. Answer only from retrieved HBL " "documents. Do not guess. If unavailable, say you could not find this " "information." ) def generate_answer(query, docs): context = "\n\n".join(d["text"] for d in docs) return local_chat( [ {"role": "system", "content": SYSTEM_PROMPT}, { "role": "user", "content": f""" HBL DOCUMENTS: {context} QUESTION: {query} """, }, ], temperature=0.1, stream=True, ) # ================================================== # CHAT # ================================================== print("\n" + "=" * 60) print("HBL Assistant Ready") print("Type exit to quit") print("=" * 60) history = [] # list of (role, text) tuples, most recent last while True: query = input("\nUser: ") if query.lower() == "exit": break if not query.strip(): continue # ---------------------------------------------- # ENTITY CHECK FIRST (cheap, catches obvious cases) # ---------------------------------------------- entity = detect_bank_entity(query) if entity == "OTHER_BANK": print("\nAssistant:\nI can only assist with Habib Bank Limited related queries.") continue if entity == "HBL": intent = "HBL_RELATED" elif is_person_or_designation_query(query): # "Who is the CTO?" / "Who is Jamal Nasir?" - no bank named, but # clearly a person/designation lookup. Let retrieval decide # relevance instead of risking a wrong block from the classifier. intent = "HBL_RELATED" else: intent = classify_query(query, history) print("Intent:", intent) if intent in HARD_BLOCK_CATEGORIES: print("\nAssistant:\nI can only assist with Habib Bank Limited related queries.") continue # ---------------------------------------------- # RETRIEVE (this is the real relevance gate) # ---------------------------------------------- docs = retrieve(query) if len(docs) == 0: print("\nAssistant:\nI could not find this information in the available HBL documents.") continue # ---------------------------------------------- # GENERATE # ---------------------------------------------- print("\nAssistant:") answer = generate_answer(query, docs) history.append(("user", query)) history.append(("assistant", answer)) history = history[-HISTORY_TURNS * 2:]