Commit 86b7f2a0 authored by minhnguyengp1's avatar minhnguyengp1
Browse files

baseline done!

parent db448d99
import os
from typing import Dict, Optional, Union, List, TypedDict, Any, Literal from typing import Dict, Optional, Union, List, TypedDict, Any, Literal
from dotenv import load_dotenv from dotenv import load_dotenv
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage, BaseMessage from langchain_core.messages import HumanMessage, AIMessage, SystemMessage, BaseMessage
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import JsonOutputParser
from langgraph.graph import MessagesState, StateGraph, END from langgraph.graph import MessagesState, StateGraph, END
import json from agents.message_filter import MessageFilter
from agents.rag_agent.buffer_memory import WindowBufferMemory
from config import Config from config import Config
from agents.rag_agent import ResearchAssistantRAG from agents.rag_agent import ResearchAssistantRAG
from langgraph.checkpoint.memory import MemorySaver from langgraph.checkpoint.memory import MemorySaver
...@@ -18,92 +16,39 @@ memory = MemorySaver() ...@@ -18,92 +16,39 @@ memory = MemorySaver()
thread_config = {"configurable": {"thread_id": "1"}} thread_config = {"configurable": {"thread_id": "1"}}
class AgentConfig:
DECISION_MODEL = "gpt-4o"
CONFIDENCE_THRESHOLD = 0.85
DECISION_SYSTEM_PROMPT = """
Du bist ein intelligentes akademisches Routingsystem, das Benutzeranfragen an den jeweils passenden spezialisierten Agenten weiterleitet.
Deine Aufgabe ist es, die Anfrage des Benutzers zu analysieren und zu bestimmen, welcher Agent am besten geeignet ist –
basierend auf dem Inhalt der Anfrage und dem Gesprächskontext.
Verfügbare Agenten:
1. CONVERSATION_AGENT – Für allgemeine Konversation, Begrüßungen oder nicht-akademische Fragen.
2. KEYWORD_SEARCH_AGENT – Für gezielte Stichwortsuchen innerhalb der Dokumentensammlung.
3. RAG_AGENT – Für Fragen zu spezifischen akademischen Inhalten, die aus den bereitgestellten Dokumenten beantwortet werden können.
4. SUMMARIZATION_AGENT – Für Zusammenfassungen von Dokumenten oder bestimmten Abschnitten.
5. CLASSIFICATION_AGENT – Für die Erkennung des Dokumententyps (z. B. Bachelorarbeit, Artikel) und der Hauptthemen (z. B. Informatik, Medizin).
6. USER_MEMORY_AGENT – Interner Agent zur Verwaltung von Benutzerhistorie und Personalisierung.
Triff deine Entscheidung anhand der folgenden Richtlinien:
- Wenn die Anfrage eine Begrüßung, allgemeine Frage oder unklare Formulierung enthält → wähle CONVERSATION_AGENT.
- Wenn die Anfrage eine gezielte Suche nach einem Begriff oder Schlüsselwort ist → wähle KEYWORD_SEARCH_AGENT.
- Wenn der Benutzer eine inhaltliche Frage zu einem Thema aus den Dokumenten stellt → wähle RAG_AGENT.
- Wenn eine Zusammenfassung eines Dokuments oder Abschnitts gewünscht ist → wähle SUMMARIZATION_AGENT.
- Wenn nach Art oder Thema eines Dokuments gefragt wird → wähle CLASSIFICATION_AGENT.
- Wenn die Absicht nicht eindeutig ist, wähle sicherheitshalber CONVERSATION_AGENT.
Du musst deine Antwort im JSON-Format mit folgender Struktur liefern:
{
"agent": "AGENT_NAME",
"reasoning": "Schrittweise Begründung für die Wahl dieses Agenten",
"confidence": 0.95 // Wert zwischen 0.0 und 1.0, der das Vertrauen in die Entscheidung angibt
}
"""
class AgentDecision(TypedDict):
agent: Literal[
"CONVERSATION_AGENT",
"KEYWORD_SEARCH_AGENT",
"RAG_AGENT",
"SUMMARIZATION_AGENT",
"CLASSIFICATION_AGENT",
"USER_MEMORY_AGENT"
]
reasoning: str
confidence: float
class AgentState(MessagesState): class AgentState(MessagesState):
"""State maintained across the workflow.""" """State maintained across the workflow."""
messages: List[BaseMessage] messages: List[BaseMessage]
agent_name: Optional[str]
current_input: Optional[Union[str, Dict]] current_input: Optional[Union[str, Dict]]
has_image: bool
image_type: Optional[str]
output: Optional[str] output: Optional[str]
needs_human_validation: bool
retrieval_confidence: float retrieval_confidence: float
bypass_routing: bool
insufficient_info: bool
# class AgentDecision(TypedDict):
# agent: str
# reasoning: str
# confidence: float
def create_agent_graph(): def create_agent_graph():
message_filter = MessageFilter(config.rag.llm)
def run_rag_agent(state: AgentState) -> AgentState: def run_rag_agent(state: AgentState) -> AgentState:
print(f"Selected agent: RAG_AGENT") print(f"Selected agent: RAG_AGENT")
new_state = state.copy()
rag_agent = ResearchAssistantRAG(config) rag_agent = ResearchAssistantRAG(config)
messages: List[BaseMessage] = state["messages"] messages: List[BaseMessage] = state["messages"]
query: Union[str, Dict] = state["current_input"] query: Union[str, Dict] = state["current_input"]
rag_context_limit: int = config.rag.context_limit rag_context_limit: int = config.rag.context_limit
recent_context: str = "" print(f"DEBUG messages: {messages}")
for msg in messages[-rag_context_limit:]:
if isinstance(msg, HumanMessage): buffer_memory = WindowBufferMemory(context_limit=rag_context_limit)
# print("######### DEBUG 1:", msg) chat_history: List[Dict[str, str]] = buffer_memory.build_context(messages)
recent_context += f"User: {msg.content}\n"
elif isinstance(msg, AIMessage): print("DEBUG chat_history input:", chat_history)
# print("######### DEBUG 2:", msg) print("DEBUG type of chat_history:", type(chat_history))
recent_context += f"Assistant: {msg.content}\n"
# Call pipeline RAG if chat_history:
response: Dict[str, Any] = rag_agent.process_query(query, chat_history=recent_context) for i, msg in enumerate(chat_history):
print(f"DEBUG chat_history[{i}]: {msg} (type: {type(msg)})")
response: Dict[str, Any] = rag_agent.process_query(query, chat_history=chat_history)
### CHECKPOINT ### CHECKPOINT
print("Final response from RAG agent:", response) print("Final response from RAG agent:", response)
...@@ -124,6 +69,7 @@ def create_agent_graph(): ...@@ -124,6 +69,7 @@ def create_agent_graph():
print(f"Response text type: {type(response_text)}") print(f"Response text type: {type(response_text)}")
print(f"Response text preview: {response_text[:100]}...") print(f"Response text preview: {response_text[:100]}...")
# TODO: need to be improved because the logic is still "lord"
if isinstance(response_text, str) and ( if isinstance(response_text, str) and (
"I don't have enough information to answer this question based on the provided context" in response_text or "I don't have enough information to answer this question based on the provided context" in response_text or
"I don't have enough information" in response_text or "I don't have enough information" in response_text or
...@@ -135,32 +81,79 @@ def create_agent_graph(): ...@@ -135,32 +81,79 @@ def create_agent_graph():
): ):
print("RAG response indicates insufficient information") print("RAG response indicates insufficient information")
print(f"Response text that triggered insufficient_info: {response_text[:100]}...") print(f"Response text that triggered insufficient_info: {response_text[:100]}...")
insufficient_info = True
print(f"Insufficient info flag set to: {insufficient_info}")
# Store RAG output ONLY if confidence is high # Store RAG output ONLY if confidence is high
if retrieval_confidence >= config.rag.min_retrieval_confidence: if retrieval_confidence >= config.rag.min_retrieval_confidence:
temp_output = response["response"] response_output = AIMessage(content=response_text)
else: else:
temp_output = response["response"] response_output = AIMessage(content=response_text) # should be = ""
return { updated_messages = messages + [AIMessage(content=response_text)]
new_state.update({
**state, **state,
"output": temp_output, "messages": updated_messages,
"needs_human_validation": False, "output": response_output,
"retrieval_confidence": retrieval_confidence, "retrieval_confidence": retrieval_confidence,
"agent_name": "RAG_AGENT", })
"insufficient_info": insufficient_info
} print(f"new_state in run_rag_agent: {new_state}")
return new_state
def apply_message_filter(state: AgentState) -> AgentState:
"""Apply output guardrails to the generated response."""
new_state = state.copy()
output = state["output"]
current_input = state["current_input"]
# Check if output is valid
if not output or not isinstance(output, (str, AIMessage)):
return state
# Get the original input text
input_text = ""
if isinstance(current_input, str):
input_text = current_input
elif isinstance(current_input, dict):
input_text = current_input.get("text", "")
output_text = ""
if isinstance(output, str):
output_text = output
elif isinstance(output, AIMessage):
output_text = output.content
sanitized_output = message_filter.validate_output(output_text, input_text)
# For non-validation cases, add the sanitized output to messages
sanitized_message = AIMessage(content=sanitized_output) if isinstance(output, AIMessage) else sanitized_output
updated_messages = state.get("messages", [])
if isinstance(updated_messages, list):
updated_messages = updated_messages + [sanitized_message]
else:
updated_messages = [sanitized_message] # ✅ Thêm fallback nếu messages bị sai kiểu
new_state.update({
**state,
"messages": updated_messages,
"output": sanitized_message
})
print(f"new_state in apply_message_filter: {new_state}")
return new_state
# === Create LangGraph ===
graph = StateGraph(AgentState) graph = StateGraph(AgentState)
graph.add_node("RAG_AGENT", run_rag_agent) graph.add_node("RAG_AGENT", run_rag_agent)
graph.add_node("MESSAGE_FILTER", apply_message_filter)
graph.set_entry_point("RAG_AGENT") graph.set_entry_point("RAG_AGENT")
graph.add_edge("RAG_AGENT", "MESSAGE_FILTER")
graph.add_edge("RAG_AGENT", END) graph.add_edge("MESSAGE_FILTER", END)
return graph.compile(checkpointer=memory) return graph.compile(checkpointer=memory)
...@@ -169,31 +162,29 @@ def init_agent_state() -> AgentState: ...@@ -169,31 +162,29 @@ def init_agent_state() -> AgentState:
"""Initialize the agent state with default values.""" """Initialize the agent state with default values."""
return { return {
"messages": [], "messages": [],
"agent_name": None,
"current_input": None, "current_input": None,
"has_image": False,
"image_type": None,
"output": None, "output": None,
"needs_human_validation": False,
"retrieval_confidence": 0.0, "retrieval_confidence": 0.0,
"bypass_routing": True,
"insufficient_info": False
} }
def process_query(query: Union[str, Dict], conversation_history: List[BaseMessage] = None) -> Dict[str, Any]: def process_query(query: Union[str, Dict], conversation_history: List[BaseMessage] = None) -> Dict[str, Any]:
graph = create_agent_graph() graph = create_agent_graph()
state = init_agent_state() state = init_agent_state()
# if conversation_history:
# state["messages"] = conversation_history print(f"conversation_history {conversation_history}")
input_text = query if isinstance(query, str) else query.get("text", "") input_text = query if isinstance(query, str) else query.get("text", "")
if conversation_history:
state["messages"] = conversation_history
else:
state["messages"] = [HumanMessage(content=input_text)]
state["current_input"] = query state["current_input"] = query
state["messages"] = [HumanMessage(content=input_text)]
# result = graph.invoke(state, thread_config) print(f"state first: {state}")
state = graph.invoke(state, thread_config) state = graph.invoke(state, thread_config)
output = state.get("output") output = state.get("output")
...@@ -211,6 +202,5 @@ def process_query(query: Union[str, Dict], conversation_history: List[BaseMessag ...@@ -211,6 +202,5 @@ def process_query(query: Union[str, Dict], conversation_history: List[BaseMessag
return { return {
"response": response_text, "response": response_text,
"agent_name": state.get("agent_name"),
"messages": state.get("messages", []) "messages": state.get("messages", [])
} }
\ No newline at end of file
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import PromptTemplate
from langchain_core.messages import AIMessage
from langchain_core.runnables import RunnablePassthrough
class MessageFilter:
def __init__(self, llm):
self.llm = llm
self.input_filter_prompt = PromptTemplate.from_template("")
self.output_filter_prompt = PromptTemplate.from_template("")
self.input_filter_chain = self.input_filter_prompt | self.llm | StrOutputParser()
self.output_filter_chain = self.output_filter_prompt | self.llm | StrOutputParser()
def validate_user_input(self, user_input: str) -> tuple[bool, AIMessage | str]:
result = self.input_filter_chain.invoke({"input": user_input})
return True, user_input
def validate_output(self, output: str, user_input: str = "") -> str:
if not output:
return output
output_text = ""
if isinstance(output, str):
output_text = output
elif hasattr(output, "content"):
output_text = output.content
return output_text
...@@ -4,11 +4,15 @@ from typing import List, Dict, Any, Optional ...@@ -4,11 +4,15 @@ from typing import List, Dict, Any, Optional
import uuid import uuid
from pathlib import Path from pathlib import Path
import json import json
from .query_expander import QueryExpander
from .vector_store import QdrantRetriever from .vector_store import QdrantRetriever
from .document_processor import DocumentProcessor from .document_processor import DocumentProcessor
from .query_processor import QueryProcessor from .query_processor import QueryProcessor
from .response_generator import ResponseGenerator from .response_generator import ResponseGenerator
from .data_ingestion import DataIngestion from .data_ingestion import DataIngestion
from .multi_query_generator import MultiQueryGenerator
from .query_rewriter import QueryRewriter
class ResearchAssistantRAG: class ResearchAssistantRAG:
...@@ -27,10 +31,14 @@ class ResearchAssistantRAG: ...@@ -27,10 +31,14 @@ class ResearchAssistantRAG:
self.embedding_model = self.config.rag.embedding_model self.embedding_model = self.config.rag.embedding_model
self.logger.info(f"Using embedding model: {type(self.embedding_model).__name__}") self.logger.info(f"Using embedding model: {type(self.embedding_model).__name__}")
self.sparse_embedder = self.config.rag.sparse_embedder
self.query_expander = QueryExpander(self.config)
self.query_rewriter = QueryRewriter(self.llm)
self.query_processor = QueryProcessor(self.config, self.embedding_model) self.query_processor = QueryProcessor(self.config, self.embedding_model, self.sparse_embedder)
self.document_processor = DocumentProcessor(self.config, self.embedding_model) self.document_processor = DocumentProcessor(self.config, self.embedding_model)
self.query_generator = MultiQueryGenerator(llm=self.llm, max_queries=self.config.rag.max_queries or 5)
self.retriever = QdrantRetriever(self.config) self.retriever = QdrantRetriever(self.config)
...@@ -58,16 +66,82 @@ class ResearchAssistantRAG: ...@@ -58,16 +66,82 @@ class ResearchAssistantRAG:
def process_query(self, query: str, chat_history: Optional[List[Dict[str, str]]] = None) -> Dict[str, Any]: def process_query(self, query: str, chat_history: Optional[List[Dict[str, str]]] = None) -> Dict[str, Any]:
self.logger.info(f"RAG Agent processing query: {query}") self.logger.info(f"RAG Agent processing query: {query}")
# Process query and return result, passing chat_history
result = self.query(query, chat_history) result = self.query(query, chat_history)
return result return result
def query(self, query: str, chat_history: Optional[List[Dict[str, str]]] = None) -> Dict[str, Any]:
exec_count = 1
self.logger.info(f"Processing query: {query}")
start_time = time.time()
try:
query_embedding: Dict[str, Any]
filters: Dict[str, Any]
if chat_history:
query = self.query_rewriter.rewrite(query, chat_history)
query_embedding, filters = self.query_processor.process_query(query)
### DEBUG
# dense_vector = query_embedding.get("dense")
# sparse_vector = query_embedding.get("sparse")
#
# if dense_vector:
# print("DEBUG dense_vector[:5]:", dense_vector[:5])
# print("DEBUG dense_vector length:", len(dense_vector))
#
# if sparse_vector:
# print("DEBUG sparse_vector type:", type(sparse_vector))
# print("DEBUG sparse_vector indices[:5]:", getattr(sparse_vector, "indices", [])[:5])
# print("DEBUG sparse_vector values[:5]:", getattr(sparse_vector, "values", [])[:5])
#
# print("DEBUG has as_object:", hasattr(sparse_vector, "as_object"))
###
# TODO: Enhance retrieval by incorporating chat history context into the query embedding or retrieval strategy
search_results = self._run_vector_search(query_embedding, filters, query)
if self.similarity_threshold > 0:
search_results = [item for item in search_results if item.get('score', 0) >= self.similarity_threshold]
self.logger.info(f"{len(search_results)} results passed similarity threshold")
print(f"DEBUG chat_history at {exec_count}th time: \n{chat_history}")
exec_count += 1
response = self.response_generator.generate_response(
query=query,
retrieved_docs=search_results,
chat_history=chat_history
)
processing_time = time.time() - start_time
response["processing_time"] = processing_time
return response
except Exception as e:
self.logger.error(f"Error processing query: {e}")
import traceback
self.logger.error(traceback.format_exc())
return {
"response": f"I encountered an error while processing your query: {str(e)}",
"sources": [],
"confidence": 0.0,
"processing_time": time.time() - start_time
}
def ingest_documents(self, documents: List[Dict[str, Any]]) -> Dict[str, Any]: def ingest_documents(self, documents: List[Dict[str, Any]]) -> Dict[str, Any]:
self.logger.info(f"Ingesting {len(documents)} documents") self.logger.info(f"Ingesting {len(documents)} documents")
start_time = time.time() start_time = time.time()
print(f"documents: {documents}") ###
for i, doc in enumerate(documents):
print(f"Document {i} keys: {list(doc.keys())}")
###
try: try:
processed_dir = Path("data/processed") processed_dir = Path("data/processed")
...@@ -78,11 +152,12 @@ class ResearchAssistantRAG: ...@@ -78,11 +152,12 @@ class ResearchAssistantRAG:
for document in documents: for document in documents:
content = document.get("content", "") content = document.get("content", "")
metadata = document.get("metadata", {}) metadata = document.get("metadata", {})
elements = document.get("elements", [])
if "id" not in metadata: if "id" not in metadata:
metadata["id"] = str(uuid.uuid4()) metadata["id"] = str(uuid.uuid4())
chunks_from_file = self.document_processor.process_document(content, metadata) chunks_from_file = self.document_processor.process_document(content, metadata, elements)
if chunks_from_file: if chunks_from_file:
all_chunks.extend(chunks_from_file) all_chunks.extend(chunks_from_file)
...@@ -190,54 +265,71 @@ class ResearchAssistantRAG: ...@@ -190,54 +265,71 @@ class ResearchAssistantRAG:
pass pass
def _retrieve_documents(self, query_embedding: List[float], filters: Dict[str, Any], query: str): def _run_vector_search(self, query_embedding: Dict[str, Any], filters: Dict[str, Any], query: str):
self.logger.info(f"Retrieving documents with filters: {filters}") self.logger.info(f"Running vector search with filters: {filters}")
retrieved_docs = self.retriever.retrieve( search_results = self.retriever.retrieve(
query_vector=query_embedding, query_embedding=query_embedding,
filters=filters, filters=filters,
query_text=query
) )
self.logger.info(f"Retrieved {len(retrieved_docs)} documents") self.logger.info(f"Retrieved {len(search_results)} results from vector search")
return retrieved_docs return search_results
# def query(self, query: str, chat_history: Optional[List[Dict[str, str]]] = None) -> Dict[str, Any]:
def query(self, query: str, chat_history: Optional[List[Dict[str, str]]] = None) -> Dict[str, Any]: # exec_count = 1
self.logger.info(f"Processing query: {query}") # self.logger.info(f"Processing query: {query}")
start_time = time.time() # start_time = time.time()
#
try: # try:
query_embedding, filters = self.query_processor.process_query(query) # # query_embedding: Dict[str, Any]
# # filters: Dict[str, Any]
# TODO: Enhance retrieval by incorporating chat history context into the query embedding or retrieval strategy # # query_embedding, filters = self.query_processor.process_query(query)
retrieved_docs = self._retrieve_documents(query_embedding, filters, query) #
# # # TODO: Enhance retrieval by incorporating chat history context into the query embedding or retrieval strategy
if self.similarity_threshold > 0: # # search_results = self._run_vector_search(query_embedding, filters, query)
retrieved_docs = [doc for doc in retrieved_docs if doc.get('score', 0) >= self.similarity_threshold] #
self.logger.info(f"After similarity threshold: {len(retrieved_docs)} documents") # # Step 1: Generate multiple sub-queries from the user query and chat history
# sub_queries = self.query_generator.generate_queries(
###CHECKPOINT 1c. # user_question=query,
# chat_history=chat_history,
response = self.response_generator.generate_response( # )
query=query, #
retrieved_docs=retrieved_docs, # print("sub_queries: ", sub_queries)
chat_history=chat_history #
) # all_results = []
# for sq in sub_queries:
processing_time = time.time() - start_time # sq_embedding, sq_filters = self.query_processor.process_query(sq)
response["processing_time"] = processing_time # results = self._run_vector_search(sq_embedding, sq_filters, sq)
# all_results.extend(results)
return response #
# # 🧼 Lọc trùng theo nội dung
except Exception as e: # seen = set()
self.logger.error(f"Error processing query: {e}") # deduped_results = []
import traceback # for item in all_results:
self.logger.error(traceback.format_exc()) # content = item.get("content")
# if content and content not in seen:
return { # deduped_results.append(item)
"response": f"I encountered an error while processing your query: {str(e)}", # seen.add(content)
"sources": [], #
"confidence": 0.0, # search_results = deduped_results
"processing_time": time.time() - start_time #
} # print(f"search_results: {search_results}")
\ No newline at end of file #
# if self.similarity_threshold > 0:
# search_results = [item for item in search_results if item.get('score', 0) >= self.similarity_threshold]
# self.logger.info(f"{len(search_results)} results passed similarity threshold")
#
# print(f"DEBUG chat_history at {exec_count}th time: \n{chat_history}")
# exec_count += 1
#
# response = self.response_generator.generate_response(
# query=query,
# retrieved_docs=search_results,
# chat_history=chat_history
# )
#
# processing_time = time.time() - start_time
# response["processing_time"] = processing_time
#
# return response
from typing import List, Union, Dict
from langchain.schema import BaseMessage, HumanMessage, AIMessage, SystemMessage
class WindowBufferMemory:
def __init__(self, context_limit: int = 5):
self.context_limit = context_limit
def build_context(self, messages: List[BaseMessage]) -> List[Dict[str, str]]:
"""
Trả về các message gần nhất dưới dạng List[Dict], giữ đúng cấu trúc
"""
recent_messages = messages[-self.context_limit:]
history = []
for message in recent_messages:
if isinstance(message, HumanMessage):
history.append({"role": "user", "content": message.content})
elif isinstance(message, AIMessage):
history.append({"role": "assistant", "content": message.content})
elif isinstance(message, SystemMessage):
history.append({"role": "system", "content": message.content})
else:
history.append({"role": "unknown", "content": getattr(message, "content", "")})
return history
...@@ -9,6 +9,8 @@ from datetime import datetime ...@@ -9,6 +9,8 @@ from datetime import datetime
import nltk import nltk
from nltk.tokenize import sent_tokenize from nltk.tokenize import sent_tokenize
import json import json
# from sparse_embedder import GermanBM25SparseEmbedder
from fastembed import SparseTextEmbedding
try: try:
nltk.data.find('tokenizers/punkt') nltk.data.find('tokenizers/punkt')
...@@ -20,10 +22,9 @@ def make_serializable(metadata: Dict[str, Any]) -> Dict[str, Any]: ...@@ -20,10 +22,9 @@ def make_serializable(metadata: Dict[str, Any]) -> Dict[str, Any]:
serializable_metadata = {} serializable_metadata = {}
for key, value in metadata.items(): for key, value in metadata.items():
try: try:
json.dumps(value) # Test if value is serializable json.dumps(value)
serializable_metadata[key] = value serializable_metadata[key] = value
except TypeError: except TypeError:
# Handle non-serializable objects
if isinstance(value, Header): if isinstance(value, Header):
serializable_metadata[key] = str(value) # Convert Header to string serializable_metadata[key] = str(value) # Convert Header to string
else: else:
...@@ -40,8 +41,10 @@ class DocumentProcessor: ...@@ -40,8 +41,10 @@ class DocumentProcessor:
self.processed_docs_dir = Path(config.rag.processed_docs_dir) self.processed_docs_dir = Path(config.rag.processed_docs_dir)
self.processed_docs_dir.mkdir(parents=True, exist_ok=True) self.processed_docs_dir.mkdir(parents=True, exist_ok=True)
self.chunking_strategy = getattr(config.rag, "chunking_strategy", "sliding_window") self.chunking_strategy = getattr(config.rag, "chunking_strategy", "sliding_window")
# self.sparse_embedder = GermanBM25SparseEmbedder()
self.sparse_embedder = SparseTextEmbedding("Qdrant/bm25")
def process_document(self, content: str, metadata: Dict[str, Any]) -> List[Dict[str, Any]]: def process_document(self, content: str, metadata: Dict[str, Any], elements: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
try: try:
doc_id_base = hashlib.md5(content.encode()).hexdigest() doc_id_base = hashlib.md5(content.encode()).hexdigest()
doc_id = str(uuid.UUID(doc_id_base[:32])) doc_id = str(uuid.UUID(doc_id_base[:32]))
...@@ -50,19 +53,42 @@ class DocumentProcessor: ...@@ -50,19 +53,42 @@ class DocumentProcessor:
enhanced_metadata['processing_timestamp'] = datetime.now().isoformat() enhanced_metadata['processing_timestamp'] = datetime.now().isoformat()
if self.chunking_strategy == "semantic": if self.chunking_strategy == "semantic":
chunks = self._create_semantic_chunks(content) chunks = self._create_semantic_chunks(content, elements)
elif self.chunking_strategy == "sliding_window":
chunks = self._create_sliding_window_chunks(content)
else: else:
chunks = self._create_sliding_window_chunks(content) chunks = self._create_sliding_window_chunks(content)
processed_chunks = [] processed_chunks = []
for i, chunk_data in enumerate(chunks): for i, chunk_info in enumerate(chunks):
if isinstance(chunk_data, tuple): if isinstance(chunk_info, tuple):
chunk_text = chunk_data[0] chunk_text, section, level = chunk_info[0], chunk_info[1], chunk_info[2] if len(chunk_info) > 2 else "standard"
else: else:
chunk_text = chunk_data chunk_text, section, level = chunk_info, "general", "standard"
# Generate chunk ID as a UUID with a suffix
chunk_id = str(uuid.UUID(doc_id_base[:24] + f"{i:08}")) chunk_id = str(uuid.UUID(doc_id_base[:24] + f"{i:08}"))
embedding = self.embedding_model.embed_documents([chunk_text])[0]
print(f"DEBUGG chunk_text: {chunk_text}")
print(f"DEBUGG section: {section}")
# Generate embedding
dense_embedding = self.embedding_model.embed_documents([chunk_text])[0]
sparse_embeddings = list(
self.sparse_embedder.passage_embed([chunk_text])) # Lấy sparse embedding từ passage_embed
# Đảm bảo rằng sparse_embeddings không rỗng và lấy phần tử đầu tiên
if sparse_embeddings:
sparse_embedding = sparse_embeddings[0]
# In ra các chỉ số và giá trị của sparse_embedding
print(f"DEBUGG sparse_embedding indices (first 5): {sparse_embedding.indices[:5]}")
print(f"DEBUGG sparse_embedding values (first 5): {sparse_embedding.values[:5]}")
else:
sparse_embedding = {}
# Print embeddings to debug
print(f"DEBUGG dense_embedding (first 5 elements): {dense_embedding[:5]}") # Print first 5 elements for brevity
chunk_metadata = enhanced_metadata.copy() chunk_metadata = enhanced_metadata.copy()
chunk_metadata["chunk_number"] = i chunk_metadata["chunk_number"] = i
...@@ -73,7 +99,10 @@ class DocumentProcessor: ...@@ -73,7 +99,10 @@ class DocumentProcessor:
processed_chunks.append({ processed_chunks.append({
"id": chunk_id, "id": chunk_id,
"content": chunk_text, "content": chunk_text,
"embedding": embedding, "embedding": {
"dense": dense_embedding,
"sparse": sparse_embedding
},
"metadata": make_serializable(chunk_metadata) "metadata": make_serializable(chunk_metadata)
}) })
...@@ -83,29 +112,53 @@ class DocumentProcessor: ...@@ -83,29 +112,53 @@ class DocumentProcessor:
self.logger.error(f"Error processing document: {e}") self.logger.error(f"Error processing document: {e}")
raise raise
def _create_semantic_chunks(self, text: str) -> List[str]: def _create_semantic_chunks(self, content: str, elements: List[Dict[str, Any]]) -> List[Tuple[str, str, str]]:
paragraphs = re.split(r'\n\s*\n', text) """
Create semantic chunks from document based on 'elements' with category-based sectioning.
Falls back to paragraph-based chunking if elements are missing.
Returns:
List of (chunk_text, section_name, chunk_type) tuples
"""
chunks = [] chunks = []
current_section = None
section_text = []
encountered_title = False
for element in elements:
category = element.get("category", "")
element_text = element.get('text', '').strip()
# if not element_text:
# continue
if category == 'Title':
if section_text:
if not encountered_title:
chunks.append((' '.join(section_text), "preamble", "section"))
else:
chunks.append((' '.join(section_text), current_section, "section"))
current_section = element_text
section_text = [element_text]
encountered_title = True
else:
section_text.append(element_text)
for para in paragraphs: if section_text:
if not para.strip(): if not encountered_title:
continue chunks.append((' '.join(section_text), "preamble", "section"))
if len(para.split()) > self.chunk_size:
sentences = sent_tokenize(para)
current_chunk = []
current_length = 0
for sentence in sentences:
length = len(sentence.split())
if current_length + length > self.chunk_size and current_chunk:
chunks.append(" ".join(current_chunk))
current_chunk = []
current_length = 0
current_chunk.append(sentence)
current_length += length
if current_chunk:
chunks.append(" ".join(current_chunk))
else: else:
chunks.append(para.strip()) chunks.append((' '.join(section_text), current_section, "section"))
if not chunks and content:
paragraphs = re.split(r'\n\s*\n', content)
for para in paragraphs:
if para.strip():
chunks.append((para.strip(), "paragraph", "standard"))
print("Chunks:", chunks)
# TODO: Split section into paragraphs if it's too large
return chunks return chunks
...@@ -116,7 +169,7 @@ class DocumentProcessor: ...@@ -116,7 +169,7 @@ class DocumentProcessor:
if len(sentences) <= 3: if len(sentences) <= 3:
return [text] return [text]
stride = max(1, (self.chunk_size - self.chunk_overlap) // 20) # Approximate 20 words/sentence stride = max(1, (self.chunk_size - self.chunk_overlap) // 20)
for i in range(0, len(sentences), stride): for i in range(0, len(sentences), stride):
window_size = min(i + max(3, self.chunk_size // 20), len(sentences)) window_size = min(i + max(3, self.chunk_size // 20), len(sentences))
......
from typing import List, Dict, Optional
from langchain_core.language_models import BaseLanguageModel
class MultiQueryGenerator:
def __init__(self, llm: BaseLanguageModel, max_queries: int = 5):
"""
MQ (Multi-query) Generator: Sinh nhiều truy vấn từ câu hỏi gốc và ngữ cảnh.
Có thể nâng cấp sang MQA dễ dàng trong tương lai.
Args:
llm: LLM dùng để sinh truy vấn (GPT-3.5, GPT-4...)
max_queries: Số truy vấn tối đa muốn sinh
"""
self.llm = llm
self.max_queries = max_queries
def generate_queries(
self,
user_question: str,
chat_history: Optional[List[Dict[str, str]]] = None,
persona: Optional[List[str]] = None,
initial_answer: Optional[str] = None # nếu sau này dùng MQA, có thể truyền vào đây
) -> List[str]:
"""
Sinh ra các truy vấn tìm kiếm từ câu hỏi và ngữ cảnh hội thoại.
Args:
user_question: câu hỏi cuối cùng của người dùng
chat_history: lịch sử hội thoại (list gồm {"role": "user"|"ai", "content": ...})
persona: thông tin nền của người dùng (dùng sau cho iKAT)
initial_answer: câu trả lời sơ bộ (dành cho nâng cấp sang MQA)
Returns:
List[str]: Danh sách các truy vấn con
"""
history_text = "\n".join(f"{msg['role'].capitalize()}: {msg['content']}" for msg in chat_history or [])
persona_text = "\n".join(persona or [])
# Prompt template có thể được mở rộng sang MQA nếu truyền initial_answer
prompt = f"""
Du bist ein intelligenter Suchassistent.
Ich werde dir ein Gespräch zwischen einem Nutzer und einem System sowie die letzte Frage des Nutzers geben.
Deine Aufgabe ist es, bis zu {self.max_queries} präzisere Suchanfragen zu generieren, die dabei helfen können, die gesuchte Information besser zu finden.
# Hintergrundinformationen zum Nutzer:
{persona_text}
# Gesprächsverlauf:
{history_text}
# Letzte Nutzerfrage:
{user_question}
# Zu generierende Suchanfragen:
(schreibe jede Anfrage in eine neue Zeile, ohne Nummerierung)
"""
print(f"search-prompt: \n{prompt}")
response = self.llm.invoke(prompt)
if hasattr(response, "content"):
response_text = response.content
else:
response_text = response # fallback nếu là str
queries = [line.strip() for line in response_text.strip().splitlines() if line.strip()]
return queries[:self.max_queries]
import logging
from typing import List, Dict, Any
class QueryExpander:
def __init__(self, config):
self.logger = logging.getLogger(f"{self.__module__}")
self.config = config
self.model = config.rag.llm
def expand_query(self, original_query: str) -> Dict[str, Any]:
"""
Expand the original query with relevant academic and scientific terms.
Args:
original_query: The user's original query
Returns:
Dictionary with original and expanded queries
"""
self.logger.info(f"Expanding query: {original_query}")
# Generate expansions
# Step 1: expand the query using synonyms and related terms (using a predefined thesaurus or external source)
expanded_query = self._expand_with_synonyms_and_related_terms(original_query)
# Step 2: use llm to expand query semantically
expanded_query = self._expand_with_semantics(expanded_query)
return {
"original_query": original_query,
"expanded_query": expanded_query
}
def _expand_with_synonyms_and_related_terms(self, query: str) -> str:
"""Expand the query by adding synonyms and related terms using a predefined thesaurus or domain knowledge."""
# Define a dictionary for basic synonyms/related terms (this can be expanded based on your domain)
synonym_dict = {
"artificial intelligence": ["machine learning", "deep learning", "neural networks", "AI"],
"data science": ["big data", "data analysis", "machine learning", "data mining"],
}
expanded_query = query.lower() # Convert to lowercase for matching
# Expand using synonyms from the dictionary
for key, synonyms in synonym_dict.items():
if key.lower() in expanded_query:
expanded_query += " OR " + " OR ".join(synonyms)
self.logger.info(f"Expanded query after synonym addition: {expanded_query}")
# return expanded_query
### For now: do nothing and return original query
return query
def _expand_with_semantics(self, query: str) -> str:
"""Use LLM to expand query with academic and scientific terminology."""
prompt = f"""
As a research assistant, expand the following query with relevant scientific terminology,
synonyms, related concepts, and keywords that would help in retrieving relevant academic papers and research articles:
User Query: {query}
Expand the query only if you feel like it is required, otherwise keep the user query intact.
Be specific to the field of study mentioned, do not add other irrelevant domains.
If the user query asks about answering in tabular format, include that in the expanded query and do not answer in tabular format yourself.
Provide only the expanded query without explanations.
"""
expansion = self.model.invoke(prompt)
return expansion
\ No newline at end of file
...@@ -5,9 +5,10 @@ from typing import List, Dict, Any, Optional, Tuple, Union ...@@ -5,9 +5,10 @@ from typing import List, Dict, Any, Optional, Tuple, Union
from datetime import datetime from datetime import datetime
class QueryProcessor: class QueryProcessor:
def __init__(self, config, embedding_model): def __init__(self, config, embedding_model, sparse_embedder):
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self.embedding_model = embedding_model self.embedding_model = embedding_model
self.sparse_embedder = sparse_embedder
self.synonyms = { self.synonyms = {
"zusammenfassung": ["summary", "zusammenfassen", "kurzfassung"], "zusammenfassung": ["summary", "zusammenfassen", "kurzfassung"],
...@@ -22,7 +23,16 @@ class QueryProcessor: ...@@ -22,7 +23,16 @@ class QueryProcessor:
"article_navigation": re.compile(r"\b(link|artikel|paper|artikel)\b", re.IGNORECASE), "article_navigation": re.compile(r"\b(link|artikel|paper|artikel)\b", re.IGNORECASE),
} }
def process_query(self, query: str) -> Tuple[List[float], Dict[str, Any]]: def process_query(self, query: str) -> Tuple[Dict[str, Any], Dict[str, Any]]:
"""
Process the query to generate embedding and extract metadata filters.
Args:
query: User query string
Returns:
Tuple of (query_embedding, extracted_filters)
"""
try: try:
query_id = str(uuid.uuid4()) query_id = str(uuid.uuid4())
...@@ -30,11 +40,17 @@ class QueryProcessor: ...@@ -30,11 +40,17 @@ class QueryProcessor:
query_intent = self._detect_query_intent(query) query_intent = self._detect_query_intent(query)
query_embedding = self.embedding_model.embed_query(expanded_query) query_dense_embedding = self.embedding_model.embed_query(expanded_query)
if query_embedding is None: if query_dense_embedding is None:
raise ValueError("Embedding model returned None for query.") raise ValueError("Embedding model returned None for query.")
query_embedding = query_embedding.tolist() if hasattr(query_embedding, "tolist") else query_embedding query_dense_embedding = query_dense_embedding.tolist() if hasattr(query_dense_embedding, "tolist") else query_dense_embedding
query_sparse_embeddings = list(self.sparse_embedder.query_embed(query))
if query_sparse_embeddings:
query_sparse_embedding = query_sparse_embeddings[0]
else:
query_sparse_embedding = {}
filters: Dict[str, Any] = { filters: Dict[str, Any] = {
"query_id": query_id, "query_id": query_id,
...@@ -46,25 +62,37 @@ class QueryProcessor: ...@@ -46,25 +62,37 @@ class QueryProcessor:
self.logger.info(f"Processed query with filters: {filters}") self.logger.info(f"Processed query with filters: {filters}")
return query_embedding, filters return {
"dense": query_dense_embedding,
"sparse": query_sparse_embedding
}, filters
except Exception as e: except Exception as e:
self.logger.error(f"Error processing query: {e}") self.logger.error(f"Error processing query: {e}")
fallback_embedding = self.embedding_model.embed_query(query) fallback_dense_embedding = self.embedding_model.embed_query(query)
if fallback_embedding is None: if fallback_dense_embedding is None:
fallback_embedding = [] fallback_dense_embedding = []
fallback_dense_embedding = fallback_dense_embedding.tolist() if hasattr(fallback_dense_embedding, "tolist") else fallback_dense_embedding
fallback_embedding = fallback_embedding.tolist() if hasattr(fallback_embedding, try:
"tolist") else fallback_embedding fallback_sparse_embeddings = list(self.sparse_embedder.query_embed(query))
fallback_sparse_embedding = fallback_sparse_embeddings[0] if fallback_sparse_embeddings else {}
except Exception as se:
self.logger.error(f"Error generating sparse fallback: {se}")
fallback_sparse_embedding = {}
fallback_filters = { fallback_filters: Dict[str, Any] = {
"query_id": str(uuid.uuid4()), "query_id": str(uuid.uuid4()),
"timestamp": datetime.now().isoformat(), "timestamp": datetime.now().isoformat(),
"is_fallback": True "is_fallback": True
} }
return fallback_embedding, fallback_filters return {
"dense": fallback_dense_embedding,
"sparse": fallback_sparse_embedding
}, fallback_filters
def _expand_query(self, query: str) -> str: def _expand_query(self, query: str) -> str:
parts = [query] parts = [query]
......
from typing import List, Dict
from langchain_core.language_models import BaseLanguageModel
class QueryRewriter:
def __init__(self, llm: BaseLanguageModel):
self.llm = llm
def rewrite(self, query: str, chat_history: List[Dict[str, str]]) -> str:
history_str = "\n".join(f"{m['role'].capitalize()}: {m['content']}" for m in chat_history)
print(f"history_str in def rewrite: {history_str}")
prompt = f"""
Du bist ein Query-Rewriter. Ich gebe dir eine Unterhaltung und eine unklare Nutzerfrage.
Deine Aufgabe ist es, die letzte Frage so umzuschreiben, dass sie **ohne den Gesprächskontext** vollständig verständlich ist.
# Gespräch:
{history_str}
# Ursprüngliche Frage:
{query}
# Umschriebene Frage:
"""
response = self.llm.invoke(prompt)
return response.content.strip() if hasattr(response, "content") else response.strip()
...@@ -30,6 +30,7 @@ class ResponseGenerator: ...@@ -30,6 +30,7 @@ class ResponseGenerator:
chat_history: Optional[List[Dict[str, str]]] = None) -> Dict[str, Any]: chat_history: Optional[List[Dict[str, str]]] = None) -> Dict[str, Any]:
try: try:
# tai cai nay
if not retrieved_docs: if not retrieved_docs:
return self._generate_no_documents_response(query) return self._generate_no_documents_response(query)
...@@ -37,6 +38,8 @@ class ResponseGenerator: ...@@ -37,6 +38,8 @@ class ResponseGenerator:
prompt = self._build_prompt(query, formatted_context, chat_history) prompt = self._build_prompt(query, formatted_context, chat_history)
print(f"DEBUG prompt: \n{prompt}")
response = self.llm.invoke(prompt) response = self.llm.invoke(prompt)
sources = self._extract_sources(retrieved_docs) if self.include_sources else [] sources = self._extract_sources(retrieved_docs) if self.include_sources else []
...@@ -157,7 +160,6 @@ class ResponseGenerator: ...@@ -157,7 +160,6 @@ class ResponseGenerator:
def _build_prompt(self, query: str, context: str, def _build_prompt(self, query: str, context: str,
chat_history: Optional[List[Dict[str, str]]] = None) -> str: chat_history: Optional[List[Dict[str, str]]] = None) -> str:
project_instructions = """ project_instructions = """
Du bist ein wissenschaftlicher Chatbot, der folgende Aufgaben erfüllt: Du bist ein wissenschaftlicher Chatbot, der folgende Aufgaben erfüllt:
- Zusammenfassung wissenschaftlicher Artikel - Zusammenfassung wissenschaftlicher Artikel
...@@ -175,11 +177,25 @@ class ResponseGenerator: ...@@ -175,11 +177,25 @@ class ResponseGenerator:
3. Interpretieren und referenzieren Sie die Tabellen deutlich 3. Interpretieren und referenzieren Sie die Tabellen deutlich
""" """
chat_str = ""
if chat_history and isinstance(chat_history, list):
chat_lines = []
for msg in chat_history:
if isinstance(msg, dict):
role = "User" if msg.get("type") == "human" else "Assistant"
content = msg.get("content", "")
chat_lines.append(f"{role}: {content}")
else:
chat_lines.append(str(msg))
chat_str = "\n".join(chat_lines)
print(f"chat_str: {chat_str}")
prompt = f""" prompt = f"""
{project_instructions} {project_instructions}
Bisherige Konversation: Bisherige Konversation:
{chat_history} {chat_str}
Benutzeranfrage: Benutzeranfrage:
{query} {query}
......
import nltk
from sklearn.feature_extraction.text import TfidfVectorizer
from typing import List, Dict
nltk.download('stopwords', quiet=True)
from nltk.corpus import stopwords
german_stopwords = stopwords.words('german')
class GermanBM25SparseEmbedder:
def __init__(self):
self.vectorizer = TfidfVectorizer(
stop_words=german_stopwords,
ngram_range=(1, 2),
max_features=20000,
norm='l2'
)
self.fitted = False
self.feature_names = []
def fit(self, corpus: List[str]):
self.vectorizer.fit(corpus)
self.feature_names = self.vectorizer.get_feature_names_out()
self.fitted = True
def encode(self, texts: List[str]) -> List[Dict[int, float]]:
if not self.fitted:
raise ValueError("Call `fit()` before `encode()`.")
tfidf_matrix = self.vectorizer.transform(texts)
return [
{int(i): float(v) for i, v in zip(row.indices, row.data)}
for row in tfidf_matrix
]
\ No newline at end of file
...@@ -2,7 +2,7 @@ from typing import List, Dict, Any, Optional, Union ...@@ -2,7 +2,7 @@ from typing import List, Dict, Any, Optional, Union
import logging import logging
from qdrant_client import QdrantClient from qdrant_client import QdrantClient
from qdrant_client.http import models as qdrant_models from qdrant_client.http import models as qdrant_models
from qdrant_client.http.models import ContextQuery, ContextPair, SearchParams from qdrant_client.http.models import ContextQuery, ContextPair, SearchParams, SparseVectorParams
from qdrant_client.http.exceptions import UnexpectedResponse from qdrant_client.http.exceptions import UnexpectedResponse
from qdrant_client.http.models import Distance, VectorParams, PointStruct, Prefetch, FusionQuery, Fusion from qdrant_client.http.models import Distance, VectorParams, PointStruct, Prefetch, FusionQuery, Fusion
from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.feature_extraction.text import TfidfVectorizer
...@@ -47,20 +47,33 @@ class QdrantRetriever: ...@@ -47,20 +47,33 @@ class QdrantRetriever:
collection_names = [collection.name for collection in collection_info.collections] collection_names = [collection.name for collection in collection_info.collections]
print("Collection names:", collection_names) print("Collection names:", collection_names)
# self.client.delete_collection(collection_name=self.collection_name)
if self.collection_name not in collection_names: if self.collection_name not in collection_names:
self.logger.info(f"Creating new collection {self.collection_name}") self.logger.info(f"Creating new collection {self.collection_name}")
try: try:
self.client.create_collection( self.client.create_collection(
collection_name=self.collection_name, collection_name=self.collection_name,
vectors_config=VectorParams( vectors_config={
size=self.embedding_dim, "dense": VectorParams(
distance=Distance.COSINE size=self.embedding_dim,
), distance=self.distance_metric, # =Distance.COSINE
optimizers_config=qdrant_models.OptimizersConfigDiff( )
indexing_threshold=10000, },
), sparse_vectors_config={
"sparse": SparseVectorParams(
modifier=qdrant_models.Modifier.IDF
)
}
# vectors_config=VectorParams(
# size=self.embedding_dim,
# distance=Distance.COSINE
# ),
# optimizers_config=qdrant_models.OptimizersConfigDiff(
# indexing_threshold=10000,
# ),
) )
self.logger.info(f"Collection {self.collection_name} created") self.logger.info(f"Created new collection: {self.collection_name}")
except Exception as e: except Exception as e:
self.logger.error(f"Error creating collection: {e}") self.logger.error(f"Error creating collection: {e}")
raise e raise e
...@@ -69,6 +82,7 @@ class QdrantRetriever: ...@@ -69,6 +82,7 @@ class QdrantRetriever:
def upsert_chunks(self, chunks: List[Dict[str, Any]]): def upsert_chunks(self, chunks: List[Dict[str, Any]]):
try: try:
print(f"DEBUG Upserting chunks: {chunks}")
points: List[PointStruct] = [] points: List[PointStruct] = []
for chunk in chunks: for chunk in chunks:
...@@ -76,15 +90,29 @@ class QdrantRetriever: ...@@ -76,15 +90,29 @@ class QdrantRetriever:
payload["content"] = chunk["content"] payload["content"] = chunk["content"]
print(f"DEBUG Upserting chunk: {chunk['id']}")
print(f"DEBUG Embedding keys: {chunk['embedding']}")
print(f"DEBUG Payload: {payload}")
points.append( points.append(
qdrant_models.PointStruct( PointStruct(
id=chunk["id"], id=chunk["id"],
vector=chunk["embedding"], # vector=chunk["embedding"],
vector={
"dense": chunk["embedding"]["dense"],
"sparse": chunk["embedding"]["sparse"].as_object()
},
#Giả định chunk["embedding"] là dict kiểu:
# {
# "dense": [...], # list[float]
# "sparse": {...} # dict[int -> float]
# }
payload=payload payload=payload
) )
) )
batch_size = 100 batch_size = 100
# Nghĩa là mỗi lần gửi lên database sẽ gửi tối đa 100 points (vector + metadata).
for i in range(0, len(points), batch_size): for i in range(0, len(points), batch_size):
batch = points[i:i + batch_size] batch = points[i:i + batch_size]
self.client.upsert( self.client.upsert(
...@@ -134,90 +162,102 @@ class QdrantRetriever: ...@@ -134,90 +162,102 @@ class QdrantRetriever:
"values": values "values": values
} }
from qdrant_client.http import models as qdrant_models
from qdrant_client import QdrantClient
from qdrant_client.http.models import SearchParams, ContextPair, ContextQuery, FusionQuery, Fusion, Prefetch
def retrieve( def retrieve(
self, self,
query_vector: List[float], query_embedding: Dict[str, Any],
filters: Optional[Dict] = None, filters: Optional[Dict[str, Any]] = None,
top_k: int = 5, top_k: int = 5,
include_metadata: bool = True, include_metadata: bool = True,
query_text: Optional[str] = None, ) -> List[Dict[str, Any]]:
use_sparse: bool = False """
) -> List[Dict]: Perform hybrid retrieval using both dense and sparse embeddings.
# 1. Log params
Args:
query_embedding: Dict with keys "dense" (List[float]) and "sparse" (SparseEmbedding)
filters: Optional metadata filters
top_k: Number of documents to retrieve
include_metadata: Whether to include payload in results
Returns:
List of retrieved documents with score
"""
dense_vector = query_embedding.get("dense")
sparse_vector = query_embedding.get("sparse")
self.logger.info(f""" self.logger.info(f"""
[RETRIEVE PARAMS] [RETRIEVE PARAMS]
query_vector dim={len(query_vector)}, filters={filters},
top_k={top_k}, include_metadata={include_metadata}, dense_dim={len(dense_vector) if dense_vector else 'None'},
query_text={query_text[:50] if query_text else None}, filters={filters},
use_sparse={use_sparse} top_k={top_k},
include_metadata={include_metadata}
""") """)
sparse_vector = {} if sparse_vector:
if use_sparse and query_text: self.logger.info("Sparse vector detected → using hybrid search")
self.logger.info("Creating sparse vector from query_text.")
sparse_vector = self.text_to_sparse_vector(query_text) prefetch = [
self.logger.debug(f"Sparse vector: {sparse_vector}") qdrant_models.Prefetch(query=dense_vector, using="dense", limit=top_k),
qdrant_models.Prefetch(
query=qdrant_models.SparseVector(
indices=sparse_vector.indices.tolist(),
values=sparse_vector.values.tolist()
),
using="sparse",
limit=top_k,
),
]
filter_obj = None
if filters:
try:
conditions = []
for key, value in filters.items():
filter_obj = qdrant_models.Filter(should=conditions)
except Exception:
self.logger.error("Error creating filter", exc_info=True)
if not sparse_vector.get("indices"):
self.logger.info("Dense-only fallback: using client.search()")
try: try:
hits = self.client.search( response = self.client.query_points(
collection_name=self.collection_name, collection_name=self.collection_name,
query_vector=query_vector, prefetch=prefetch,
limit=top_k, query=qdrant_models.FusionQuery(fusion=qdrant_models.Fusion.RRF),
with_payload=include_metadata, with_payload=include_metadata,
query_filter=filter_obj, query_filter=None, # TODO: build filter from `filters` if needed
) )
results = [ retrieved_points = response.points
{**(hit.payload or {}), "score": hit.score}
for hit in hits results = []
] for point in retrieved_points:
self.logger.info(f"Found {len(results)} docs (dense-only)") print("DEBUG point:", point)
result_item = dict(point.payload or {})
result_item["score"] = point.score
results.append(result_item)
self.logger.info(f"Retrieved {len(results)} results (hybrid search)")
return results return results
except Exception as e: except Exception as e:
self.logger.error(f"Error in dense-only search: {e}", exc_info=True) self.logger.error(f"Error during hybrid retrieval: {e}", exc_info=True)
return [] return []
self.logger.info("Using hybrid search with query_points()") else:
prefetch = [ self.logger.info("No sparse vector → fallback to dense-only search")
Prefetch(query=query_vector, using="dense", limit=top_k),
Prefetch( try:
query=qdrant_models.SparseVector( response = self.client.query_points(
indices=sparse_vector["indices"], collection_name=self.collection_name,
values=sparse_vector["values"], prefetch=[
), qdrant_models.Prefetch(query=dense_vector, using="dense", limit=top_k)
using="sparse", ],
limit=top_k, query=qdrant_models.FusionQuery(fusion=qdrant_models.Fusion.RRF),
), with_payload=include_metadata,
] query_filter=None,
try: )
hits = self.client.query_points( retrieved_points = response.points
collection_name=self.collection_name,
prefetch=prefetch, results = []
query=FusionQuery(fusion=Fusion.RRF), for point in retrieved_points:
with_payload=include_metadata, result_item = dict(point.payload or {})
query_filter=filter_obj, result_item["score"] = point.score
) results.append(result_item)
results = [
{**(hit.payload or {}), "score": hit.score} self.logger.info(f"Retrieved {len(results)} results (dense-only search)")
for hit in hits return results
]
self.logger.info(f"Found {len(results)} docs (hybrid)") except Exception as e:
return results self.logger.error(f"Error during dense-only retrieval: {e}", exc_info=True)
except Exception as e: return []
self.logger.error(f"Error querying points: {e}", exc_info=True)
return []
...@@ -5,6 +5,8 @@ from fastapi.staticfiles import StaticFiles ...@@ -5,6 +5,8 @@ from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel from pydantic import BaseModel
import uvicorn import uvicorn
from agents.agent_decision import process_query from agents.agent_decision import process_query
from typing import List, Union
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, SystemMessage
UPLOAD_FOLDER = "uploads/backend" UPLOAD_FOLDER = "uploads/backend"
os.makedirs(UPLOAD_FOLDER, exist_ok=True) os.makedirs(UPLOAD_FOLDER, exist_ok=True)
...@@ -13,8 +15,23 @@ app = FastAPI(title="Transfer-Bot", version="1.0") ...@@ -13,8 +15,23 @@ app = FastAPI(title="Transfer-Bot", version="1.0")
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads") app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
def deserialize_messages(serialized: List[dict]) -> List[BaseMessage]:
message_objects = []
for msg in serialized:
print(f"Message deri: {msg}")
msg_type = msg.get("type")
content = msg.get("content", "")
if msg_type == "human":
message_objects.append(HumanMessage(content=content))
elif msg_type == "ai":
message_objects.append(AIMessage(content=content))
elif msg_type == "system":
message_objects.append(SystemMessage(content=content))
return message_objects
class QueryRequest(BaseModel): class QueryRequest(BaseModel):
query: str query: str
conversation_history: List[dict] = []
@app.post("/chat") @app.post("/chat")
def chat(request: QueryRequest, def chat(request: QueryRequest,
...@@ -25,7 +42,16 @@ def chat(request: QueryRequest, ...@@ -25,7 +42,16 @@ def chat(request: QueryRequest,
session_id = request_obj.cookies.get("session_id", str(uuid.uuid4())) session_id = request_obj.cookies.get("session_id", str(uuid.uuid4()))
try: try:
response_data = process_query(request.query) print(f"Session id: {session_id}")
history: List[BaseMessage] = deserialize_messages(request.conversation_history)
print(f"History: {history}")
print("Incoming request:", request.model_dump())
response_data = process_query(
query=request.query,
conversation_history=history
)
response_text = response_data['response'] response_text = response_data['response']
......
...@@ -9,6 +9,12 @@ st.title("Transfer-Bot Frontend") ...@@ -9,6 +9,12 @@ st.title("Transfer-Bot Frontend")
if "chat_history" not in st.session_state: if "chat_history" not in st.session_state:
st.session_state.chat_history = [] st.session_state.chat_history = []
def get_serialized_history():
return [
{"type": "human" if role == "user" else "ai", "content": msg}
for role, msg in st.session_state.chat_history
]
tab1, tab2 = st.tabs(["Chat", "Upload mit Text"]) tab1, tab2 = st.tabs(["Chat", "Upload mit Text"])
with tab1: with tab1:
...@@ -40,8 +46,6 @@ with tab1: ...@@ -40,8 +46,6 @@ with tab1:
user_query = st.chat_input("Stell mir deine Frage...") user_query = st.chat_input("Stell mir deine Frage...")
if user_query: if user_query:
st.session_state.chat_history.append(("user", user_query)) st.session_state.chat_history.append(("user", user_query))
st.session_state.pending_query = user_query st.session_state.pending_query = user_query
...@@ -49,14 +53,32 @@ with tab1: ...@@ -49,14 +53,32 @@ with tab1:
if "pending_query" in st.session_state: if "pending_query" in st.session_state:
with st.spinner("Wird verarbeitet..."): with st.spinner("Wird verarbeitet..."):
data = {}
try: try:
response = requests.post(f"{API_URL}/chat", json={"query": st.session_state.pending_query}) payload = {
"query": st.session_state.pending_query,
"conversation_history": get_serialized_history() # ✅ Thêm
}
print(f"payload: {payload}")
response = requests.post(f"{API_URL}/chat", json=payload)
data = response.json() data = response.json()
bot_reply = data["response"] bot_reply = data.get("response", "Keine Antwort erhalten.")
# st.session_state.chat_history.append(("bot", bot_reply))
if "messages" in data:
st.session_state.chat_history = [
("user" if m["type"] == "human" else "bot", m["content"])
for m in data["messages"]
]
else:
st.session_state.chat_history.append(("bot", bot_reply))
except Exception as e: except Exception as e:
bot_reply = f"Error: {str(e)}" bot_reply = f"Error: {str(e)}"
st.session_state.chat_history.append(("bot", bot_reply))
st.session_state.chat_history.append(("bot", bot_reply))
del st.session_state.pending_query del st.session_state.pending_query
st.rerun() st.rerun()
......
import os import os
from dotenv import load_dotenv from dotenv import load_dotenv
from fastembed import SparseTextEmbedding
from langchain_openai import ChatOpenAI, OpenAIEmbeddings from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from openai import OpenAI from openai import OpenAI
load_dotenv() load_dotenv()
# class AgentDecisoinConfig: class AgentDecisoinConfig:
# # def __init__(self): def __init__(self):
# # self.llm = AzureChatOpenAI( self.llm = ChatOpenAI(
# # deployment_name=os.getenv("deployment_name"), model=os.getenv("model_name"),
# # model_name=os.getenv("model_name"), api_key=os.getenv("openai_api_key"),
# # azure_endpoint=os.getenv("azure_endpoint"), temperature=0.3
# # openai_api_key=os.getenv("openai_api_key"), )
# # openai_api_version=os.getenv("openai_api_version"),
# # temperature=0.1
# # )
# def __init__(self):
# self.client = OpenAI(api_key=os.getenv("openai_api_key"))
#
# self.llm = self.get_openai_llm()
#
# def get_openai_llm(self):
# return self.client.chat.completions.create(
# model="gpt-3.5-turbo",
# messages=[{"role": "user", "content": "Hello"}],
# temperature=0.1
# )
class RAGConfig: class RAGConfig:
def __init__(self): def __init__(self):
...@@ -33,10 +20,10 @@ class RAGConfig: ...@@ -33,10 +20,10 @@ class RAGConfig:
self.embedding_dim = 1536 self.embedding_dim = 1536
self.distance_metric = "Cosine" self.distance_metric = "Cosine"
self.use_local = True self.use_local = True
self.local_path = "./data/qdrant_db" self.vector_local_path = "./data/qdrant_db" # Add this with a default value
self.url = os.getenv("QDRANT_URL") self.url = os.getenv("QDRANT_URL")
self.api_key = os.getenv("QDRANT_API_KEY") self.api_key = os.getenv("QDRANT_API_KEY")
self.collection_name = "transfer_rag" self.collection_name = "research_assistant_rag"
self.chunk_size = 512 self.chunk_size = 512
self.chunk_overlap = 50 self.chunk_overlap = 50
self.processed_docs_dir = "./data/processed" self.processed_docs_dir = "./data/processed"
...@@ -45,17 +32,20 @@ class RAGConfig: ...@@ -45,17 +32,20 @@ class RAGConfig:
model=os.getenv("embedding_model_name"), model=os.getenv("embedding_model_name"),
api_key=os.getenv("openai_api_key") api_key=os.getenv("openai_api_key")
) )
self.sparse_embedder = SparseTextEmbedding("Qdrant/bm25")
self.llm = ChatOpenAI( self.llm = ChatOpenAI(
model=os.getenv("model_name"), model=os.getenv("model_name"),
api_key=os.getenv("openai_api_key"), api_key=os.getenv("openai_api_key"),
temperature=0.3 temperature=0.3
) )
self.max_queries = 3
self.top_k = 5 self.top_k = 5
self.similarity_threshold = 0.75 self.similarity_threshold = 0.75
self.huggingface_token = os.getenv("HUGGINGFACE_TOKEN") self.huggingface_token = os.getenv("HUGGINGFACE_TOKEN")
self.chunking_strategy = "hybrid" self.vector_search_type = 'similarity' # or 'mmr'
self.chunking_strategy = "semantic"
self.max_context_length = 8192 self.max_context_length = 8192
self.response_format_instructions = """Instructions: self.response_format_instructions = """Instructions:
...@@ -66,8 +56,8 @@ class RAGConfig: ...@@ -66,8 +56,8 @@ class RAGConfig:
6. Provide a well-structured response based on retrieved knowledge.""" 6. Provide a well-structured response based on retrieved knowledge."""
self.include_sources = True self.include_sources = True
self.metrics_save_path = "./logs/rag_metrics.json" self.metrics_save_path = "./logs/rag_metrics.json"
self.min_retrieval_confidence = 0.8 self.min_retrieval_confidence = 0.8 # the auto routing from RAG agent to WEB_SEARCH agent is dependent on this value
self.context_limit = 20 self.context_limit = 20 # include last 20 messsages (10 Q&A pairs) in history
class APIConfig: class APIConfig:
def __init__(self): def __init__(self):
...@@ -75,11 +65,11 @@ class APIConfig: ...@@ -75,11 +65,11 @@ class APIConfig:
self.port = 8000 self.port = 8000
self.debug = True self.debug = True
self.rate_limit = 10 self.rate_limit = 10
self.max_image_upload_size = 5 self.max_image_upload_size = 5 # max upload size in MB
class Config: class Config:
def __init__(self): def __init__(self):
# self.agent_decision = AgentDecisoinConfig() self.agent_decision = AgentDecisoinConfig()
self.rag = RAGConfig() self.rag = RAGConfig()
self.api = APIConfig() self.api = APIConfig()
self.max_conversation_history = 40 self.max_conversation_history = 40
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment