Ary-007 commited on
Commit
f36c047
·
verified ·
1 Parent(s): 0d81ef8

Upload 6 files

Browse files
Files changed (6) hide show
  1. Dockerfile +20 -8
  2. app.py +160 -0
  3. config.py +33 -0
  4. data_processing.py +117 -0
  5. rag_engine.py +126 -0
  6. requirements.txt +15 -3
Dockerfile CHANGED
@@ -1,20 +1,32 @@
1
- FROM python:3.13.5-slim
 
2
 
 
3
  WORKDIR /app
4
 
 
 
5
  RUN apt-get update && apt-get install -y \
6
  build-essential \
7
  curl \
8
- git \
 
 
 
 
9
  && rm -rf /var/lib/apt/lists/*
10
 
11
- COPY requirements.txt ./
12
- COPY src/ ./src/
13
 
14
- RUN pip3 install -r requirements.txt
 
15
 
16
- EXPOSE 8501
 
17
 
18
- HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health
 
19
 
20
- ENTRYPOINT ["streamlit", "run", "src/streamlit_app.py", "--server.port=8501", "--server.address=0.0.0.0"]
 
 
1
+ # 1. Base Image
2
+ FROM python:3.11-slim
3
 
4
+ # 2. Set Working Directory
5
  WORKDIR /app
6
 
7
+ # 3. Install System Dependencies
8
+ # ADDED: libgl1 and libglib2.0-0 (Fixes ImportError: libGL.so.1)
9
  RUN apt-get update && apt-get install -y \
10
  build-essential \
11
  curl \
12
+ poppler-utils \
13
+ tesseract-ocr \
14
+ libmagic1 \
15
+ libgl1 \
16
+ libglib2.0-0 \
17
  && rm -rf /var/lib/apt/lists/*
18
 
19
+ # 4. Copy Requirements
20
+ COPY requirements.txt .
21
 
22
+ # 5. Install Python Dependencies
23
+ RUN pip install --no-cache-dir -r requirements.txt
24
 
25
+ # 6. Copy Application Code
26
+ COPY . .
27
 
28
+ # 7. Expose Port
29
+ EXPOSE 8501
30
 
31
+ # 8. Run
32
+ CMD ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"]
app.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import uuid
3
+ import os
4
+ import base64
5
+ from langchain_community.retrievers import BM25Retriever
6
+ from langchain_core.messages import HumanMessage, AIMessage
7
+
8
+ from data_processing import process_and_ingest, SessionDocStore, cleanup_session_index
9
+ from rag_engine import run_advanced_rag
10
+
11
+ # ──────────────────────────────────────────────────────────────────
12
+ # 1. PAGE CONFIG & STATE INITIALIZATION
13
+ # ──────────────────────────────────────────────────────────────────
14
+
15
+ st.set_page_config(
16
+ page_title="Deep RAG Analyzer",
17
+ page_icon="🤖",
18
+ layout="wide"
19
+ )
20
+
21
+ # Initialize Session State
22
+ if "session_id" not in st.session_state:
23
+ st.session_state.session_id = str(uuid.uuid4())
24
+
25
+ if "doc_store" not in st.session_state:
26
+ st.session_state.doc_store = SessionDocStore()
27
+
28
+ if "messages" not in st.session_state:
29
+ st.session_state.messages = [] # Format: {"role": "user/assistant", "content": "text", "images": []}
30
+
31
+ if "bm25" not in st.session_state:
32
+ st.session_state.bm25 = None
33
+
34
+ if "processed_file" not in st.session_state:
35
+ st.session_state.processed_file = None
36
+
37
+ if "uploader_key" not in st.session_state:
38
+ st.session_state.uploader_key = str(uuid.uuid4())
39
+
40
+ # ──────────────────────────────────────────────────────────────────
41
+ # 2. SIDEBAR (Upload & Reset)
42
+ # ──────────────────────────────────────────────────────────────────
43
+
44
+ with st.sidebar:
45
+ st.title("📁 Document Upload")
46
+ uploaded_file = st.file_uploader("Upload PDF", type=["pdf"],key=st.session_state.uploader_key)
47
+
48
+ if uploaded_file and uploaded_file.name != st.session_state.processed_file:
49
+ with st.spinner("Partitioning & Embedding (This may take a moment)..."):
50
+ # Save to temp file for processing
51
+ temp_path = f"temp_{uploaded_file.name}"
52
+ with open(temp_path, "wb") as f:
53
+ f.write(uploaded_file.getbuffer())
54
+
55
+ try:
56
+ # Run Pipeline
57
+ documents = process_and_ingest(
58
+ temp_path,
59
+ st.session_state.session_id,
60
+ st.session_state.doc_store
61
+ )
62
+
63
+ # Setup Retriever
64
+ bm25 = BM25Retriever.from_documents(documents)
65
+ bm25.k = 3
66
+ st.session_state.bm25 = bm25
67
+ st.session_state.processed_file = uploaded_file.name
68
+
69
+ st.success(f"Processed {len(documents)} chunks!")
70
+ except Exception as e:
71
+ st.error(f"Error: {e}")
72
+ finally:
73
+ # Cleanup temp file
74
+ if os.path.exists(temp_path):
75
+ os.remove(temp_path)
76
+
77
+ st.markdown("---")
78
+ if st.button("🗑️ Clear Chat & Reset"):
79
+ # Cleanup Pinecone
80
+ cleanup_session_index(st.session_state.session_id)
81
+ # Reset State
82
+ st.session_state.session_id = str(uuid.uuid4())
83
+ st.session_state.doc_store = SessionDocStore()
84
+ st.session_state.messages = []
85
+ st.session_state.bm25 = None
86
+ st.session_state.processed_file = None
87
+ st.session_state.uploader_key = str(uuid.uuid4())
88
+ st.rerun()
89
+
90
+ # ──────────────────────────────────────────────────────────────────
91
+ # 3. CHAT INTERFACE
92
+ # ──────────────────────────────────────────────────────────────────
93
+
94
+ st.title("🤖 Advanced Multimodal RAG (with chat history and visuals)")
95
+ st.caption("Using GPT-4.1, Pinecone, RRF and Re-ranking (Do not upload extremly large PDFs!)")
96
+
97
+ # Display History
98
+ for msg in st.session_state.messages:
99
+ with st.chat_message(msg["role"]):
100
+ st.markdown(msg["content"])
101
+ if "images" in msg and msg["images"]:
102
+ # Display images in a row
103
+ cols = st.columns(len(msg["images"]))
104
+ for idx, img_b64 in enumerate(msg["images"]):
105
+ with cols[idx]:
106
+ if "," in img_b64: img_b64 = img_b64.split(",")[1]
107
+ st.image(base64.b64decode(img_b64), use_container_width=True)
108
+
109
+ # Chat Input
110
+ if prompt := st.chat_input("Ask about your document..."):
111
+
112
+ if not st.session_state.bm25:
113
+ st.error("Please upload and process a PDF first!")
114
+ st.stop()
115
+
116
+ # 1. Display User Message
117
+ st.chat_message("user").markdown(prompt)
118
+ st.session_state.messages.append({"role": "user", "content": prompt})
119
+
120
+ # 2. Prepare History for RAG
121
+ lc_history = []
122
+ for m in st.session_state.messages:
123
+ if m["role"] == "user":
124
+ lc_history.append(HumanMessage(content=m["content"]))
125
+ else:
126
+ lc_history.append(AIMessage(content=m["content"]))
127
+
128
+ # 3. Generate Response
129
+ with st.chat_message("assistant"):
130
+ with st.spinner("Thinking (retrieving the context)..."):
131
+ try:
132
+ answer, images = run_advanced_rag(
133
+ prompt,
134
+ st.session_state.session_id,
135
+ st.session_state.bm25,
136
+ st.session_state.doc_store,
137
+ lc_history
138
+ )
139
+
140
+ st.markdown(answer)
141
+
142
+ # Display Images if found
143
+ if images:
144
+ st.write("---")
145
+ st.caption("📸 Retrieved Visual Context:")
146
+ cols = st.columns(min(3, len(images)))
147
+ for idx, img_b64 in enumerate(images[:3]):
148
+ with cols[idx]:
149
+ if "," in img_b64: img_b64 = img_b64.split(",")[1]
150
+ st.image(base64.b64decode(img_b64), use_container_width=True)
151
+
152
+ # Save to history
153
+ st.session_state.messages.append({
154
+ "role": "assistant",
155
+ "content": answer,
156
+ "images": images[:3] if images else []
157
+ })
158
+
159
+ except Exception as e:
160
+ st.error(f"Error generating response: {e}")
config.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from dotenv import load_dotenv
3
+ from langchain_huggingface import HuggingFaceEmbeddings
4
+ from langchain_openai import AzureChatOpenAI
5
+
6
+ load_dotenv()
7
+
8
+ # 1. API KEYS & CONFIG
9
+ # ⚠️ Ensure these are set in your Hugging Face Space "Secrets" setting
10
+ PINECONE_API_KEY = os.getenv("PINECONE_API_KEY")
11
+ INDEX_NAME = "gradio-db" # 768 dimensions
12
+
13
+ AZURE_OPENAI_API_KEY = os.getenv("AZURE_OPENAI_API_KEY")
14
+ AZURE_OPENAI_ENDPOINT = os.getenv("AZURE_OPENAI_ENDPOINT")
15
+ AZURE_OPENAI_API_VERSION = "2025-01-01-preview" # Updated to stable preview
16
+ AZURE_DEPLOYMENT_NAME = "gpt-4.1"
17
+
18
+ # 2. MODEL INITIALIZATION
19
+
20
+ def get_llm(temperature=0.25):
21
+
22
+ return AzureChatOpenAI(
23
+ azure_deployment=AZURE_DEPLOYMENT_NAME,
24
+ api_version=AZURE_OPENAI_API_VERSION,
25
+ temperature=temperature,
26
+ max_tokens=1024,
27
+ streaming=True
28
+ )
29
+
30
+ def get_embeddings():
31
+ # IntFloat E5-Base v2 (768 dim)
32
+ return HuggingFaceEmbeddings(model_name="intfloat/e5-base-v2")
33
+
data_processing.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import uuid
3
+ import json
4
+ from typing import List, Dict
5
+ from unstructured.partition.pdf import partition_pdf
6
+ from unstructured.chunking.title import chunk_by_title
7
+ from langchain_core.documents import Document
8
+ from langchain_pinecone import PineconeVectorStore
9
+ from langchain_core.messages import HumanMessage
10
+ from pinecone import Pinecone, ServerlessSpec
11
+
12
+ from config import *
13
+
14
+ # Initialize Pinecone Client
15
+ pc = Pinecone(api_key=PINECONE_API_KEY)
16
+
17
+ class SessionDocStore:
18
+ """
19
+ In-memory storage for heavy content (Images/Tables) tied to a session.
20
+ In a real production app, use Redis/Postgres.
21
+ """
22
+ def __init__(self):
23
+ self.store = {}
24
+
25
+ def save_chunk(self, doc_id: str, data: Dict):
26
+ self.store[doc_id] = data
27
+
28
+ def get_chunk(self, doc_id: str):
29
+ return self.store.get(doc_id, {})
30
+
31
+ def clear(self):
32
+ self.store = {}
33
+
34
+ # Helper to summarize visual content for embeddings
35
+ def create_multimodal_summary(text, tables, images):
36
+ llm = get_llm()
37
+ prompt_text = f"Analyze content. TEXT: {text[:1000]}. INSTRUCTIONS: Summarize text and describe images/tables for retrieval."
38
+
39
+ message_content = [{"type": "text", "text": prompt_text}]
40
+ if images:
41
+ for b64_str in images:
42
+ if "," in b64_str: b64_str = b64_str.split(",")[1]
43
+ message_content.append({
44
+ "type": "image_url",
45
+ "image_url": {"url": f"data:image/jpeg;base64,{b64_str}", "detail": "low"}
46
+ })
47
+
48
+ response = llm.invoke([HumanMessage(content=message_content)])
49
+ return response.content
50
+
51
+ def process_and_ingest(file_path: str, session_id: str, doc_store: SessionDocStore):
52
+ """
53
+ Parses PDF, creates summary/heavy splits, uploads to Pinecone Namespace.
54
+ """
55
+ print(f"📄 Partitioning: {file_path}")
56
+ elements = partition_pdf(
57
+ filename=file_path, strategy="hi_res", infer_table_structure=True,
58
+ extract_image_block_types=["Image", "Table"], extract_image_block_to_payload=True
59
+ )
60
+
61
+ chunks = chunk_by_title(elements, max_characters=3000, new_after_n_chars=2400, combine_text_under_n_chars=500)
62
+
63
+ documents = []
64
+
65
+ for i, chunk in enumerate(chunks):
66
+ content = {'text': chunk.text, 'tables': [], 'images': []}
67
+
68
+ # Extract visual data
69
+ if hasattr(chunk, 'metadata') and hasattr(chunk.metadata, 'orig_elements'):
70
+ for element in chunk.metadata.orig_elements:
71
+ el_type = type(element).__name__
72
+ if el_type == 'Table':
73
+ content['tables'].append(getattr(element.metadata, 'text_as_html', element.text))
74
+ elif el_type == 'Image' and hasattr(element.metadata, 'image_base64'):
75
+ content['images'].append(element.metadata.image_base64)
76
+
77
+ # Summarize if heavy content exists
78
+ if content['images'] or content['tables']:
79
+ enhanced_text = create_multimodal_summary(content['text'], content['tables'], content['images'])
80
+ else:
81
+ enhanced_text = content['text']
82
+
83
+ doc_id = str(uuid.uuid4())
84
+
85
+ # Save heavy data to local in-memory store
86
+ doc_store.save_chunk(doc_id, {
87
+ "raw_text": content['text'],
88
+ "tables": content['tables'],
89
+ "images": content['images']
90
+ })
91
+
92
+ # Create light document for Pinecone
93
+ doc = Document(
94
+ page_content=enhanced_text, # "query: " prefix added automatically by some embeddings, but explicit here
95
+ metadata={"doc_id": doc_id, "chunk_index": i}
96
+ )
97
+ documents.append(doc)
98
+
99
+ # Ingest to Pinecone (Namespace = Session ID)
100
+ print(f"🔮 Ingesting to Namespace: {session_id}")
101
+ PineconeVectorStore.from_documents(
102
+ documents=documents,
103
+ index_name=INDEX_NAME,
104
+ embedding=get_embeddings(),
105
+ namespace=session_id
106
+ )
107
+
108
+ return documents # Return docs for BM25 initialization
109
+
110
+ def cleanup_session_index(session_id: str):
111
+ """Deletes the specific namespace for the session"""
112
+ try:
113
+ index = pc.Index(INDEX_NAME)
114
+ index.delete(delete_all=True, namespace=session_id)
115
+ print(f"🗑️ Deleted Namespace: {session_id}")
116
+ except Exception as e:
117
+ print(f"⚠️ Error deleting namespace: {e}")
rag_engine.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from typing import List
3
+ from langchain_pinecone import PineconeVectorStore
4
+ from langchain_core.documents import Document
5
+ from langchain_core.messages import HumanMessage
6
+ from flashrank import Ranker, RerankRequest
7
+ from config import *
8
+
9
+ # Logging setup
10
+ logging.basicConfig(level=logging.INFO)
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # ⚡ OPTIMIZATION 1: Use FlashRank (Ultra-fast CPU Re-ranking)
14
+ ranker = Ranker(model_name="ms-marco-MiniLM-L-12-v2", cache_dir="/tmp/flashrank_cache")
15
+
16
+ def reciprocal_rank_fusion(results: List[List[Document]], k=60):
17
+ fused_scores = {}
18
+ doc_map = {}
19
+ for docs in results:
20
+ for rank, doc in enumerate(docs):
21
+ doc_id = doc.metadata.get("doc_id")
22
+ if doc_id not in doc_map: doc_map[doc_id] = doc
23
+ if doc_id not in fused_scores: fused_scores[doc_id] = 0
24
+ fused_scores[doc_id] += 1 / (rank + k)
25
+
26
+ reranked_ids = sorted(fused_scores, key=fused_scores.get, reverse=True)
27
+ return [doc_map[doc_id] for doc_id in reranked_ids]
28
+
29
+ def rerank_documents(query: str, docs: List[Document], top_n=5):
30
+ """
31
+ Optimized Re-ranking using FlashRank (runs in milliseconds).
32
+ """
33
+ if not docs: return []
34
+
35
+ try:
36
+ # Prepare format for FlashRank
37
+ passages = [
38
+ {"id": str(i), "text": doc.page_content, "meta": doc.metadata}
39
+ for i, doc in enumerate(docs)
40
+ ]
41
+
42
+ # Rerank
43
+ rerank_request = RerankRequest(query=query, passages=passages)
44
+ results = ranker.rerank(rerank_request)
45
+
46
+ # Convert back to Document objects
47
+ final_docs = []
48
+ for res in results[:top_n]:
49
+ final_docs.append(Document(page_content=res["text"], metadata=res["meta"]))
50
+
51
+ return final_docs
52
+ except Exception as e:
53
+ logger.error(f"FlashRank failed: {e}")
54
+ return docs[:top_n] # Fallback
55
+
56
+ def run_advanced_rag(query: str, session_id: str, bm25_retriever, doc_store, chat_history):
57
+ # ⚡ OPTIMIZATION 2: Skip LLM Query Decomposition (Saves 3-5s)
58
+ # We treat the user query as the only query.
59
+ queries = [query]
60
+
61
+ # ⚡ OPTIMIZATION 3: Standard Similarity Search (Faster than MMR)
62
+ vectorstore = PineconeVectorStore.from_existing_index(
63
+ index_name=INDEX_NAME, embedding=get_embeddings(), namespace=session_id
64
+ )
65
+ dense_retriever = vectorstore.as_retriever(search_type="similarity", search_kwargs={"k": 3})
66
+
67
+ all_docs = []
68
+
69
+ # Hybrid Search
70
+ for q in queries:
71
+ dense_docs = dense_retriever.invoke(f"query: {q}")
72
+ sparse_docs = bm25_retriever.invoke(q)
73
+ # Fuse results
74
+ all_docs.extend(reciprocal_rank_fusion([dense_docs, sparse_docs]))
75
+
76
+ # Deduplicate by ID
77
+ unique_docs = {d.metadata["doc_id"]: d for d in all_docs}
78
+
79
+ # Re-rank (Fast)
80
+ final_docs = rerank_documents(query, list(unique_docs.values()), top_n=5)
81
+
82
+ # Context Construction
83
+ context_text = ""
84
+ retrieved_images = []
85
+ seen_imgs = set()
86
+
87
+ for i, doc in enumerate(final_docs):
88
+ # Fetch heavy content from in-memory store
89
+ heavy = doc_store.get_chunk(doc.metadata["doc_id"])
90
+
91
+ context_text += f"\n--- Source {i+1} ---\n{heavy.get('raw_text', '')}\n"
92
+ for t in heavy.get('tables', []):
93
+ context_text += f"[Table]: {t}\n"
94
+
95
+ # Collect images
96
+ for img in heavy.get('images', []):
97
+ if img not in seen_imgs:
98
+ seen_imgs.add(img)
99
+ retrieved_images.append(img)
100
+
101
+ # Final Generation
102
+ llm = get_llm()
103
+
104
+ # Limit history to last 2 turns to save tokens/time
105
+ hist_text = "\n".join([f"{m.type}: {m.content}" for m in chat_history[-2:]])
106
+
107
+ prompt = f"""
108
+ Answer the user question based on the provided context.
109
+ CHAT HISTORY: {hist_text}
110
+ CONTEXT: {context_text[:5000]}
111
+ QUESTION: {query}
112
+ """
113
+
114
+ msg_content = [{"type": "text", "text": prompt}]
115
+
116
+ # ⚡ OPTIMIZATION 4: Limit Images to top 2
117
+ for b64 in retrieved_images[:2]:
118
+ if "," in b64: b64 = b64.split(",")[1]
119
+ msg_content.append({
120
+ "type": "image_url",
121
+ "image_url": {"url": f"data:image/jpeg;base64,{b64}"}
122
+ })
123
+
124
+ response = llm.invoke([HumanMessage(content=msg_content)])
125
+
126
+ return response.content, retrieved_images
requirements.txt CHANGED
@@ -1,3 +1,15 @@
1
- altair
2
- pandas
3
- streamlit
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FlashRank==0.2.10
2
+ langchain==1.2.0
3
+ openai==2.11.0
4
+ langchain_community==0.4.1
5
+ langchain_core==1.2.6
6
+ langchain_huggingface==1.2.0
7
+ langchain_openai==1.1.6
8
+ langchain_pinecone==0.2.13
9
+ pinecone==7.3.0
10
+ python-dotenv==1.2.1
11
+ streamlit==1.44.1
12
+ unstructured[all-docs]==0.17.2
13
+ pdfminer.six
14
+ pi-heif==1.1.0
15
+