simonbb 0.0.8__tar.gz → 0.0.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simonbb
3
- Version: 0.0.8
3
+ Version: 0.0.9
4
4
  Summary: A small package to facilitate teaching AI courses at business schools
5
5
  Project-URL: Homepage, https://simonbb.com
6
6
  Author-email: Huaxia Rui <huaxia.rui@simon.rochester.edu>
@@ -9,18 +9,17 @@ License-File: LICENSE
9
9
  Classifier: Operating System :: OS Independent
10
10
  Classifier: Programming Language :: Python :: 3
11
11
  Requires-Python: >=3.9
12
- Requires-Dist: gymnasium~=1.3.0
13
- Requires-Dist: matplotlib~=3.10.8
14
- Requires-Dist: numpy~=2.4.0
15
- Requires-Dist: opencv-python~=5.0.0.93
16
- Requires-Dist: pytorch-ignite~=0.5.4
17
- Requires-Dist: pyyaml~=6.0.3
18
- Requires-Dist: ray~=2.56.0
19
- Requires-Dist: sentence-transformers~=5.3.0
20
- Requires-Dist: stable-baselines3~=2.9.0
21
- Requires-Dist: torchvision~=0.24.1
22
- Requires-Dist: torch~=2.9.1
23
- Requires-Dist: transformers~=4.57.3
12
+ Requires-Dist: gymnasium>=1.3.0
13
+ Requires-Dist: matplotlib>=3.10.8
14
+ Requires-Dist: numpy>=2.4.0
15
+ Requires-Dist: opencv-python>=5.0.0.93
16
+ Requires-Dist: pytorch-ignite>=0.5.4
17
+ Requires-Dist: pyyaml>=6.0.3
18
+ Requires-Dist: ray>=2.56.0
19
+ Requires-Dist: stable-baselines3>=2.9.0
20
+ Requires-Dist: torch>=2.9.1
21
+ Requires-Dist: torchvision>=0.24.1
22
+ Requires-Dist: transformers>=4.57.3
24
23
  Description-Content-Type: text/markdown
25
24
 
26
25
  # Background
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "simonbb"
7
- version = "0.0.8"
7
+ version = "0.0.9"
8
8
  authors = [
9
9
  { name="Huaxia Rui", email="huaxia.rui@simon.rochester.edu" },
10
10
  ]
@@ -18,18 +18,17 @@ classifiers = [
18
18
  license = "MIT"
19
19
  license-files = ["LICEN[CS]E*"]
20
20
  dependencies = [
21
- "matplotlib~=3.10.8",
22
- "torch~=2.9.1",
23
- "torchvision~=0.24.1",
24
- "numpy~=2.4.0",
25
- "transformers~=4.57.3",
26
- "sentence-transformers~=5.3.0",
27
- "gymnasium~=1.3.0",
28
- "pytorch-ignite~=0.5.4",
29
- "stable_baselines3~=2.9.0",
30
- "ray~=2.56.0",
31
- "opencv-python~=5.0.0.93",
32
- "pyyaml~=6.0.3"
21
+ "matplotlib >= 3.10.8",
22
+ "torch >= 2.9.1",
23
+ "torchvision >=0.24.1",
24
+ "numpy >=2.4.0",
25
+ "transformers >= 4.57.3",
26
+ "gymnasium >= 1.3.0",
27
+ "pytorch-ignite >= 0.5.4",
28
+ "stable_baselines3 >= 2.9.0",
29
+ "ray >= 2.56.0",
30
+ "opencv-python >= 5.0.0.93",
31
+ "pyyaml >= 6.0.3"
33
32
  ]
34
33
  [project.urls]
35
34
  Homepage = "https://simonbb.com"
@@ -0,0 +1,455 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ from typing import Annotated, TypedDict, get_args, Any, Protocol, Sequence
6
+ from dataclasses import dataclass
7
+ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
8
+
9
+ from pathlib import Path
10
+
11
+ from functools import partial
12
+
13
+ from langchain_core.documents import Document
14
+ from langchain_core.messages import HumanMessage, SystemMessage
15
+ from langchain_core.prompts import ChatPromptTemplate
16
+ from langchain_core.runnables import Runnable, RunnablePassthrough
17
+ from langchain_core.output_parsers import StrOutputParser
18
+
19
+ from langgraph.graph import END, START, StateGraph
20
+ from langgraph.graph.message import add_messages
21
+
22
+ from langchain_chroma import Chroma
23
+ from langchain_community.document_loaders import PyPDFLoader, UnstructuredHTMLLoader
24
+
25
+ from langchain_core.callbacks import CallbackManagerForRetrieverRun
26
+
27
+ import hashlib
28
+ import chromadb
29
+ from langchain_core.embeddings import Embeddings
30
+ from langchain_core.language_models import BaseChatModel
31
+ from langchain_core.retrievers import BaseRetriever
32
+ from langchain_text_splitters import RecursiveCharacterTextSplitter
33
+
34
+ #%%
35
+
36
+ def load_documents(path: Path):
37
+ """Load documents from a file or directory."""
38
+
39
+ valid_extensions = {'.pdf', '.html', '.ejs'}
40
+
41
+ docs = []
42
+ success_cnt = 0
43
+ page_cnt = 0
44
+
45
+ if path.exists():
46
+ if path.is_dir():
47
+ for file in [ item for item in path.rglob('*') if item.is_file() and item.suffix in valid_extensions ]:
48
+ print(file.name)
49
+ if file.suffix == '.pdf':
50
+ documents = PyPDFLoader(file).load()
51
+ elif file.suffix in {'.html', '.ejs'}:
52
+ documents = UnstructuredHTMLLoader(file).load()
53
+ else:
54
+ continue
55
+ success_cnt += 1
56
+ page_cnt += len(documents)
57
+ docs.extend(documents)
58
+ else:
59
+ print(path.name)
60
+ if path.suffix == '.pdf':
61
+ documents = PyPDFLoader(path).load()
62
+ elif path.suffix in {'.html', '.ejs'}:
63
+ documents = UnstructuredHTMLLoader(path).load()
64
+ else:
65
+ print(f"Unsupported file type: {path}")
66
+ return None, 0, 0
67
+ success_cnt = 1
68
+ page_cnt = len(documents)
69
+ docs.extend(documents)
70
+ else:
71
+ print(f"Error: '{path}' is not a valid file or directory")
72
+ return None, 0, 0
73
+
74
+ if success_cnt == 0:
75
+ print("No valid documents found!")
76
+ return None, 0, 0
77
+
78
+ return docs, success_cnt, page_cnt
79
+
80
+ #%% MultiQueryRerankRetriever
81
+
82
+ class MultiQueryRerankRetriever(BaseRetriever):
83
+ """Chroma retriever with optional multi-query retrieval and reranking."""
84
+ knowledge_base: str
85
+ embeddings: Embeddings = Field(exclude=True)
86
+ query_llm: BaseChatModel | None = Field(default=None, exclude=True)
87
+ reranker: Any = Field(default=None, exclude=True)
88
+ k: int = Field(default=3, ge=1)
89
+ top_n: int = Field(default=5, ge=1)
90
+ vstore_path: str = Field( default_factory=lambda: f"{os.path.expanduser('~')}/vstore" )
91
+ _base_retriever: BaseRetriever = PrivateAttr()
92
+
93
+ def model_post_init(self, __context: Any) -> None:
94
+ """Initialize the underlying Chroma retriever."""
95
+ store = Chroma( persist_directory=( f"{self.vstore_path}/{self.knowledge_base}/BAAI_db/chroma_vector_store"), embedding_function=self.embeddings )
96
+ self._base_retriever = store.as_retriever( search_kwargs={"k": self.k} )
97
+ if self.query_llm is not None and self.reranker is None:
98
+ raise ValueError( "A reranker is required when query_llm is provided." )
99
+ # run_manager manages callbacks, tracing, and monitoring during a retriever's execution, allowing integrations with LangSmith
100
+ def _generate_queries( self, query: str, run_manager: CallbackManagerForRetrieverRun ) -> list[str]:
101
+ """Generate five alternative formulations of the query."""
102
+ prompt = ChatPromptTemplate.from_template( "You are an AI language model assistant. Generate 5 different versions of the following question. Return exactly 5 alternative questions, one per line. Do not include explanations or numbering. Original question: {question}" )
103
+ chain = prompt | self.query_llm | StrOutputParser()
104
+ result = chain.invoke( {"question": query}, config={"callbacks": run_manager.get_child()} )
105
+ return [q.strip() for q in result.splitlines() if q.strip()]
106
+
107
+ @staticmethod
108
+ def _unique_union( document_lists: list[list[Document]] ) -> list[Document]:
109
+ """Flatten document lists and remove duplicates."""
110
+ unique_docs = {
111
+ doc.page_content: doc
112
+ for docs in document_lists
113
+ for doc in docs
114
+ }
115
+ return list(unique_docs.values())
116
+
117
+ def _rerank( self, query: str, docs: list[Document] ) -> list[Document]:
118
+ """Rerank retrieved documents using a cross-encoder."""
119
+ if not docs:
120
+ return []
121
+ pairs = [(query, doc.page_content) for doc in docs]
122
+ scores = self.reranker.predict(pairs)
123
+ scored_docs = sorted(
124
+ zip(scores, docs),
125
+ key=lambda x: float(x[0]),
126
+ reverse=True,
127
+ )
128
+ return [doc for _, doc in scored_docs[:self.top_n]]
129
+
130
+ def _get_relevant_documents( self, query: str, *, run_manager: CallbackManagerForRetrieverRun ) -> list[Document]:
131
+ """Retrieve documents using simple or multi-query retrieval."""
132
+ # Simple retrieval
133
+ if self.query_llm is None:
134
+ return self._base_retriever.invoke( query, config={"callbacks": run_manager.get_child()} )
135
+ # Generate alternative queries
136
+ alt_queries = self._generate_queries(query, run_manager)
137
+ all_queries = list( dict.fromkeys([query, *alt_queries]) ) # standard, most efficient way to remove duplicates from a list (or any iterable) while preserving the original insertion order
138
+ # Retrieve documents for all queries
139
+ doc_lists = self._base_retriever.batch(
140
+ all_queries,
141
+ config={"callbacks": run_manager.get_child()},
142
+ )
143
+ # Deduplicate and rerank
144
+ unique_docs = self._unique_union(doc_lists)
145
+ return self._rerank(query, unique_docs)
146
+
147
+
148
+ #%% Hypothetical Question Retriever
149
+
150
+ def _stable_id(source_id: str, index: int, text: str) -> str:
151
+ digest = hashlib.sha256(f"{source_id}\0{index}\0{text}".encode()).hexdigest()
152
+ return f"chunk-{digest[:32]}"
153
+
154
+ def _clean_questions(questions: Sequence[str]) -> list[str]:
155
+ clean: list[str] = []
156
+ seen: set[str] = set()
157
+ for value in questions:
158
+ question = " ".join(str(value).split()).strip()
159
+ key = question.casefold()
160
+ if question and key not in seen:
161
+ seen.add(key)
162
+ clean.append(question)
163
+ return clean
164
+
165
+ def _chroma_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
166
+ """Keep Chroma-compatible scalar metadata and drop None/compound values."""
167
+ return {
168
+ str(key): value for key, value in metadata.items()
169
+ if value is not None and isinstance(value, (str, int, float, bool))
170
+ }
171
+
172
+ class QuestionGenerator(Protocol):
173
+ """Anything capable of generating search questions for a chunk."""
174
+ def generate(self, document: Document) -> list[str]: ...
175
+
176
+ class _QuestionList(BaseModel):
177
+ questions: list[str] = Field(description="Distinct questions answered by the text")
178
+
179
+ class HypotheticalQuestionGenerator:
180
+ """LLM-backed question generator using provider-supported structured output."""
181
+
182
+ def __init__(self, model: BaseChatModel, *, questions_per_chunk: int = 4) -> None:
183
+ if questions_per_chunk < 1:
184
+ raise ValueError("questions_per_chunk must be positive")
185
+ self.questions_per_chunk = questions_per_chunk
186
+ prompt = ChatPromptTemplate.from_messages(
187
+ [
188
+ ( "system", "Generate exactly {count} distinct, natural questions that can be answered from the supplied text. Do not answer them and do not use outside knowledge."),
189
+ ("human", "Text:\n{text}"),
190
+ ]
191
+ )
192
+ self._chain = prompt | model.with_structured_output(_QuestionList)
193
+
194
+ def generate(self, document: Document) -> list[str]:
195
+ result = self._chain.invoke( {"count": self.questions_per_chunk, "text": document.page_content} )
196
+ questions = result.questions if isinstance(result, _QuestionList) else result["questions"]
197
+ return _clean_questions(questions)[: self.questions_per_chunk]
198
+
199
+ @dataclass(frozen=True)
200
+ class IngestionResult:
201
+ chunks: int
202
+ hypothetical_questions: int
203
+ chunk_ids: tuple[str, ...]
204
+
205
+ class HypotheticalQuestionRetriever(BaseRetriever):
206
+ """Search question vectors, then resolve their chunk IDs from the chunk collection."""
207
+ model_config = ConfigDict(arbitrary_types_allowed=True)
208
+ question_store: Chroma
209
+ chunk_collection: Any
210
+ generator: Any
211
+ question_k: int = 12
212
+ chunk_k: int = 4
213
+ metadata_filter: dict[str, Any] | None = None
214
+
215
+ def __init__(self, *, persist_directory: str | Path, embeddings: Embeddings, generator: QuestionGenerator, question_collection: str = "hypothetical_questions", chunk_collection: str = "original_chunks", question_k: int = 12, chunk_k: int = 4, metadata_filter: dict[str, Any] | None = None, **kwargs: Any) -> None:
216
+ client = chromadb.PersistentClient(path=str(persist_directory))
217
+ question_store = Chroma( client=client, collection_name=question_collection, embedding_function=embeddings )
218
+ chunks = client.get_or_create_collection( name=chunk_collection, embedding_function=None )
219
+ super().__init__(
220
+ question_store=question_store,
221
+ chunk_collection=chunks,
222
+ generator=generator,
223
+ question_k=question_k,
224
+ chunk_k=chunk_k,
225
+ metadata_filter=metadata_filter,
226
+ **kwargs,
227
+ )
228
+
229
+ def ingest( self, documents: Sequence[Document], *, source_id: str, split: bool = True, chunk_size: int = 1000, chunk_overlap: int = 150, replace_source: bool = True ) -> IngestionResult:
230
+ if not source_id: # source_id is a unique identifier for the source document or documents being ingested
231
+ raise ValueError("source_id must not be empty")
232
+ splitter = RecursiveCharacterTextSplitter( chunk_size=chunk_size, chunk_overlap=chunk_overlap, add_start_index=True )
233
+ input_docs = splitter.split_documents( list(documents) ) if split else list(documents)
234
+
235
+ if replace_source:
236
+ self.question_store.delete(where={"source_id": source_id})
237
+ self.chunk_collection.delete(where={"source_id": source_id})
238
+
239
+ chunk_ids: list[str] = []
240
+ question_docs: list[Document] = []
241
+ question_ids: list[str] = []
242
+ chunk_metadatas: list[dict[str, Any]] = []
243
+ chunk_texts: list[str] = []
244
+
245
+ for index, doc in enumerate(input_docs):
246
+ chunk_id = _stable_id(source_id, index, doc.page_content)
247
+ metadata = _chroma_metadata( {**doc.metadata, "source_id": source_id, "chunk_id": chunk_id} )
248
+ chunk_ids.append( chunk_id )
249
+ chunk_metadatas.append( metadata )
250
+ chunk_texts.append( doc.page_content )
251
+
252
+ questions = _clean_questions( self.generator.generate(doc) )
253
+ for question_index, question in enumerate(questions):
254
+ question_ids.append( f"{chunk_id}:q:{question_index}" )
255
+ question_docs.append(
256
+ Document( page_content=question,
257
+ metadata={"chunk_id": chunk_id, "source_id": source_id, "question_index": question_index },
258
+ )
259
+ )
260
+ if question_docs: # LangChain's Chroma.add_documents() triggers embedding
261
+ self.question_store.add_documents( question_docs, ids=question_ids )
262
+
263
+ if chunk_ids: # Chroma's underlying collection API stores text and metadata without embeddings.
264
+ self.chunk_collection.upsert( ids=chunk_ids, documents=chunk_texts, metadatas=chunk_metadatas,
265
+ embeddings=[[0.0] for _ in chunk_ids] ) # placeholder vectors since Chroma records require vectors
266
+
267
+ return IngestionResult(len(chunk_ids), len(question_docs), tuple(chunk_ids))
268
+
269
+ # override _get_relevant_documents() method from BaseRetriever
270
+ def _get_relevant_documents(self, query: str, *, run_manager: Any = None) -> list[Document]:
271
+ matches = self.question_store.similarity_search( query, k=self.question_k, filter=self.metadata_filter )
272
+ chunk_ids: list[str] = []
273
+ seen: set[str] = set()
274
+ for m in matches:
275
+ chunk_id = str(m.metadata["chunk_id"])
276
+ if chunk_id not in seen:
277
+ seen.add(chunk_id)
278
+ chunk_ids.append(chunk_id)
279
+ if len(chunk_ids) == self.chunk_k:
280
+ break
281
+ if not chunk_ids:
282
+ return []
283
+
284
+ records = self.chunk_collection.get(ids=chunk_ids, include=["documents", "metadatas"])
285
+ by_id = {
286
+ record_id: Document(page_content=text, metadata=metadata or {})
287
+ for record_id, text, metadata in zip(
288
+ records["ids"], records["documents"], records["metadatas"], strict=True
289
+ )
290
+ }
291
+ return [by_id[chunk_id] for chunk_id in chunk_ids if chunk_id in by_id]
292
+
293
+ #%% RAG Chains
294
+
295
+ system_prompt = """You are a helpful assistant answering questions based on provided document snippets.
296
+ IMPORTANT INSTRUCTIONS:
297
+ 1. Base your answer ONLY on the provided context snippets below.
298
+ 2. Synthesize information from multiple snippets if needed.
299
+ 3. If the snippets contain partial information, provide the best answer you can with what's available.
300
+ 4. Only say "There wasn't enough information to answer the question" if the snippets truly contain NO relevant information.
301
+ 5. Always cite your sources using the snippet numbers in parentheses, like (1), (2), etc.
302
+
303
+ The snippets are provided in this format:
304
+ Snippet 1: <text content>
305
+ Snippet 2: <text content>
306
+ ...
307
+
308
+ {context}"""
309
+
310
+ def format_documents(documents: Sequence[Document]) -> str:
311
+ snippets = []
312
+ for i, doc in enumerate(documents, start=1):
313
+ snippets.append(f"Snippet {i}: {doc.page_content}")
314
+ context = "\n".join(snippets)
315
+ return context
316
+
317
+ def rag_basic(retriever: BaseRetriever, model: BaseChatModel) -> Runnable:
318
+ """Return an LCEL chain accepting a question string and producing an answer string."""
319
+ prompt = ChatPromptTemplate.from_messages(
320
+ [
321
+ ( "system", "Answer using only the context below. If the answer is absent, say you do not know.\n\nContext:\n{context}" ),
322
+ ("human", "{query}"),
323
+ ]
324
+ )
325
+ inputs = {
326
+ "context": retriever | format_documents,
327
+ "query": RunnablePassthrough(),
328
+ }
329
+ return inputs | prompt | model | StrOutputParser()
330
+
331
+
332
+ def rag_main(retriever: BaseRetriever, model: BaseChatModel) -> Runnable:
333
+ """Return an LCEL chain accepting a question string and producing an dic with 4 keys/values
334
+ -- 'sources': original list of Documents from retriever
335
+ -- 'query': original query
336
+ -- 'context': merged content from relevant documents
337
+ -- 'answer': final answer """
338
+ prompt = ChatPromptTemplate.from_messages([
339
+ ("system", system_prompt),
340
+ ("human", "{query}")
341
+ ])
342
+ return (
343
+ {
344
+ "sources": retriever,
345
+ "query": RunnablePassthrough(),
346
+ }
347
+ | RunnablePassthrough.assign( context=lambda x: format_documents(x["sources"]) )
348
+ | RunnablePassthrough.assign( answer=prompt | model | StrOutputParser() )
349
+ )
350
+
351
+ build_rag = rag_main
352
+
353
+ #%% RAG with Routing
354
+
355
+ domain_prompt = """You are a helpful assistant answering questions based on provided document snippets relating to {domain}.
356
+
357
+ IMPORTANT INSTRUCTIONS:
358
+ 1. Base your answer ONLY on the provided context snippets below.
359
+ 2. Synthesize information from multiple snippets if needed.
360
+ 3. If the snippets contain partial information, provide the best answer you can with what's available.
361
+ 4. Only say "There wasn't enough information to answer the question" if the snippets truly contain NO relevant information.
362
+ 5. Always cite your sources using the snippet numbers in parentheses, like (1), (2), etc.
363
+
364
+ The snippets are provided in this format:
365
+ Snippet 1: <text content>
366
+ Snippet 2: <text content>
367
+ ...
368
+
369
+ {context}"""
370
+
371
+ def multiRagBuilder( domain_literals, router_prompt, llm, embeddings, reranker, query_llm=None, k=3):
372
+ """
373
+ domain_literals: the last one is a placeholder if the router finds no domain matches the query
374
+ """
375
+ domains = get_args(domain_literals)[:-1] # exclude the placeholder
376
+ nodomain = get_args(domain_literals)[-1]
377
+ # use the functional form of TypedDict instead of the class form to accommodate Python 3.14
378
+ State = TypedDict(
379
+ "State",
380
+ {
381
+ "messages": Annotated[list, add_messages],
382
+ "query": str,
383
+ "domain": domain_literals,
384
+ "documents": list[Document],
385
+ "answer": str,
386
+ }
387
+ )
388
+
389
+ class Input(TypedDict):
390
+ query: str
391
+
392
+ Output = TypedDict(
393
+ "Output",
394
+ {
395
+ "domain": domain_literals,
396
+ "documents": list[Document],
397
+ "answer": str
398
+ }
399
+ )
400
+
401
+ def router_node(state) -> State:
402
+ user_message = HumanMessage(state["query"])
403
+ messages = [ SystemMessage(router_prompt), *state["messages"], user_message ]
404
+ res = llm.invoke(messages)
405
+ return {
406
+ "domain": res.content.strip(),
407
+ "messages": [user_message, res], # update conversation history
408
+ }
409
+
410
+ def pick_retriever( state ):
411
+ for domain in domains:
412
+ if re.search( domain, state['domain'] ):
413
+ return domain
414
+ return 'generate_answer'
415
+
416
+ def generate_answer(state) -> State:
417
+ snippets = []
418
+ for i, doc in enumerate(state.get('documents', []), start=1):
419
+ snippets.append(f"Snippet {i}: {doc.page_content}")
420
+ context = "\n".join(snippets)
421
+ if state.get('domain', '')!=nodomain:
422
+ prompt = ChatPromptTemplate.from_messages( [ ("system", domain_prompt), ("human", "{query}") ])
423
+ messages = prompt.format_messages( domain=state.get('domain', ''), context=context, query=state["query"] )
424
+ else:
425
+ prompt = ChatPromptTemplate.from_messages( [ ("system", 'You are a helpful assistant.'), ("human", "{query}") ])
426
+ messages = prompt.format_messages( query=state["query"] )
427
+ res = llm.invoke(messages)
428
+ return {
429
+ "answer": res.content,
430
+ "messages": res, # update conversation history
431
+ }
432
+
433
+ def retriever_node( domain, state):
434
+ query = re.sub(domain, 'this class', state["query"] ) # using the course code hurts RAG since the course code is rarely used in documents.
435
+ #print( f"domain={domain}; query={query}")
436
+ documents = retrievers[domain].invoke( query )
437
+ return { "documents": documents, 'query': query }
438
+
439
+ builder = StateGraph(State, input_schema=Input, output_schema=Output)
440
+ builder.add_node("router", router_node)
441
+ builder.add_edge(START, "router")
442
+ builder.add_conditional_edges("router", pick_retriever)
443
+
444
+ retrievers = {}
445
+ retriever_nodes = {}
446
+ for domain in domains:
447
+ retrievers[domain] = MultiQueryRerankRetriever(knowledge_base=domain, embeddings=embeddings, query_llm=query_llm, reranker=reranker, k=k)
448
+ retriever_nodes[domain] = partial( retriever_node, domain ) # the 1st argument is the partial function. the rest are frozen arguments passed positionally to fill the leftmost parameters of the original function.
449
+ builder.add_node(domain, retriever_nodes[domain])
450
+ builder.add_edge(domain, "generate_answer")
451
+
452
+ builder.add_node("generate_answer", generate_answer)
453
+ builder.add_edge("generate_answer", END)
454
+
455
+ return builder
@@ -1,258 +0,0 @@
1
- # module dependencies: torch torchvision langchain_core langchain langgraph langchain_community langchain_chroma langchain_huggingface sentence_transformers
2
- # additional packages for class projects: langchain_ollama langchain_text_splitters unstructured streamlit_javascript
3
-
4
- import os
5
- import re
6
- from typing import Annotated, Literal, TypedDict, get_args
7
- from pathlib import Path
8
-
9
- from functools import partial
10
-
11
- import torch
12
- device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
13
-
14
- from langchain_core.documents import Document
15
- from langchain_core.messages import HumanMessage, SystemMessage
16
- from langchain_core.prompts import ChatPromptTemplate
17
- from langchain_core.runnables import RunnableLambda
18
-
19
- from langgraph.graph import END, START, StateGraph
20
- from langgraph.graph.message import add_messages
21
-
22
- from langchain_chroma import Chroma
23
- from langchain_huggingface import HuggingFaceEmbeddings
24
- from langchain_community.document_loaders import PyPDFLoader, UnstructuredHTMLLoader
25
- from sentence_transformers import CrossEncoder
26
-
27
- embedding_model = HuggingFaceEmbeddings(
28
- model_name="BAAI/bge-large-en-v1.5",
29
- model_kwargs={'device': device},
30
- encode_kwargs={'normalize_embeddings': False}
31
- )
32
-
33
- #%%
34
-
35
- def load_documents(path: Path):
36
- """Load documents from a file or directory."""
37
-
38
- valid_extensions = {'.pdf', '.html', '.ejs'}
39
-
40
- docs = []
41
- success_cnt = 0
42
- page_cnt = 0
43
-
44
- if path.exists():
45
- if path.is_dir():
46
- for file in [ item for item in path.rglob('*') if item.is_file() and item.suffix in valid_extensions ]:
47
- print(file.name)
48
- if file.suffix == '.pdf':
49
- documents = PyPDFLoader(file).load()
50
- elif file.suffix in {'.html', '.ejs'}:
51
- documents = UnstructuredHTMLLoader(file).load()
52
- else:
53
- continue
54
- success_cnt += 1
55
- page_cnt += len(documents)
56
- docs.extend(documents)
57
- else:
58
- print(path.name)
59
- if path.suffix == '.pdf':
60
- documents = PyPDFLoader(path).load()
61
- elif path.suffix in {'.html', '.ejs'}:
62
- documents = UnstructuredHTMLLoader(path).load()
63
- else:
64
- print(f"Unsupported file type: {path}")
65
- return None, 0, 0
66
- success_cnt = 1
67
- page_cnt = len(documents)
68
- docs.extend(documents)
69
- else:
70
- print(f"Error: '{path}' is not a valid file or directory")
71
- return None, 0, 0
72
-
73
- if success_cnt == 0:
74
- print("No valid documents found!")
75
- return None, 0, 0
76
-
77
- return docs, success_cnt, page_cnt
78
-
79
- reranker_model = CrossEncoder("BAAI/bge-reranker-large")
80
- def _getRetriever( knowledge_base, query_llm=None, k=3, vstore_path = f'{os.getenv("HOME")}/vstore' ):
81
- store = Chroma( persist_directory=f'{vstore_path}/{knowledge_base}/BAAI_db/chroma_vector_store', embedding_function=embedding_model )
82
- base_retriever = store.as_retriever(search_kwargs={"k": k}) # a vector store becomes a retriever through the as_retriever() method
83
- if query_llm==None:
84
- return base_retriever
85
-
86
- perspectives_prompt = ChatPromptTemplate.from_template(
87
- """You are an AI language model assistant. Your task is to generate 5 different versions of the given original question
88
- These should be in 5 lines and provide ONLY these alternative questions separated by newlines, no explaination is needed.
89
- Original question: {question}""")
90
- def parse_queries_output(message):
91
- #print( message.content )
92
- return message.content.split('\n')
93
-
94
- query_gen = perspectives_prompt | query_llm | parse_queries_output
95
-
96
- def get_unique_union(document_lists): # Flatten list of lists, and deduplicate them
97
- deduped_docs = { doc.page_content: doc for sublist in document_lists for doc in sublist}
98
- unique_docs = list(deduped_docs.values()) # a flat list of unique docs
99
- return unique_docs
100
-
101
- def rerank_docs(query, docs, top_n=3):
102
- """Rerank documents using cross-encoder and return top_n."""
103
- if not docs:
104
- return []
105
- pairs = [(query, doc.page_content) for doc in docs]
106
- scores = reranker_model.predict(pairs)
107
- scored_docs = sorted(zip(scores, docs), key=lambda x: float(x[0]), reverse=True)
108
- return [doc for _, doc in scored_docs[:top_n]]
109
-
110
- def retrieve_and_rerank(query):
111
- # Generate alternative queries + include original
112
- alt_queries = query_gen.invoke({"question": query})
113
- all_queries = [query] + alt_queries
114
- # Retrieve chunks for each query
115
- doc_lists = base_retriever.batch(all_queries)
116
- unique_docs = get_unique_union(doc_lists)
117
- # Rerank with cross-encoder and return top 3
118
- return rerank_docs(query, unique_docs, top_n=5)
119
-
120
- return RunnableLambda(retrieve_and_rerank)
121
-
122
-
123
- """
124
- RAG Chain
125
- """
126
-
127
- def getRagModel(knowledge_base, llm, query_llm=None, k=3):
128
- """
129
- Create and return a RAG model that returns a dictionary with 'result' and 'source_documents'.
130
- """
131
- retriever = _getRetriever(knowledge_base, query_llm, k)
132
-
133
- def _rag_chain(input_dict):
134
- query = input_dict["query"]
135
- docs = retriever.invoke(query)
136
- snippets = []
137
- for i, doc in enumerate(docs, start=1):
138
- snippets.append(f"Snippet {i}: {doc.page_content}")
139
- context = "\n".join(snippets)
140
- system_prompt = """You are a helpful assistant answering questions based on provided document snippets.
141
- IMPORTANT INSTRUCTIONS:
142
- 1. Base your answer ONLY on the provided context snippets below.
143
- 2. Synthesize information from multiple snippets if needed.
144
- 3. If the snippets contain partial information, provide the best answer you can with what's available.
145
- 4. Only say "There wasn't enough information to answer the question" if the snippets truly contain NO relevant information.
146
- 5. Always cite your sources using the snippet numbers in parentheses, like (1), (2), etc.
147
-
148
- The snippets are provided in this format:
149
- Snippet 1: <text content>
150
- Snippet 2: <text content>
151
- ...
152
-
153
- {context}"""
154
- prompt = ChatPromptTemplate.from_messages( [ ("system", system_prompt), ("human", "{query}") ])
155
- messages = prompt.format_messages( context=context, query=query )
156
- answer = llm.invoke(messages).content
157
- return {"answer": answer, "sources": docs}
158
-
159
- return RunnableLambda(_rag_chain)
160
-
161
-
162
- """
163
- multi-rag system with router
164
- """
165
-
166
- domain_prompt = """You are a helpful assistant answering questions based on provided document snippets relating to {domain}.
167
-
168
- IMPORTANT INSTRUCTIONS:
169
- 1. Base your answer ONLY on the provided context snippets below.
170
- 2. Synthesize information from multiple snippets if needed.
171
- 3. If the snippets contain partial information, provide the best answer you can with what's available.
172
- 4. Only say "There wasn't enough information to answer the question" if the snippets truly contain NO relevant information.
173
- 5. Always cite your sources using the snippet numbers in parentheses, like (1), (2), etc.
174
-
175
- The snippets are provided in this format:
176
- Snippet 1: <text content>
177
- Snippet 2: <text content>
178
- ...
179
-
180
- {context}"""
181
-
182
- def multiRagBuilder( domain_literals, router_prompt, chat_router, chat, query_llm=None, k=3):
183
- """
184
- domain_literals: the last one is a placeholder if the router finds no domain matches the query
185
- """
186
- domains = get_args(domain_literals)[:-1] # exclude the placeholder
187
- nodomain = get_args(domain_literals)[-1]
188
-
189
- class State(TypedDict):
190
- messages: Annotated[list, add_messages] # annotated with the reducer function to append new messages to the existing list, rather than overwriting
191
- query: str # input
192
- domain: domain_literals # output
193
- documents: list[Document]
194
- answer: str
195
-
196
- class Input(TypedDict):
197
- query: str
198
-
199
- class Output(TypedDict):
200
- domain: domain_literals
201
- documents: list[Document]
202
- answer: str
203
-
204
- def router_node(state: State) -> State:
205
- user_message = HumanMessage(state["query"])
206
- messages = [ SystemMessage(router_prompt), *state["messages"], user_message ]
207
- res = chat_router.invoke(messages)
208
- return {
209
- "domain": res.content.strip(),
210
- "messages": [user_message, res], # update conversation history
211
- }
212
-
213
- def pick_retriever( state: State) -> Literal[*domains, "generate_answer"]:
214
- for domain in domains:
215
- if re.search( domain, state['domain'] ):
216
- return domain
217
- return 'generate_answer'
218
-
219
- def generate_answer(state: State) -> State:
220
- snippets = []
221
- for i, doc in enumerate(state.get('documents', []), start=1):
222
- snippets.append(f"Snippet {i}: {doc.page_content}")
223
- context = "\n".join(snippets)
224
- if state.get('domain', '')!=nodomain:
225
- prompt = ChatPromptTemplate.from_messages( [ ("system", domain_prompt), ("human", "{query}") ])
226
- messages = prompt.format_messages( domain=state.get('domain', ''), context=context, query=state["query"] )
227
- else:
228
- prompt = ChatPromptTemplate.from_messages( [ ("system", 'You are a helpful assistant.'), ("human", "{query}") ])
229
- messages = prompt.format_messages( query=state["query"] )
230
- res = chat.invoke(messages)
231
- return {
232
- "answer": res.content,
233
- "messages": res, # update conversation history
234
- }
235
-
236
- def retriever_node( domain: domains, state: State):
237
- query = re.sub(domain, 'this class', state["query"] ) # using the course code hurts RAG since the course code is rarely used in documents.
238
- #print( f"domain={domain}; query={query}")
239
- documents = retrievers[domain].invoke( query )
240
- return { "documents": documents, 'query': query }
241
-
242
- builder = StateGraph(State, input_schema=Input, output_schema=Output)
243
- builder.add_node("router", router_node)
244
- builder.add_edge(START, "router")
245
- builder.add_conditional_edges("router", pick_retriever)
246
-
247
- retrievers = {}
248
- retriever_nodes = {}
249
- for domain in domains:
250
- retrievers[domain] = _getRetriever(domain, query_llm, k)
251
- retriever_nodes[domain] = partial( retriever_node, domain ) # the 1st argument is the partial function. the rest are frozen arguments passed positionally to fill the leftmost parameters of the original function.
252
- builder.add_node(domain, retriever_nodes[domain])
253
- builder.add_edge(domain, "generate_answer")
254
-
255
- builder.add_node("generate_answer", generate_answer)
256
- builder.add_edge("generate_answer", END)
257
-
258
- return builder
File without changes
File without changes
File without changes
File without changes