simonbb 0.0.8__tar.gz → 0.0.10__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.10
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.10"
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,545 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Annotated, TypedDict, get_args, Any, Protocol, Sequence
5
+ from dataclasses import dataclass
6
+ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
7
+
8
+ from pathlib import Path
9
+
10
+ from functools import partial
11
+
12
+ from langchain_core.documents import Document
13
+ from langchain_core.messages import HumanMessage, SystemMessage
14
+ from langchain_core.prompts import ChatPromptTemplate
15
+ from langchain_core.runnables import Runnable, RunnablePassthrough
16
+ from langchain_core.output_parsers import StrOutputParser
17
+
18
+ from langgraph.graph import END, START, StateGraph
19
+ from langgraph.graph.message import add_messages
20
+
21
+ from langchain_chroma import Chroma
22
+ from langchain_community.document_loaders import PyPDFLoader, UnstructuredHTMLLoader
23
+
24
+ from langchain_core.callbacks import CallbackManagerForRetrieverRun
25
+
26
+ import hashlib
27
+ from uuid import uuid4
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
+ valid_extensions = {'.pdf', '.html', '.ejs'}
39
+ docs = []
40
+ success_cnt = 0
41
+ page_cnt = 0
42
+ if path.exists():
43
+ if path.is_dir():
44
+ for file in [ item for item in path.rglob('*') if item.is_file() and item.suffix in valid_extensions ]:
45
+ print(file.name)
46
+ if file.suffix == '.pdf':
47
+ documents = PyPDFLoader(file).load()
48
+ elif file.suffix in {'.html', '.ejs'}:
49
+ documents = UnstructuredHTMLLoader(file).load()
50
+ else:
51
+ continue
52
+ success_cnt += 1
53
+ page_cnt += len(documents)
54
+ docs.extend(documents)
55
+ else:
56
+ print(path.name)
57
+ if path.suffix == '.pdf':
58
+ documents = PyPDFLoader(path).load()
59
+ elif path.suffix in {'.html', '.ejs'}:
60
+ documents = UnstructuredHTMLLoader(path).load()
61
+ else:
62
+ print(f"Unsupported file type: {path}")
63
+ return None, 0, 0
64
+ success_cnt = 1
65
+ page_cnt = len(documents)
66
+ docs.extend(documents)
67
+ else:
68
+ print(f"Error: '{path}' is not a valid file or directory")
69
+ return None, 0, 0
70
+
71
+ if success_cnt == 0:
72
+ print("No valid documents found!")
73
+ return None, 0, 0
74
+
75
+ return docs, success_cnt, page_cnt
76
+
77
+ #%% RAG Chains
78
+
79
+ system_prompt = """You are a helpful assistant answering questions based on provided document snippets.
80
+ IMPORTANT INSTRUCTIONS:
81
+ 1. Base your answer ONLY on the provided context snippets below.
82
+ 2. Synthesize information from multiple snippets if needed.
83
+ 3. If the snippets contain partial information, provide the best answer you can with what's available.
84
+ 4. Only say "There wasn't enough information to answer the question" if the snippets truly contain NO relevant information.
85
+ 5. Always cite your sources using the snippet numbers in parentheses, like (1), (2), etc.
86
+
87
+ The snippets are provided in this format:
88
+ Snippet 1: <text content>
89
+ Snippet 2: <text content>
90
+ ...
91
+
92
+ {context}"""
93
+
94
+ def format_documents(documents: Sequence[Document]) -> str:
95
+ snippets = []
96
+ for i, doc in enumerate(documents, start=1):
97
+ snippets.append(f"Snippet {i}: {doc.page_content}")
98
+ context = "\n".join(snippets)
99
+ return context
100
+
101
+ def rag_basic(retriever: BaseRetriever, model: BaseChatModel) -> Runnable:
102
+ """Return an LCEL chain accepting a question string and producing an answer string."""
103
+ prompt = ChatPromptTemplate.from_messages(
104
+ [
105
+ ( "system", "Answer using only the context below. If the answer is absent, say you do not know.\n\nContext:\n{context}" ),
106
+ ("human", "{query}"),
107
+ ]
108
+ )
109
+ inputs = {
110
+ "context": retriever | format_documents,
111
+ "query": RunnablePassthrough(),
112
+ }
113
+ return inputs | prompt | model | StrOutputParser()
114
+
115
+
116
+ def rag_main(retriever: BaseRetriever, model: BaseChatModel) -> Runnable:
117
+ """Return an LCEL chain accepting a question string and producing an dic with 4 keys/values
118
+ -- 'sources': original list of Documents from retriever
119
+ -- 'query': original query
120
+ -- 'context': merged content from relevant documents
121
+ -- 'answer': final answer """
122
+ prompt = ChatPromptTemplate.from_messages([
123
+ ("system", system_prompt),
124
+ ("human", "{query}")
125
+ ])
126
+ return (
127
+ {
128
+ "sources": retriever,
129
+ "query": RunnablePassthrough(),
130
+ }
131
+ | RunnablePassthrough.assign( context=lambda x: format_documents(x["sources"]) )
132
+ | RunnablePassthrough.assign( answer=prompt | model | StrOutputParser() )
133
+ )
134
+
135
+ build_rag = rag_main
136
+
137
+
138
+ """
139
+ Retrievers are subclasses of LangChain's BaseRetriever which inherits from Pydantic's BaseModel.
140
+ Unlike ordinary Python classes where attributes declared in the class body are class attributes shared by all instances,
141
+ Pydantic model fields declared with type annotations are instance attributes, not shared class attributes, even though they are declared in the class body.
142
+ """
143
+ #%% MultiQueryRerankRetriever
144
+
145
+ class MultiQueryRerankRetriever(BaseRetriever):
146
+ """Chroma retriever with optional multi-query retrieval and reranking."""
147
+ embeddings: Embeddings = Field(exclude=True)
148
+ query_llm: BaseChatModel | None = Field(default=None, exclude=True)
149
+ reranker: Any = Field(default=None, exclude=True)
150
+ k: int = Field(default=3, ge=1)
151
+ top_n: int = Field(default=5, ge=1)
152
+ persist_directory: str | Path
153
+ collection_name: str
154
+ create_collection_if_not_exists: bool = False
155
+ store: Chroma = None
156
+ _base_retriever: BaseRetriever = PrivateAttr()
157
+
158
+ """ model_post_init() is a special method in Pydantic v2 that runs automatically after a model instance has been initialized and its fields have been validated."""
159
+ def model_post_init(self, __context: Any) -> None:
160
+ """Initialize the underlying Chroma retriever. Use the last folder name as collection name"""
161
+ self.store = Chroma( persist_directory=self.persist_directory, embedding_function=self.embeddings, collection_name=self.collection_name, create_collection_if_not_exists=self.create_collection_if_not_exists )
162
+ self._base_retriever = self.store.as_retriever( search_kwargs={"k": self.k} )
163
+ if self.query_llm is not None and self.reranker is None:
164
+ raise ValueError( "A reranker is required when query_llm is provided." )
165
+
166
+ def ingest( self, documents: Sequence[Document], *, source_dir: str = 'placeholder', chunk_size: int = 1000, chunk_overlap: int = 150 ):
167
+ text_splitter = RecursiveCharacterTextSplitter( separators=["\n\n", "\n", ". ", " ", ""], chunk_size=chunk_size, chunk_overlap=chunk_overlap )
168
+ chunks = text_splitter.split_documents( documents )
169
+ self.store.add_documents( chunks )
170
+
171
+ # run_manager manages callbacks, tracing, and monitoring during a retriever's execution, allowing integrations with LangSmith
172
+ def _generate_queries( self, query: str, run_manager: CallbackManagerForRetrieverRun ) -> list[str]:
173
+ """Generate five alternative formulations of the query."""
174
+ 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}" )
175
+ chain = prompt | self.query_llm | StrOutputParser()
176
+ result = chain.invoke( {"question": query}, config={"callbacks": run_manager.get_child()} )
177
+ return [q.strip() for q in result.splitlines() if q.strip()]
178
+
179
+ @staticmethod
180
+ def _unique_union( document_lists: list[list[Document]] ) -> list[Document]:
181
+ """Flatten document lists and remove duplicates."""
182
+ unique_docs = {
183
+ doc.page_content: doc
184
+ for docs in document_lists
185
+ for doc in docs
186
+ }
187
+ return list(unique_docs.values())
188
+
189
+ def _rerank( self, query: str, docs: list[Document] ) -> list[Document]:
190
+ """Rerank retrieved documents using a cross-encoder."""
191
+ if not docs:
192
+ return []
193
+ pairs = [(query, doc.page_content) for doc in docs]
194
+ scores = self.reranker.predict(pairs)
195
+ scored_docs = sorted(
196
+ zip(scores, docs),
197
+ key=lambda x: float(x[0]),
198
+ reverse=True,
199
+ )
200
+ return [doc for _, doc in scored_docs[:self.top_n]]
201
+
202
+ def _get_relevant_documents( self, query: str, *, run_manager: CallbackManagerForRetrieverRun ) -> list[Document]:
203
+ """Retrieve documents using simple or multi-query retrieval."""
204
+ # Simple retrieval
205
+ if self.query_llm is None:
206
+ return self._base_retriever.invoke( query, config={"callbacks": run_manager.get_child()} )
207
+ # Generate alternative queries
208
+ alt_queries = self._generate_queries(query, run_manager)
209
+ 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
210
+ # Retrieve documents for all queries
211
+ doc_lists = self._base_retriever.batch(
212
+ all_queries,
213
+ config={"callbacks": run_manager.get_child()},
214
+ )
215
+ # Deduplicate and rerank
216
+ unique_docs = self._unique_union(doc_lists)
217
+ return self._rerank(query, unique_docs)
218
+
219
+ #%%
220
+
221
+ class DummyEmbeddings(Embeddings):
222
+ """Return constant 1-dimensional embeddings without using an LLM."""
223
+ def embed_documents(self, texts: list[str]) -> list[list[float]]:
224
+ return [[0.0] for _ in texts]
225
+ def embed_query(self, text: str) -> list[float]:
226
+ return [0.0]
227
+
228
+ class ParentDocumentRetriever(BaseRetriever):
229
+ persist_directory: str | Path
230
+ parent_collection: str
231
+ child_collection: str
232
+ embeddings: Embeddings
233
+ k: int = 5
234
+ _parent_store: Chroma = PrivateAttr()
235
+ _child_store: Chroma = PrivateAttr()
236
+
237
+ def __init__(self, **kwargs):
238
+ super().__init__(**kwargs)
239
+ self._parent_store = Chroma(
240
+ embedding_function=DummyEmbeddings(),
241
+ collection_name=self.parent_collection,
242
+ persist_directory=self.persist_directory,
243
+ )
244
+
245
+ self._child_store = Chroma(
246
+ embedding_function=self.embeddings,
247
+ collection_name=self.child_collection,
248
+ persist_directory=self.persist_directory,
249
+ )
250
+
251
+ def ingest(self, docs: list[Document], *, source_dir: str = 'placeholder', parent_chunk_size: int = 600, parent_chunk_overlap: int = 100, child_chunk_size: int = 200, child_chunk_overlap: int = 50) -> None:
252
+ """Split documents and store parent and child chunks."""
253
+ parent_splitter = RecursiveCharacterTextSplitter( chunk_size=parent_chunk_size, chunk_overlap=parent_chunk_overlap )
254
+ child_splitter = RecursiveCharacterTextSplitter( chunk_size=child_chunk_size, chunk_overlap=child_chunk_overlap )
255
+
256
+ parent_docs = []
257
+ parent_ids = []
258
+ child_docs = []
259
+
260
+ for parent in parent_splitter.split_documents(docs):
261
+ parent_id = str(uuid4())
262
+ parent_docs.append(parent)
263
+ parent_ids.append(parent_id)
264
+ for child in child_splitter.split_documents( [parent] ):
265
+ child.metadata["parent_id"] = parent_id
266
+ child_docs.append(child)
267
+ if parent_docs:
268
+ self._parent_store.add_documents( documents=parent_docs, ids=parent_ids )
269
+ if child_docs:
270
+ self._child_store.add_documents( documents=child_docs )
271
+
272
+ def _get_relevant_documents(
273
+ self,
274
+ query: str,
275
+ *,
276
+ run_manager: CallbackManagerForRetrieverRun,
277
+ ) -> list[Document]:
278
+ """Retrieve parent documents using child similarity search."""
279
+
280
+ # 1. Search child chunks
281
+ child_chunks = self._child_store.similarity_search( query, k=self.k )
282
+ # 2. Extract unique parent IDs in relevance order
283
+ parent_ids = list(dict.fromkeys( # standard trick to remove duplicates from a list or iterable while preserving the original item orde
284
+ chunk.metadata["parent_id"] for chunk in child_chunks if "parent_id" in chunk.metadata
285
+ ))
286
+ if not parent_ids:
287
+ return []
288
+ # 3. Fetch parent documents
289
+ parents = self._parent_store.get_by_ids( parent_ids )
290
+ parents_by_id = { doc.id: doc for doc in parents if doc.id is not None }
291
+ # 4. Restore relevance order
292
+ return [
293
+ parents_by_id[pid]
294
+ for pid in parent_ids if pid in parents_by_id
295
+ ]
296
+
297
+
298
+ #%% Hypothetical Question Retriever
299
+
300
+ def _stable_id(source_dir: str, index: int, text: str) -> str:
301
+ digest = hashlib.sha256(f"{source_dir}\0{index}\0{text}".encode()).hexdigest()
302
+ return f"chunk-{digest[:32]}"
303
+
304
+ def _clean_questions(questions: Sequence[str]) -> list[str]:
305
+ clean: list[str] = []
306
+ seen: set[str] = set()
307
+ for value in questions:
308
+ question = " ".join(str(value).split()).strip()
309
+ key = question.casefold()
310
+ if question and key not in seen:
311
+ seen.add(key)
312
+ clean.append(question)
313
+ return clean
314
+
315
+ def _chroma_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
316
+ """Keep Chroma-compatible scalar metadata and drop None/compound values."""
317
+ return {
318
+ str(key): value for key, value in metadata.items()
319
+ if value is not None and isinstance(value, (str, int, float, bool))
320
+ }
321
+
322
+
323
+ class _QuestionList(BaseModel):
324
+ questions: list[str] = Field(description="Distinct questions answered by the text")
325
+
326
+ class HypotheticalQuestionGenerator:
327
+ """LLM-backed question generator using provider-supported structured output."""
328
+
329
+ def __init__(self, llm: BaseChatModel, *, questions_per_chunk: int = 4) -> None:
330
+ if questions_per_chunk < 1:
331
+ raise ValueError("questions_per_chunk must be positive")
332
+ self.questions_per_chunk = questions_per_chunk
333
+ prompt = ChatPromptTemplate.from_messages(
334
+ [
335
+ ( "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."),
336
+ ("human", "Text:\n{text}"),
337
+ ]
338
+ )
339
+ self._chain = prompt | llm.with_structured_output(_QuestionList)
340
+
341
+ def generate(self, document: Document) -> list[str]:
342
+ result = self._chain.invoke( {"count": self.questions_per_chunk, "text": document.page_content} )
343
+ questions = result.questions if isinstance(result, _QuestionList) else result["questions"]
344
+ return _clean_questions(questions)[: self.questions_per_chunk]
345
+
346
+ @dataclass(frozen=True)
347
+ class IngestionResult:
348
+ chunks: int
349
+ hypothetical_questions: int
350
+ chunk_ids: tuple[str, ...]
351
+
352
+ class HypotheticalQuestionRetriever(BaseRetriever):
353
+ """Search question vectors, then resolve their chunk IDs from the chunk collection."""
354
+ llm: Any = None
355
+ persist_directory: str | Path
356
+ embeddings: Embeddings
357
+ question_collection: str
358
+ chunk_collection: str
359
+ question_k: int = 12
360
+ chunk_k: int = 4
361
+ metadata_filter: dict[str, Any] | None = None
362
+ create_collection_if_not_exists: bool = False
363
+ _question_store: Chroma = PrivateAttr()
364
+ _chunk_collection: Any = PrivateAttr()
365
+ _generator: Any = PrivateAttr()
366
+
367
+ model_config = ConfigDict(arbitrary_types_allowed=True)
368
+
369
+ def __init__(self, **kwargs) -> None:
370
+ super().__init__(**kwargs)
371
+ client = chromadb.PersistentClient( path= self.persist_directory )
372
+ self._question_collection = Chroma( client=client, collection_name=self.question_collection, embedding_function=self.embeddings, create_collection_if_not_exists=self.create_collection_if_not_exists )
373
+ self._chunk_collection = client.get_or_create_collection( name=self.chunk_collection, embedding_function=None )
374
+
375
+ def ingest( self, documents: Sequence[Document], *, source_dir: str, split: bool = True, chunk_size: int = 500, chunk_overlap: int = 150, replace_source: bool = True ) -> IngestionResult:
376
+ if not self.llm:
377
+ raise ValueError("To ingest, llm must be provided in the constructor.")
378
+ self._generator = HypotheticalQuestionGenerator( self.llm )
379
+ if not source_dir: # source_dir is a unique identifier for the source document or documents being ingested
380
+ raise ValueError("source_dir must not be empty")
381
+ splitter = RecursiveCharacterTextSplitter( chunk_size=chunk_size, chunk_overlap=chunk_overlap, add_start_index=True )
382
+ input_docs = splitter.split_documents( list(documents) ) if split else list(documents)
383
+
384
+ if replace_source:
385
+ self._question_collection.delete( where={"source_dir": source_dir} )
386
+ self._chunk_collection.delete( where={"source_dir": source_dir} )
387
+
388
+ chunk_ids: list[str] = []
389
+ question_docs: list[Document] = []
390
+ question_ids: list[str] = []
391
+ chunk_metadatas: list[dict[str, Any]] = []
392
+ chunk_texts: list[str] = []
393
+
394
+ for index, doc in enumerate(input_docs):
395
+ chunk_id = _stable_id(source_dir, index, doc.page_content)
396
+ metadata = _chroma_metadata( {**doc.metadata, "source_dir": source_dir, "chunk_id": chunk_id} )
397
+ chunk_ids.append( chunk_id )
398
+ chunk_metadatas.append( metadata )
399
+ chunk_texts.append( doc.page_content )
400
+ questions = _clean_questions( self._generator.generate(doc) )
401
+ for question_index, question in enumerate(questions):
402
+ question_ids.append( f"{chunk_id}:q:{question_index}" )
403
+ question_docs.append(
404
+ Document( page_content=question,
405
+ metadata={"chunk_id": chunk_id, "source_dir": source_dir, "question_index": question_index },
406
+ )
407
+ )
408
+ if question_docs: # LangChain's Chroma.add_documents() triggers embedding
409
+ self._question_collection.add_documents( question_docs, ids=question_ids )
410
+
411
+ if chunk_ids: # Chroma's underlying collection API stores text and metadata without embeddings.
412
+ self._chunk_collection.upsert( ids=chunk_ids, documents=chunk_texts, metadatas=chunk_metadatas,
413
+ embeddings=[[0.0] for _ in chunk_ids] ) # placeholder vectors since Chroma records require vectors
414
+
415
+ return IngestionResult(len(chunk_ids), len(question_docs), tuple(chunk_ids))
416
+
417
+ # override _get_relevant_documents() method from BaseRetriever
418
+ def _get_relevant_documents(self, query: str, *, run_manager: Any = None) -> list[Document]:
419
+ matches = self._question_collection.similarity_search( query, k=self.question_k, filter=self.metadata_filter )
420
+ chunk_ids: list[str] = []
421
+ seen: set[str] = set()
422
+ for m in matches:
423
+ chunk_id = str(m.metadata["chunk_id"])
424
+ if chunk_id not in seen:
425
+ seen.add(chunk_id)
426
+ chunk_ids.append(chunk_id)
427
+ if len(chunk_ids) == self.chunk_k:
428
+ break
429
+ if not chunk_ids:
430
+ return []
431
+
432
+ records = self._chunk_collection.get(ids=chunk_ids, include=["documents", "metadatas"])
433
+ by_id = {
434
+ record_id: Document(page_content=text, metadata=metadata or {})
435
+ for record_id, text, metadata in zip(
436
+ records["ids"], records["documents"], records["metadatas"], strict=True
437
+ )
438
+ }
439
+ return [by_id[chunk_id] for chunk_id in chunk_ids if chunk_id in by_id]
440
+
441
+
442
+
443
+ #%% RAG with Routing
444
+
445
+ domain_prompt = """You are a helpful assistant answering questions based on provided document snippets relating to {domain}.
446
+
447
+ IMPORTANT INSTRUCTIONS:
448
+ 1. Base your answer ONLY on the provided context snippets below.
449
+ 2. Synthesize information from multiple snippets if needed.
450
+ 3. If the snippets contain partial information, provide the best answer you can with what's available.
451
+ 4. Only say "There wasn't enough information to answer the question" if the snippets truly contain NO relevant information.
452
+ 5. Always cite your sources using the snippet numbers in parentheses, like (1), (2), etc.
453
+
454
+ The snippets are provided in this format:
455
+ Snippet 1: <text content>
456
+ Snippet 2: <text content>
457
+ ...
458
+
459
+ {context}"""
460
+
461
+ def multiRagBuilder( persist_directory, domain_literals, router_prompt, llm, embeddings, reranker, query_llm=None, k=3):
462
+ """
463
+ domain_literals: the last one is a placeholder if the router finds no domain matches the query
464
+ """
465
+ domains = get_args(domain_literals)[:-1] # exclude the placeholder
466
+ nodomain = get_args(domain_literals)[-1]
467
+ # use the functional form of TypedDict instead of the class form to accommodate Python 3.14
468
+ State = TypedDict(
469
+ "State",
470
+ {
471
+ "messages": Annotated[list, add_messages],
472
+ "query": str,
473
+ "domain": domain_literals,
474
+ "documents": list[Document],
475
+ "answer": str,
476
+ }
477
+ )
478
+
479
+ class Input(TypedDict):
480
+ query: str
481
+
482
+ Output = TypedDict(
483
+ "Output",
484
+ {
485
+ "domain": domain_literals,
486
+ "documents": list[Document],
487
+ "answer": str
488
+ }
489
+ )
490
+
491
+ def router_node(state) -> State:
492
+ user_message = HumanMessage(state["query"])
493
+ messages = [ SystemMessage(router_prompt), *state["messages"], user_message ]
494
+ res = llm.invoke(messages)
495
+ return {
496
+ "domain": res.content.strip(),
497
+ "messages": [user_message, res], # update conversation history
498
+ }
499
+
500
+ def pick_retriever( state ):
501
+ for domain in domains:
502
+ if re.search( domain, state['domain'] ):
503
+ return domain
504
+ return 'generate_answer'
505
+
506
+ def generate_answer(state) -> State:
507
+ snippets = []
508
+ for i, doc in enumerate(state.get('documents', []), start=1):
509
+ snippets.append(f"Snippet {i}: {doc.page_content}")
510
+ context = "\n".join(snippets)
511
+ if state.get('domain', '')!=nodomain:
512
+ prompt = ChatPromptTemplate.from_messages( [ ("system", domain_prompt), ("human", "{query}") ])
513
+ messages = prompt.format_messages( domain=state.get('domain', ''), context=context, query=state["query"] )
514
+ else:
515
+ prompt = ChatPromptTemplate.from_messages( [ ("system", 'You are a helpful assistant.'), ("human", "{query}") ])
516
+ messages = prompt.format_messages( query=state["query"] )
517
+ res = llm.invoke(messages)
518
+ return {
519
+ "answer": res.content,
520
+ "messages": res, # update conversation history
521
+ }
522
+
523
+ def retriever_node( domain, state):
524
+ query = re.sub(domain, 'this class', state["query"] ) # using the course code hurts RAG since the course code is rarely used in documents.
525
+ #print( f"domain={domain}; query={query}")
526
+ documents = retrievers[domain].invoke( query )
527
+ return { "documents": documents, 'query': query }
528
+
529
+ builder = StateGraph(State, input_schema=Input, output_schema=Output)
530
+ builder.add_node("router", router_node)
531
+ builder.add_edge(START, "router")
532
+ builder.add_conditional_edges("router", pick_retriever)
533
+
534
+ retrievers = {}
535
+ retriever_nodes = {}
536
+ for domain in domains:
537
+ retrievers[domain] = MultiQueryRerankRetriever( persist_directory=persist_directory, collection_name=domain, embeddings=embeddings, query_llm=query_llm, reranker=reranker, k=k)
538
+ 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.
539
+ builder.add_node(domain, retriever_nodes[domain])
540
+ builder.add_edge(domain, "generate_answer")
541
+
542
+ builder.add_node("generate_answer", generate_answer)
543
+ builder.add_edge("generate_answer", END)
544
+
545
+ 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