grazz7 commited on
Commit
d3ab573
·
1 Parent(s): 3905c35

added vectordb

Browse files
.gitignore CHANGED
@@ -3,5 +3,5 @@ __pycache__/
3
  .venv/
4
 
5
  models/
6
- chroma/
7
  *.sqlite3
 
3
  .venv/
4
 
5
  models/
6
+
7
  *.sqlite3
chroma/__init__.py ADDED
File without changes
chroma/chatbot_chroma_db.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import chromadb
2
+ from sklearn.cluster import KMeans
3
+ import pandas as pd
4
+ import json
5
+ import ast
6
+ from itertools import groupby
7
+ import uuid
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+
11
+ root_dir = Path(__file__).parent.parent
12
+ data_dir = root_dir / "datasets"
13
+
14
+ embed_faq_df = pd.read_csv(data_dir / "processed" / "bank_faq" / "embed_faq.csv")
15
+
16
+ # chroma db
17
+ client = chromadb.PersistentClient(path="./chroma_db")
18
+
19
+ # client.delete_collection("bank_faq")
20
+ # client.delete_collection("chat_history")
21
+
22
+ # create collection
23
+ collection = client.get_or_create_collection(
24
+ name="bank_faq",
25
+ metadata={"hnsw:space": "cosine"} # cosine similarity for sentence transformers
26
+ )
27
+
28
+ embed_faq_df["embedding"] = embed_faq_df["embedding"].apply(ast.literal_eval)
29
+ # prepare data
30
+ # ids = [str(i) for i in range(len(embed_faq_df))]
31
+ ids = [
32
+ f"doc_{row['source_id']}_chunk_{row['chunk_num']}"
33
+ for _, row in embed_faq_df.iterrows()
34
+ ]
35
+ embeddings = embed_faq_df["embedding"].tolist()
36
+ documents = embed_faq_df["text"].tolist()
37
+ metadatas = [
38
+ {
39
+ "doc_id": f"doc_{row['source_id']}_chunk_{row['chunk_num']}",
40
+ "domain": row["domain"],
41
+ "source_id": row["source_id"],
42
+ "chunk_num": row["chunk_num"],
43
+ "question": row["question"]
44
+ }
45
+ for _, row in embed_faq_df.iterrows()
46
+ ]
47
+
48
+ # print(ids)
49
+ # print(type(embeddings[0]))
50
+ # print(documents)
51
+ # print(metadatas)
52
+
53
+ # store
54
+ collection.add(
55
+ ids=ids,
56
+ embeddings=embeddings,
57
+ documents=documents,
58
+ metadatas=metadatas
59
+ )
60
+
61
+ print(f"Stored {collection.count()} documents")
62
+
63
+ faq_collection = client.get_collection(name="bank_faq")
64
+ results = faq_collection.get(
65
+ include=["documents", "metadatas"]
66
+ )
67
+
68
+ source_id_to_doc_ids = {}
69
+ for doc_id, meta in zip(ids, metadatas):
70
+ source_id = meta["source_id"]
71
+ if source_id not in source_id_to_doc_ids:
72
+ source_id_to_doc_ids[source_id] = []
73
+ source_id_to_doc_ids[source_id].append(doc_id)
74
+
75
+ # build a dict grouped by source_id
76
+ groups = {}
77
+ for doc_id, meta, text in zip(results["ids"], results["metadatas"], results["documents"]):
78
+ source_id = meta["source_id"]
79
+ if source_id not in groups:
80
+ groups[source_id] = []
81
+ groups[source_id].append({
82
+ "doc_id": doc_id,
83
+ "chunk_num": int(meta["chunk_num"]),
84
+ "domain": meta["domain"],
85
+ "question": meta["question"],
86
+ "text": text.split("A:", 1)[1].strip() if "A:" in text else text,
87
+ })
88
+
89
+ # build viewable
90
+ viewable = []
91
+ for source_id, chunks in groups.items():
92
+ chunks = sorted(chunks, key=lambda x: x["chunk_num"]) # sort by chunk order
93
+
94
+ entry = {
95
+ "id": source_id,
96
+ "domain": chunks[0]["domain"],
97
+ "question": chunks[0]["question"],
98
+ }
99
+
100
+ if len(chunks) == 1:
101
+ entry["answer"] = chunks[0]["text"]
102
+ else:
103
+ entry["answer"] = {"chunks": [{"chunk": c["chunk_num"] + 1, "doc_id": c["doc_id"], "text": c["text"]} for c in chunks]}
104
+
105
+ viewable.append(entry)
106
+
107
+
108
+ with open("chroma_preview.json", "w") as f:
109
+ json.dump(viewable, f, indent=2)
110
+
111
+ print("Saved to chroma_preview.json")
112
+
113
+ with open("chroma_preview.json", "r") as f: # or test.json / val.json
114
+ faq_db = json.load(f)
115
+
116
+ for item in faq_db:
117
+ source_id = item["id"]
118
+ item["relevant_doc_ids"] = source_id_to_doc_ids.get(source_id, [])
119
+
120
+ with open("chroma_preview.json", "w") as f:
121
+ json.dump(faq_db, f, indent=2)
122
+
123
+ # save chats in db
124
+ chat_collection = client.get_or_create_collection(name="chat_history")
125
+
126
+ def create_new_chat():
127
+ # creates a new chat session and returns its ID
128
+ chat_id = str(uuid.uuid4())
129
+ print(f"New chat created: {chat_id}")
130
+ return chat_id
131
+
132
+ def get_chat_history(chat_id):
133
+ """Retrieve full history of a specific chat."""
134
+ results = chat_collection.get(
135
+ where={"chat_id": chat_id}
136
+ )
137
+
138
+ # Pair up queries and answers
139
+ history = []
140
+ for doc, meta in zip(results["documents"], results["metadatas"]):
141
+ history.append({
142
+ "query": doc,
143
+ "answer": meta["answer"],
144
+ "timestamp": meta["timestamp"]
145
+ })
146
+
147
+ # Sort by timestamp
148
+ history.sort(key=lambda x: x["timestamp"])
149
+ return history
150
+
151
+ def load_chats_from_db():
152
+ # rebuild the chats dict from ChromaDB on page load
153
+ try:
154
+ results = chat_collection.get(include=["metadatas", "documents"])
155
+ except Exception:
156
+ return {}
157
+
158
+ chats = {}
159
+ for doc, meta in zip(results["documents"], results["metadatas"]):
160
+ chat_id = meta.get("chat_id")
161
+ chat_name = meta.get("chat_name", "Chat 1")
162
+ timestamp = meta.get("timestamp", "")
163
+ query = doc
164
+ answer = meta.get("answer", "")
165
+
166
+ if chat_name not in chats:
167
+ chats[chat_name] = {"messages": [], "chat_id": chat_id}
168
+
169
+ chats[chat_name]["messages"].append({"role": "user", "content": query, "timestamp": timestamp})
170
+ chats[chat_name]["messages"].append({"role": "assistant", "content": answer, "timestamp": timestamp})
171
+
172
+ # sort messages within each chat by timestamp
173
+ for chat_name in chats:
174
+ chats[chat_name]["messages"].sort(key=lambda x: x.get("timestamp", ""))
175
+
176
+ return chats
177
+
178
+ def delete_chat(chat_id: str):
179
+ """Remove a chat from ChromaDB and the JSON log."""
180
+ # Remove from ChromaDB
181
+ results = chat_collection.get(where={"chat_id": chat_id})
182
+ if results["ids"]:
183
+ chat_collection.delete(ids=results["ids"])
184
+
185
+ print(f"Chat deleted: {chat_id}")
186
+
chroma/chatbot_retriever.py ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import chromadb
2
+ from langchain_ollama import ChatOllama
3
+ from langchain_classic.chains import create_history_aware_retriever, create_retrieval_chain
4
+ from langchain_classic.chains.combine_documents import create_stuff_documents_chain
5
+ from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
6
+ from langchain_chroma import Chroma
7
+ from langchain_huggingface import HuggingFaceEmbeddings
8
+ from langchain_core.messages import HumanMessage, AIMessage
9
+ from chroma.chatbot_chroma_db import create_new_chat, get_chat_history
10
+ from datetime import datetime
11
+ import uuid
12
+ from presidio_analyzer import AnalyzerEngine, PatternRecognizer, Pattern
13
+ from presidio_anonymizer import AnonymizerEngine
14
+ from presidio_analyzer import PatternRecognizer, Pattern
15
+ from presidio_anonymizer.entities import OperatorConfig
16
+ from presidio_analyzer.nlp_engine import NlpEngineProvider
17
+ from sentence_transformers import CrossEncoder
18
+ from langchain_classic.retrievers import ContextualCompressionRetriever
19
+ from langchain_classic.retrievers.document_compressors import CrossEncoderReranker
20
+ from langchain_community.cross_encoders import HuggingFaceCrossEncoder
21
+ from sentence_transformers import CrossEncoder
22
+ from sentence_transformers import SentenceTransformer
23
+
24
+
25
+
26
+ # load model and collection
27
+ # reranking_model = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
28
+ # reranking_model.save("../models/reranker")
29
+ # model = SentenceTransformer("multi-qa-mpnet-base-dot-v1")
30
+ # model.save("../models/embedder")
31
+
32
+ rerank_model = HuggingFaceCrossEncoder(model_name="./models/reranker")
33
+ compressor = CrossEncoderReranker(model=rerank_model, top_n=3)
34
+ model = HuggingFaceEmbeddings(model_name="./models/embedder")
35
+ client = chromadb.PersistentClient(path="./chroma_db")
36
+ chat_collection = client.get_collection("chat_history", embedding_function=None)
37
+
38
+ print(client.list_collections())
39
+
40
+ vectorstore = Chroma(
41
+ client = client,
42
+ collection_name = "bank_faq",
43
+ embedding_function = model
44
+ )
45
+
46
+ retriever = vectorstore.as_retriever(
47
+ search_type = "similarity",
48
+ search_kwargs = {"k": 3}
49
+ )
50
+
51
+ reranking_retriever = ContextualCompressionRetriever(
52
+ base_compressor=compressor,
53
+ base_retriever=vectorstore.as_retriever(search_kwargs={"k": 20})
54
+ )
55
+
56
+ llm = ChatOllama(model="llama3.2", temperature=0)
57
+
58
+ # contextualize qn
59
+ def contextualize_question():
60
+ question_reformulation_prompt = """
61
+ Given a chat history and the latest user question
62
+ which might reference context in the chat history,
63
+ formulate a standalone question that can be understood
64
+ without the chat history. Do NOT answer the question,
65
+ just reformulate it if needed and otherwise return it as is."""
66
+
67
+ question_reformulation_template = ChatPromptTemplate.from_messages([
68
+ ("system", question_reformulation_prompt),
69
+ MessagesPlaceholder("chat_history"),
70
+ ("human", "{input}"),
71
+ ])
72
+
73
+ return create_history_aware_retriever(llm, reranking_retriever, question_reformulation_template)
74
+
75
+ # answer qn
76
+ def answer_question():
77
+ # If the answer is not in the context, say "I don't have information on that.
78
+ answer_prompt = """You are a helpful banking assistant.
79
+ Answer the user's question using ONLY the context below.
80
+
81
+ Before answering, check whether the user's message:
82
+ 1. Contains sensitive information (account numbers, passwords, PINs, OTPs, card numbers, etc.), OR
83
+ 2. Requests a banking transaction or account-specific action (transfers, payments, account changes, balance checks, etc.).
84
+
85
+ If either condition is true:
86
+ - Do NOT process, verify, store, or repeat the information.
87
+ - Inform the user not to share sensitive information.
88
+ - Explain that account-specific actions cannot be performed through this chatbot.
89
+ - Do not ask for additional sensitive information.
90
+ - Do not continue the transaction.
91
+
92
+ Only after passing the security check should you use the context to answer the question.
93
+
94
+ If the answer is clearly in the context, answer it directly and MUST provide it.
95
+
96
+ If the answer is NOT in the context, first consider whether the question
97
+ might be vague or incomplete. If so, ask a single clarifying question to
98
+ help the user elaborate (e.g. "Could you tell me more about what you're
99
+ looking for?", "Are you referring to a specific account type or product?").
100
+
101
+ Do not generate information on your own that is not within the context.
102
+
103
+ Do not say 'I don't have information' if the context contains relevant details.
104
+
105
+ Only say "I don't have information on that" if:
106
+ - The question is already specific and clear, AND
107
+ - The answer is genuinely not in the context."
108
+
109
+
110
+ Context: {context}"""
111
+
112
+ answer_template = ChatPromptTemplate.from_messages([
113
+ ("system", answer_prompt),
114
+ MessagesPlaceholder("chat_history"),
115
+ ("human", "{input}"),
116
+ ])
117
+
118
+ return create_stuff_documents_chain(llm, answer_template)
119
+
120
+ history_aware_retriever = contextualize_question()
121
+ answer_chain = answer_question()
122
+
123
+ rag_chain = create_retrieval_chain(history_aware_retriever, answer_chain)
124
+
125
+ chat_history = []
126
+
127
+ # analyzer = AnalyzerEngine()
128
+ anonymizer = AnonymizerEngine()
129
+
130
+ provider = NlpEngineProvider(nlp_configuration={
131
+ "nlp_engine_name": "spacy",
132
+ "models": [{"lang_code": "en", "model_name": "en_core_web_lg"}]
133
+ })
134
+
135
+ analyzer = AnalyzerEngine(nlp_engine=provider.create_engine())
136
+
137
+ account_recognizer = PatternRecognizer(
138
+ supported_entity="ACCOUNT_NUMBER",
139
+ patterns=[Pattern(name="account_number", regex=r"\d{3}-\d{3}-\d{3}", score=0.8)],
140
+ context=["account", "acct", "num", "number", "no.", "#"]
141
+ )
142
+
143
+ analyzer.registry.add_recognizer(account_recognizer)
144
+
145
+ def _analyze(text: str):
146
+ return analyzer.analyze(text=text, language="en", entities=["ACCOUNT_NUMBER", "PERSON", "PHONE_NUMBER", "EMAIL_ADDRESS", "CREDIT_CARD"])
147
+
148
+ def scrub_pii(text: str) -> str:
149
+ results = _analyze(text)
150
+ scrubbed = anonymizer.anonymize(text=text, analyzer_results=results)
151
+ return scrubbed.text
152
+
153
+ SECURITY_MESSAGE = (
154
+ """
155
+ For your security and privacy, please do not share sensitive
156
+ information such as account numbers, passwords, PINs, or OTPs
157
+ in this chat. If you require account-related assistance, please
158
+ use the bank's secure authenticated channels.
159
+ """
160
+ )
161
+
162
+ def contains_pii(text: str) -> bool:
163
+ results = _analyze(text)
164
+ return len(results) > 0
165
+
166
+
167
+ def ask(query, chat_id=None, chat_name = None, persist=True):
168
+
169
+ if chat_id is None:
170
+ lc_history = []
171
+ past = []
172
+ else:
173
+ MAX_HISTORY_TURNS = 10
174
+ past = get_chat_history(chat_id)[-MAX_HISTORY_TURNS:]
175
+ # past = get_chat_history(chat_id)
176
+ lc_history = []
177
+ for turn in past:
178
+ lc_history.append(HumanMessage(content=turn["query"]))
179
+ lc_history.append(AIMessage(content=turn["answer"]))
180
+
181
+
182
+ # load long term history from db
183
+ clarification_phrases = ["could you", "can you elaborate", "what do you mean", "are you referring"]
184
+ clarification_count = sum(
185
+ 1 for turn in past
186
+ if any(phrase in turn["answer"].lower() for phrase in clarification_phrases)
187
+ )
188
+
189
+ clean_query = scrub_pii(query)
190
+ # print(clean_query)
191
+
192
+ # docs = retriever.invoke(clean_query)
193
+ # print("Retrieved docs:")
194
+ # for i, doc in enumerate(docs):
195
+ # print(f"\n--- Doc {i+1} ---")
196
+ # print(doc.page_content[:500])
197
+
198
+ # run rag chain with full history
199
+ response = rag_chain.invoke({
200
+ "input": clean_query,
201
+ "chat_history": lc_history
202
+ })
203
+ answer = response["answer"]
204
+
205
+ is_clarifying = any(phrase in answer.lower() for phrase in clarification_phrases)
206
+ if is_clarifying and clarification_count >= 1:
207
+ answer = "I'm sorry, I don't have information on that."
208
+
209
+ # persist new turn back to db
210
+ if persist and chat_id is not None:
211
+ message_id = str(uuid.uuid4())
212
+ chat_collection.add(
213
+ ids=[message_id],
214
+ documents=[clean_query],
215
+ metadatas=[{
216
+ "chat_id": chat_id,
217
+ "chat_name": chat_name,
218
+ "answer": answer,
219
+ "timestamp": datetime.now().isoformat()
220
+ }]
221
+ )
222
+
223
+ return answer
224
+
225
+ # Test questions
226
+ # print(ask("What are the minimum and maximum age limits for investing in M&N Life Uday"))
227
+
228
+ # print(ask("what documents do I need to open a bank account?"))
229
+ # print(ask("what about for a savings account?"))
230
+ # print(ask("do I need KYC for that too?"))
231
+
232
+ def retrieve_with_rerank(query, top_k=20, top_n=5):
233
+ # Step 1: Retrieve
234
+ raw_chunks = vectorstore.similarity_search(query, k=top_k)
235
+
236
+ # Step 2: Score each chunk against the query
237
+ pairs = [(query, chunk.page_content) for chunk in raw_chunks]
238
+ scores = rerank_model.predict(pairs)
239
+
240
+ # Step 3: Sort by score and take top_n
241
+ ranked = sorted(zip(scores, raw_chunks), key=lambda x: x[0], reverse=True)
242
+ return [chunk for _, chunk in ranked[:top_n]]
243
+
244
+
245
+
246
+ def answer_query(query):
247
+ # Before: chunks = vector_store.similarity_search(query, k=5)
248
+ # After:
249
+ chunks = retrieve_with_rerank(query, top_k=20, top_n=5)
250
+
251
+ context = "\n\n".join([c.page_content for c in chunks])
252
+
253
+ response = llm.invoke(f"""
254
+ Context: {context}
255
+ Question: {query}
256
+ Answer only from the context above.
257
+ """)
258
+ return response
259
+