simonbb 0.0.9__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.
- {simonbb-0.0.9 → simonbb-0.0.10}/PKG-INFO +1 -1
- {simonbb-0.0.9 → simonbb-0.0.10}/pyproject.toml +1 -1
- {simonbb-0.0.9 → simonbb-0.0.10}/src/simonbb/cis438/rag.py +200 -110
- {simonbb-0.0.9 → simonbb-0.0.10}/LICENSE +0 -0
- {simonbb-0.0.9 → simonbb-0.0.10}/README.md +0 -0
- {simonbb-0.0.9 → simonbb-0.0.10}/src/simonbb/__init__.py +0 -0
- {simonbb-0.0.9 → simonbb-0.0.10}/src/simonbb/cis433/__init__.py +0 -0
- {simonbb-0.0.9 → simonbb-0.0.10}/src/simonbb/cis433/actions.py +0 -0
- {simonbb-0.0.9 → simonbb-0.0.10}/src/simonbb/cis433/agent.py +0 -0
- {simonbb-0.0.9 → simonbb-0.0.10}/src/simonbb/cis433/bert.py +0 -0
- {simonbb-0.0.9 → simonbb-0.0.10}/src/simonbb/cis433/dqn.py +0 -0
- {simonbb-0.0.9 → simonbb-0.0.10}/src/simonbb/cis433/experience.py +0 -0
- {simonbb-0.0.9 → simonbb-0.0.10}/src/simonbb/cis433/gpt.py +0 -0
- {simonbb-0.0.9 → simonbb-0.0.10}/src/simonbb/cis433/ignite.py +0 -0
- {simonbb-0.0.9 → simonbb-0.0.10}/src/simonbb/cis433/math500.py +0 -0
- {simonbb-0.0.9 → simonbb-0.0.10}/src/simonbb/cis433/qwen3.py +0 -0
- {simonbb-0.0.9 → simonbb-0.0.10}/src/simonbb/cis433/transformer.py +0 -0
- {simonbb-0.0.9 → simonbb-0.0.10}/src/simonbb/cis433/utils.py +0 -0
- {simonbb-0.0.9 → simonbb-0.0.10}/src/simonbb/cis433/wrappers.py +0 -0
- {simonbb-0.0.9 → simonbb-0.0.10}/src/simonbb/cis438/__init__.py +0 -0
- {simonbb-0.0.9 → simonbb-0.0.10}/src/simonbb/utils.py +0 -0
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import os
|
|
4
3
|
import re
|
|
5
4
|
from typing import Annotated, TypedDict, get_args, Any, Protocol, Sequence
|
|
6
5
|
from dataclasses import dataclass
|
|
@@ -25,6 +24,7 @@ from langchain_community.document_loaders import PyPDFLoader, UnstructuredHTMLLo
|
|
|
25
24
|
from langchain_core.callbacks import CallbackManagerForRetrieverRun
|
|
26
25
|
|
|
27
26
|
import hashlib
|
|
27
|
+
from uuid import uuid4
|
|
28
28
|
import chromadb
|
|
29
29
|
from langchain_core.embeddings import Embeddings
|
|
30
30
|
from langchain_core.language_models import BaseChatModel
|
|
@@ -35,13 +35,10 @@ from langchain_text_splitters import RecursiveCharacterTextSplitter
|
|
|
35
35
|
|
|
36
36
|
def load_documents(path: Path):
|
|
37
37
|
"""Load documents from a file or directory."""
|
|
38
|
-
|
|
39
|
-
valid_extensions = {'.pdf', '.html', '.ejs'}
|
|
40
|
-
|
|
38
|
+
valid_extensions = {'.pdf', '.html', '.ejs'}
|
|
41
39
|
docs = []
|
|
42
40
|
success_cnt = 0
|
|
43
|
-
page_cnt = 0
|
|
44
|
-
|
|
41
|
+
page_cnt = 0
|
|
45
42
|
if path.exists():
|
|
46
43
|
if path.is_dir():
|
|
47
44
|
for file in [ item for item in path.rglob('*') if item.is_file() and item.suffix in valid_extensions ]:
|
|
@@ -77,25 +74,100 @@ def load_documents(path: Path):
|
|
|
77
74
|
|
|
78
75
|
return docs, success_cnt, page_cnt
|
|
79
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
|
+
"""
|
|
80
143
|
#%% MultiQueryRerankRetriever
|
|
81
144
|
|
|
82
145
|
class MultiQueryRerankRetriever(BaseRetriever):
|
|
83
146
|
"""Chroma retriever with optional multi-query retrieval and reranking."""
|
|
84
|
-
knowledge_base: str
|
|
85
147
|
embeddings: Embeddings = Field(exclude=True)
|
|
86
148
|
query_llm: BaseChatModel | None = Field(default=None, exclude=True)
|
|
87
149
|
reranker: Any = Field(default=None, exclude=True)
|
|
88
150
|
k: int = Field(default=3, ge=1)
|
|
89
151
|
top_n: int = Field(default=5, ge=1)
|
|
90
|
-
|
|
152
|
+
persist_directory: str | Path
|
|
153
|
+
collection_name: str
|
|
154
|
+
create_collection_if_not_exists: bool = False
|
|
155
|
+
store: Chroma = None
|
|
91
156
|
_base_retriever: BaseRetriever = PrivateAttr()
|
|
92
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."""
|
|
93
159
|
def model_post_init(self, __context: Any) -> None:
|
|
94
|
-
"""Initialize the underlying Chroma retriever."""
|
|
95
|
-
store = Chroma( persist_directory=
|
|
96
|
-
self._base_retriever = store.as_retriever( search_kwargs={"k": self.k} )
|
|
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} )
|
|
97
163
|
if self.query_llm is not None and self.reranker is None:
|
|
98
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
|
+
|
|
99
171
|
# run_manager manages callbacks, tracing, and monitoring during a retriever's execution, allowing integrations with LangSmith
|
|
100
172
|
def _generate_queries( self, query: str, run_manager: CallbackManagerForRetrieverRun ) -> list[str]:
|
|
101
173
|
"""Generate five alternative formulations of the query."""
|
|
@@ -143,12 +215,90 @@ class MultiQueryRerankRetriever(BaseRetriever):
|
|
|
143
215
|
# Deduplicate and rerank
|
|
144
216
|
unique_docs = self._unique_union(doc_lists)
|
|
145
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
|
+
]
|
|
146
296
|
|
|
147
297
|
|
|
148
298
|
#%% Hypothetical Question Retriever
|
|
149
299
|
|
|
150
|
-
def _stable_id(
|
|
151
|
-
digest = hashlib.sha256(f"{
|
|
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()
|
|
152
302
|
return f"chunk-{digest[:32]}"
|
|
153
303
|
|
|
154
304
|
def _clean_questions(questions: Sequence[str]) -> list[str]:
|
|
@@ -169,9 +319,6 @@ def _chroma_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
|
|
|
169
319
|
if value is not None and isinstance(value, (str, int, float, bool))
|
|
170
320
|
}
|
|
171
321
|
|
|
172
|
-
class QuestionGenerator(Protocol):
|
|
173
|
-
"""Anything capable of generating search questions for a chunk."""
|
|
174
|
-
def generate(self, document: Document) -> list[str]: ...
|
|
175
322
|
|
|
176
323
|
class _QuestionList(BaseModel):
|
|
177
324
|
questions: list[str] = Field(description="Distinct questions answered by the text")
|
|
@@ -179,7 +326,7 @@ class _QuestionList(BaseModel):
|
|
|
179
326
|
class HypotheticalQuestionGenerator:
|
|
180
327
|
"""LLM-backed question generator using provider-supported structured output."""
|
|
181
328
|
|
|
182
|
-
def __init__(self,
|
|
329
|
+
def __init__(self, llm: BaseChatModel, *, questions_per_chunk: int = 4) -> None:
|
|
183
330
|
if questions_per_chunk < 1:
|
|
184
331
|
raise ValueError("questions_per_chunk must be positive")
|
|
185
332
|
self.questions_per_chunk = questions_per_chunk
|
|
@@ -189,7 +336,7 @@ class HypotheticalQuestionGenerator:
|
|
|
189
336
|
("human", "Text:\n{text}"),
|
|
190
337
|
]
|
|
191
338
|
)
|
|
192
|
-
self._chain = prompt |
|
|
339
|
+
self._chain = prompt | llm.with_structured_output(_QuestionList)
|
|
193
340
|
|
|
194
341
|
def generate(self, document: Document) -> list[str]:
|
|
195
342
|
result = self._chain.invoke( {"count": self.questions_per_chunk, "text": document.page_content} )
|
|
@@ -203,38 +350,40 @@ class IngestionResult:
|
|
|
203
350
|
chunk_ids: tuple[str, ...]
|
|
204
351
|
|
|
205
352
|
class HypotheticalQuestionRetriever(BaseRetriever):
|
|
206
|
-
"""Search question vectors, then resolve their chunk IDs from the chunk collection."""
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
211
359
|
question_k: int = 12
|
|
212
360
|
chunk_k: int = 4
|
|
213
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()
|
|
214
366
|
|
|
215
|
-
|
|
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
|
-
)
|
|
367
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
228
368
|
|
|
229
|
-
def
|
|
230
|
-
|
|
231
|
-
|
|
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")
|
|
232
381
|
splitter = RecursiveCharacterTextSplitter( chunk_size=chunk_size, chunk_overlap=chunk_overlap, add_start_index=True )
|
|
233
382
|
input_docs = splitter.split_documents( list(documents) ) if split else list(documents)
|
|
234
383
|
|
|
235
384
|
if replace_source:
|
|
236
|
-
self.
|
|
237
|
-
self.
|
|
385
|
+
self._question_collection.delete( where={"source_dir": source_dir} )
|
|
386
|
+
self._chunk_collection.delete( where={"source_dir": source_dir} )
|
|
238
387
|
|
|
239
388
|
chunk_ids: list[str] = []
|
|
240
389
|
question_docs: list[Document] = []
|
|
@@ -243,32 +392,31 @@ class HypotheticalQuestionRetriever(BaseRetriever):
|
|
|
243
392
|
chunk_texts: list[str] = []
|
|
244
393
|
|
|
245
394
|
for index, doc in enumerate(input_docs):
|
|
246
|
-
chunk_id = _stable_id(
|
|
247
|
-
metadata = _chroma_metadata( {**doc.metadata, "
|
|
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} )
|
|
248
397
|
chunk_ids.append( chunk_id )
|
|
249
398
|
chunk_metadatas.append( metadata )
|
|
250
399
|
chunk_texts.append( doc.page_content )
|
|
251
|
-
|
|
252
|
-
questions = _clean_questions( self.generator.generate(doc) )
|
|
400
|
+
questions = _clean_questions( self._generator.generate(doc) )
|
|
253
401
|
for question_index, question in enumerate(questions):
|
|
254
402
|
question_ids.append( f"{chunk_id}:q:{question_index}" )
|
|
255
403
|
question_docs.append(
|
|
256
404
|
Document( page_content=question,
|
|
257
|
-
metadata={"chunk_id": chunk_id, "
|
|
405
|
+
metadata={"chunk_id": chunk_id, "source_dir": source_dir, "question_index": question_index },
|
|
258
406
|
)
|
|
259
407
|
)
|
|
260
408
|
if question_docs: # LangChain's Chroma.add_documents() triggers embedding
|
|
261
|
-
self.
|
|
409
|
+
self._question_collection.add_documents( question_docs, ids=question_ids )
|
|
262
410
|
|
|
263
411
|
if chunk_ids: # Chroma's underlying collection API stores text and metadata without embeddings.
|
|
264
|
-
self.
|
|
412
|
+
self._chunk_collection.upsert( ids=chunk_ids, documents=chunk_texts, metadatas=chunk_metadatas,
|
|
265
413
|
embeddings=[[0.0] for _ in chunk_ids] ) # placeholder vectors since Chroma records require vectors
|
|
266
414
|
|
|
267
415
|
return IngestionResult(len(chunk_ids), len(question_docs), tuple(chunk_ids))
|
|
268
416
|
|
|
269
417
|
# override _get_relevant_documents() method from BaseRetriever
|
|
270
418
|
def _get_relevant_documents(self, query: str, *, run_manager: Any = None) -> list[Document]:
|
|
271
|
-
matches = self.
|
|
419
|
+
matches = self._question_collection.similarity_search( query, k=self.question_k, filter=self.metadata_filter )
|
|
272
420
|
chunk_ids: list[str] = []
|
|
273
421
|
seen: set[str] = set()
|
|
274
422
|
for m in matches:
|
|
@@ -281,7 +429,7 @@ class HypotheticalQuestionRetriever(BaseRetriever):
|
|
|
281
429
|
if not chunk_ids:
|
|
282
430
|
return []
|
|
283
431
|
|
|
284
|
-
records = self.
|
|
432
|
+
records = self._chunk_collection.get(ids=chunk_ids, include=["documents", "metadatas"])
|
|
285
433
|
by_id = {
|
|
286
434
|
record_id: Document(page_content=text, metadata=metadata or {})
|
|
287
435
|
for record_id, text, metadata in zip(
|
|
@@ -290,65 +438,7 @@ class HypotheticalQuestionRetriever(BaseRetriever):
|
|
|
290
438
|
}
|
|
291
439
|
return [by_id[chunk_id] for chunk_id in chunk_ids if chunk_id in by_id]
|
|
292
440
|
|
|
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
441
|
|
|
351
|
-
build_rag = rag_main
|
|
352
442
|
|
|
353
443
|
#%% RAG with Routing
|
|
354
444
|
|
|
@@ -368,7 +458,7 @@ Snippet 2: <text content>
|
|
|
368
458
|
|
|
369
459
|
{context}"""
|
|
370
460
|
|
|
371
|
-
def multiRagBuilder( domain_literals, router_prompt, llm, embeddings, reranker, query_llm=None, k=3):
|
|
461
|
+
def multiRagBuilder( persist_directory, domain_literals, router_prompt, llm, embeddings, reranker, query_llm=None, k=3):
|
|
372
462
|
"""
|
|
373
463
|
domain_literals: the last one is a placeholder if the router finds no domain matches the query
|
|
374
464
|
"""
|
|
@@ -444,7 +534,7 @@ def multiRagBuilder( domain_literals, router_prompt, llm, embeddings, reranker,
|
|
|
444
534
|
retrievers = {}
|
|
445
535
|
retriever_nodes = {}
|
|
446
536
|
for domain in domains:
|
|
447
|
-
retrievers[domain] = MultiQueryRerankRetriever(
|
|
537
|
+
retrievers[domain] = MultiQueryRerankRetriever( persist_directory=persist_directory, collection_name=domain, embeddings=embeddings, query_llm=query_llm, reranker=reranker, k=k)
|
|
448
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.
|
|
449
539
|
builder.add_node(domain, retriever_nodes[domain])
|
|
450
540
|
builder.add_edge(domain, "generate_answer")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|