Spaces:
Sleeping
Sleeping
Commit ·
2ecc4a7
0
Parent(s):
Initial commit: Multimodal RAG Pipeline V3.0 with Fallback Logic
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +23 -0
- RAG_FULL_APPLICATION_BACKEND/.env.example +52 -0
- RAG_FULL_APPLICATION_BACKEND/Dockerfile +24 -0
- RAG_FULL_APPLICATION_BACKEND/__init__.py +0 -0
- RAG_FULL_APPLICATION_BACKEND/app/__init__.py +0 -0
- RAG_FULL_APPLICATION_BACKEND/app/config.py +61 -0
- RAG_FULL_APPLICATION_BACKEND/app/main.py +46 -0
- RAG_FULL_APPLICATION_BACKEND/app/models/__init__.py +0 -0
- RAG_FULL_APPLICATION_BACKEND/app/models/schemas.py +26 -0
- RAG_FULL_APPLICATION_BACKEND/app/routers/__init__.py +0 -0
- RAG_FULL_APPLICATION_BACKEND/app/routers/auth.py +63 -0
- RAG_FULL_APPLICATION_BACKEND/app/routers/ingest.py +156 -0
- RAG_FULL_APPLICATION_BACKEND/app/routers/query.py +59 -0
- RAG_FULL_APPLICATION_BACKEND/app/services/__init__.py +0 -0
- RAG_FULL_APPLICATION_BACKEND/app/services/bm25_service.py +55 -0
- RAG_FULL_APPLICATION_BACKEND/app/services/cache_service.py +42 -0
- RAG_FULL_APPLICATION_BACKEND/app/services/chunk_engine.py +133 -0
- RAG_FULL_APPLICATION_BACKEND/app/services/embed_service.py +91 -0
- RAG_FULL_APPLICATION_BACKEND/app/services/file_parser.py +114 -0
- RAG_FULL_APPLICATION_BACKEND/app/services/llm_service.py +175 -0
- RAG_FULL_APPLICATION_BACKEND/app/services/ocr_service.py +44 -0
- RAG_FULL_APPLICATION_BACKEND/app/services/rerank_service.py +40 -0
- RAG_FULL_APPLICATION_BACKEND/app/services/supabase_client.py +119 -0
- RAG_FULL_APPLICATION_BACKEND/app/services/vision_service.py +43 -0
- RAG_FULL_APPLICATION_BACKEND/app/techniques/__init__.py +0 -0
- RAG_FULL_APPLICATION_BACKEND/app/techniques/agentic_rag.py +101 -0
- RAG_FULL_APPLICATION_BACKEND/app/techniques/base.py +53 -0
- RAG_FULL_APPLICATION_BACKEND/app/techniques/cache_incremental.py +37 -0
- RAG_FULL_APPLICATION_BACKEND/app/techniques/colbert.py +76 -0
- RAG_FULL_APPLICATION_BACKEND/app/techniques/hybrid_search.py +32 -0
- RAG_FULL_APPLICATION_BACKEND/app/techniques/metadata_filter.py +34 -0
- RAG_FULL_APPLICATION_BACKEND/app/techniques/query_expansion.py +55 -0
- RAG_FULL_APPLICATION_BACKEND/app/techniques/ragas_eval.py +66 -0
- RAG_FULL_APPLICATION_BACKEND/app/techniques/reranking.py +30 -0
- RAG_FULL_APPLICATION_BACKEND/app/utils/__init__.py +0 -0
- RAG_FULL_APPLICATION_BACKEND/app/utils/auth_utils.py +30 -0
- RAG_FULL_APPLICATION_BACKEND/app/utils/hash_utils.py +5 -0
- RAG_FULL_APPLICATION_BACKEND/app/utils/json_utils.py +80 -0
- RAG_FULL_APPLICATION_BACKEND/app/utils/rank_utils.py +31 -0
- RAG_FULL_APPLICATION_BACKEND/app/utils/ws_manager.py +28 -0
- RAG_FULL_APPLICATION_BACKEND/data/bm25_indexes/052b708f-a8e7-428c-8f83-e895681c9db2.pkl +0 -0
- RAG_FULL_APPLICATION_BACKEND/data/bm25_indexes/227dd4b8-e2f8-4143-96e7-2cb86ab17271.pkl +0 -0
- RAG_FULL_APPLICATION_BACKEND/data/bm25_indexes/44de3912-dc59-4811-9e3e-466388a53f12.pkl +0 -0
- RAG_FULL_APPLICATION_BACKEND/data/bm25_indexes/492f59a0-472c-4ea3-a552-2c7dd8f15a26.pkl +0 -0
- RAG_FULL_APPLICATION_BACKEND/data/bm25_indexes/66284165-646e-4d90-9c7a-6bedad04fadd.pkl +0 -0
- RAG_FULL_APPLICATION_BACKEND/data/bm25_indexes/66d1c37c-7db8-4cf2-ac58-3cf57d15dfb3.pkl +0 -0
- RAG_FULL_APPLICATION_BACKEND/data/bm25_indexes/75595e3e-ca9e-4225-ae6b-fa6c5366d5ac.pkl +0 -0
- RAG_FULL_APPLICATION_BACKEND/data/bm25_indexes/80f52a4b-b6f5-404b-970f-dcae27e1caee.pkl +0 -0
- RAG_FULL_APPLICATION_BACKEND/data/bm25_indexes/815e0af7-418e-434f-b8f6-b4d52e8169f9.pkl +0 -0
- RAG_FULL_APPLICATION_BACKEND/data/bm25_indexes/82cf7809-1d31-4fe4-b7cd-eeb6c276cde0.pkl +0 -0
.gitignore
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- Backend & Security ---
|
| 2 |
+
.env
|
| 3 |
+
RAG_VENV/
|
| 4 |
+
__pycache__/
|
| 5 |
+
*.pyc
|
| 6 |
+
data/bm25_indexes/
|
| 7 |
+
*.log
|
| 8 |
+
|
| 9 |
+
# --- Frontend (Vite/React) ---
|
| 10 |
+
node_modules/
|
| 11 |
+
dist/
|
| 12 |
+
dist-ssr/
|
| 13 |
+
*.local
|
| 14 |
+
npm-debug.log*
|
| 15 |
+
yarn-debug.log*
|
| 16 |
+
yarn-error.log*
|
| 17 |
+
|
| 18 |
+
# --- OS & Editor ---
|
| 19 |
+
.DS_Store
|
| 20 |
+
.vscode/
|
| 21 |
+
.idea/
|
| 22 |
+
*.swp
|
| 23 |
+
*.swo
|
RAG_FULL_APPLICATION_BACKEND/.env.example
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Supabase
|
| 2 |
+
SUPABASE_URL =
|
| 3 |
+
SUPABASE_KEY =
|
| 4 |
+
SUPABASE_DB_URL =
|
| 5 |
+
|
| 6 |
+
# Embeddings (HF Space — free)
|
| 7 |
+
EMBED_API_URL = https://lamhieu-lightweight-embeddings.hf.space/
|
| 8 |
+
EMBED_MODEL = bge-m3
|
| 9 |
+
EMBED_DIM = 1024
|
| 10 |
+
EMBED_AUTH_KEY =
|
| 11 |
+
EMBED_MAX_TOKENS = 1000
|
| 12 |
+
EMBED_TIMEOUT = 60
|
| 13 |
+
EMBED_MAX_RETRIES = 3
|
| 14 |
+
|
| 15 |
+
# LLM — Qwen3 (HF Space — free)
|
| 16 |
+
QWEN3_MODEL_NAME = Qwen/Qwen3-Demo
|
| 17 |
+
QWEN3_THINKING_BUDGET = 38
|
| 18 |
+
LLM_RESPONSE_TIMEOUT = 1080
|
| 19 |
+
MAX_LLM_RETRIES = 5
|
| 20 |
+
MAX_TIMEOUT_RETRIES = 10
|
| 21 |
+
|
| 22 |
+
# OCR — Mistral (needs API key)
|
| 23 |
+
MISTRAL_OCR_SPACE = tatendachirume/Mistral-OCR
|
| 24 |
+
MISTRAL_API_KEY = "5gBKNRNZY2YllB6goe6OX0ycXdzbHS76"
|
| 25 |
+
|
| 26 |
+
# Image — Ernie Bot (free HF Space)
|
| 27 |
+
ERNIE_SPACE_URL = https://baidu-simple-ernie-bot-demo.hf.space/
|
| 28 |
+
|
| 29 |
+
# Redis
|
| 30 |
+
REDIS_URL = redis://localhost:6379
|
| 31 |
+
CACHE_TTL_SECONDS = 3600
|
| 32 |
+
|
| 33 |
+
# Auth
|
| 34 |
+
JWT_SECRET_KEY = "b0g2DlXIrvUcosozdEDOFtubAy+p30tJ6BFjx0ufYLM="
|
| 35 |
+
JWT_ALGORITHM = HS256
|
| 36 |
+
JWT_EXPIRE_MINUTES = 1440
|
| 37 |
+
|
| 38 |
+
# Re-ranking
|
| 39 |
+
RERANK_MODEL = cross-encoder/ms-marco-MiniLM-L-6-v2
|
| 40 |
+
|
| 41 |
+
# Limits
|
| 42 |
+
RATE_LIMIT_PER_MINUTE = 20
|
| 43 |
+
RATE_LIMIT_UPLOAD_PER_DAY = 50
|
| 44 |
+
MAX_FILE_SIZE_MB = 50
|
| 45 |
+
|
| 46 |
+
# Defaults
|
| 47 |
+
DEFAULT_CHUNK_SIZE = 512
|
| 48 |
+
DEFAULT_OVERLAP = 64
|
| 49 |
+
DEFAULT_TOP_K = 5
|
| 50 |
+
|
| 51 |
+
# CORS
|
| 52 |
+
CORS_ORIGINS = http://localhost:5173
|
RAG_FULL_APPLICATION_BACKEND/Dockerfile
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# System deps for python-docx, tiktoken, etc.
|
| 6 |
+
RUN apt-get update && apt-get install -y \
|
| 7 |
+
build-essential libpq-dev && \
|
| 8 |
+
rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
COPY requirements.txt .
|
| 11 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 12 |
+
|
| 13 |
+
# Download cross-encoder model at build time
|
| 14 |
+
RUN python -c "from sentence_transformers import CrossEncoder; \
|
| 15 |
+
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')"
|
| 16 |
+
|
| 17 |
+
COPY . .
|
| 18 |
+
|
| 19 |
+
# Create data dirs
|
| 20 |
+
RUN mkdir -p data/uploads data/bm25_indexes data/cache
|
| 21 |
+
|
| 22 |
+
EXPOSE 8000
|
| 23 |
+
|
| 24 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
|
RAG_FULL_APPLICATION_BACKEND/__init__.py
ADDED
|
File without changes
|
RAG_FULL_APPLICATION_BACKEND/app/__init__.py
ADDED
|
File without changes
|
RAG_FULL_APPLICATION_BACKEND/app/config.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic_settings import BaseSettings
|
| 2 |
+
from typing import Optional
|
| 3 |
+
|
| 4 |
+
class Settings(BaseSettings):
|
| 5 |
+
# Supabase
|
| 6 |
+
SUPABASE_URL: str
|
| 7 |
+
SUPABASE_KEY: str
|
| 8 |
+
SUPABASE_DB_URL: str
|
| 9 |
+
|
| 10 |
+
# Embeddings (bge-m3)
|
| 11 |
+
EMBED_API_URL: str = "https://lamhieu-lightweight-embeddings.hf.space/"
|
| 12 |
+
EMBED_MODEL: str = "bge-m3"
|
| 13 |
+
EMBED_DIM: int = 1024
|
| 14 |
+
EMBED_AUTH_KEY: str = ""
|
| 15 |
+
EMBED_MAX_TOKENS: int = 1000
|
| 16 |
+
EMBED_TIMEOUT: int = 60
|
| 17 |
+
EMBED_MAX_RETRIES: int = 3
|
| 18 |
+
|
| 19 |
+
# LLM — Qwen3
|
| 20 |
+
QWEN3_MODEL_NAME: str = "Qwen/Qwen3-Demo"
|
| 21 |
+
QWEN3_THINKING_BUDGET: int = 38
|
| 22 |
+
LLM_RESPONSE_TIMEOUT: int = 1080
|
| 23 |
+
MAX_LLM_RETRIES: int = 5
|
| 24 |
+
MAX_TIMEOUT_RETRIES: int = 10
|
| 25 |
+
|
| 26 |
+
# OCR — Mistral
|
| 27 |
+
MISTRAL_OCR_SPACE: str = "tatendachirume/Mistral-OCR"
|
| 28 |
+
MISTRAL_API_KEY: str = ""
|
| 29 |
+
|
| 30 |
+
# Image — Qwen-VL Vision
|
| 31 |
+
VISION_SPACE_URL: str = "Qwen/Qwen3-VL-30B-A3B-Demo"
|
| 32 |
+
|
| 33 |
+
# Redis
|
| 34 |
+
REDIS_URL: str
|
| 35 |
+
CACHE_TTL_SECONDS: int = 3600
|
| 36 |
+
|
| 37 |
+
# Auth
|
| 38 |
+
JWT_SECRET_KEY: str
|
| 39 |
+
JWT_ALGORITHM: str = "HS256"
|
| 40 |
+
JWT_EXPIRE_MINUTES: int = 1440
|
| 41 |
+
|
| 42 |
+
# Re-ranking
|
| 43 |
+
RERANK_MODEL: str = "cross-encoder/ms-marco-MiniLM-L-6-v2"
|
| 44 |
+
|
| 45 |
+
# Rate limiting
|
| 46 |
+
RATE_LIMIT_PER_MINUTE: int = 20
|
| 47 |
+
RATE_LIMIT_UPLOAD_PER_DAY: int = 50
|
| 48 |
+
|
| 49 |
+
# Defaults
|
| 50 |
+
DEFAULT_CHUNK_SIZE: int = 512
|
| 51 |
+
DEFAULT_OVERLAP: int = 64
|
| 52 |
+
DEFAULT_TOP_K: int = 5
|
| 53 |
+
MAX_FILE_SIZE_MB: int = 50
|
| 54 |
+
|
| 55 |
+
# CORS
|
| 56 |
+
CORS_ORIGINS: str
|
| 57 |
+
|
| 58 |
+
class Config:
|
| 59 |
+
env_file = ".env"
|
| 60 |
+
|
| 61 |
+
settings = Settings()
|
RAG_FULL_APPLICATION_BACKEND/app/main.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, WebSocket, Depends
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from .config import settings
|
| 4 |
+
from .utils.ws_manager import ws_manager
|
| 5 |
+
import logging
|
| 6 |
+
|
| 7 |
+
# Setup Logger
|
| 8 |
+
logging.basicConfig(level=logging.INFO)
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
app = FastAPI(title="RAG Pipeline API", version="3.0.0")
|
| 12 |
+
|
| 13 |
+
# CORS
|
| 14 |
+
origins = settings.CORS_ORIGINS.split(",")
|
| 15 |
+
app.add_middleware(
|
| 16 |
+
CORSMiddleware,
|
| 17 |
+
allow_origins=origins,
|
| 18 |
+
allow_credentials=True,
|
| 19 |
+
allow_methods=["*"],
|
| 20 |
+
allow_headers=["*"],
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
from .routers import auth, ingest, query
|
| 24 |
+
|
| 25 |
+
# Routers
|
| 26 |
+
app.include_router(auth.router, prefix="/auth", tags=["auth"])
|
| 27 |
+
app.include_router(ingest.router, prefix="/ingest", tags=["ingest"])
|
| 28 |
+
app.include_router(query.router, prefix="/query", tags=["query"])
|
| 29 |
+
|
| 30 |
+
@app.get("/health")
|
| 31 |
+
async def health_check():
|
| 32 |
+
return {"status": "healthy", "version": "3.0.0"}
|
| 33 |
+
|
| 34 |
+
@app.websocket("/ws/pipeline/{job_id}")
|
| 35 |
+
async def pipeline_ws(websocket: WebSocket, job_id: str, token: str):
|
| 36 |
+
# JWT verification logic will go here
|
| 37 |
+
# For now, just connect
|
| 38 |
+
await ws_manager.connect(job_id, websocket, "anonymous")
|
| 39 |
+
try:
|
| 40 |
+
while True:
|
| 41 |
+
data = await websocket.receive_text()
|
| 42 |
+
# Handle messages if needed
|
| 43 |
+
except Exception as e:
|
| 44 |
+
logger.error(f"WebSocket error for job {job_id}: {e}")
|
| 45 |
+
finally:
|
| 46 |
+
await ws_manager.disconnect(job_id, "anonymous")
|
RAG_FULL_APPLICATION_BACKEND/app/models/__init__.py
ADDED
|
File without changes
|
RAG_FULL_APPLICATION_BACKEND/app/models/schemas.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
from typing import List, Dict, Any, Optional
|
| 3 |
+
|
| 4 |
+
class UserCreate(BaseModel):
|
| 5 |
+
username: str
|
| 6 |
+
password: str
|
| 7 |
+
|
| 8 |
+
class UserResponse(BaseModel):
|
| 9 |
+
id: str
|
| 10 |
+
username: str
|
| 11 |
+
|
| 12 |
+
class Token(BaseModel):
|
| 13 |
+
access_token: str
|
| 14 |
+
token_type: str
|
| 15 |
+
|
| 16 |
+
class QueryRequest(BaseModel):
|
| 17 |
+
query: str
|
| 18 |
+
document_id: str
|
| 19 |
+
technique: str = "hybrid"
|
| 20 |
+
top_k: int = 5
|
| 21 |
+
filters: Optional[Dict[str, Any]] = None
|
| 22 |
+
|
| 23 |
+
class QueryResponse(BaseModel):
|
| 24 |
+
answer: str
|
| 25 |
+
sources: List[Dict[str, Any]]
|
| 26 |
+
job_id: str
|
RAG_FULL_APPLICATION_BACKEND/app/routers/__init__.py
ADDED
|
File without changes
|
RAG_FULL_APPLICATION_BACKEND/app/routers/auth.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 2 |
+
from fastapi.security import OAuth2PasswordRequestForm
|
| 3 |
+
from ..utils.auth_utils import verify_password, get_password_hash, create_access_token
|
| 4 |
+
from ..services.supabase_client import supabase_service
|
| 5 |
+
from ..models.schemas import UserCreate, Token, UserResponse
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
router = APIRouter()
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
@router.post("/register", response_model=UserResponse)
|
| 12 |
+
async def register(user: UserCreate):
|
| 13 |
+
# Hash password
|
| 14 |
+
hashed = get_password_hash(user.password)
|
| 15 |
+
|
| 16 |
+
# Store in Supabase
|
| 17 |
+
try:
|
| 18 |
+
result = supabase_service.client.table("users").insert({
|
| 19 |
+
"username": user.username,
|
| 20 |
+
"password_hash": hashed
|
| 21 |
+
}).execute()
|
| 22 |
+
return result.data[0]
|
| 23 |
+
except Exception as e:
|
| 24 |
+
logger.error(f"Registration failed: {e}")
|
| 25 |
+
raise HTTPException(status_code=400, detail="User already exists")
|
| 26 |
+
|
| 27 |
+
@router.post("/login", response_model=Token)
|
| 28 |
+
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
|
| 29 |
+
# Fetch user from Supabase
|
| 30 |
+
result = supabase_service.client.table("users")\
|
| 31 |
+
.select("*")\
|
| 32 |
+
.eq("username", form_data.username).execute()
|
| 33 |
+
|
| 34 |
+
if not result.data:
|
| 35 |
+
raise HTTPException(status_code=401, detail="Invalid credentials")
|
| 36 |
+
|
| 37 |
+
user = result.data[0]
|
| 38 |
+
if not verify_password(form_data.password, user["password_hash"]):
|
| 39 |
+
logger.warning(f"Login failed for user: {form_data.username} - password mismatch")
|
| 40 |
+
raise HTTPException(status_code=401, detail="Invalid credentials")
|
| 41 |
+
|
| 42 |
+
logger.info(f"User logged in: {form_data.username}")
|
| 43 |
+
# Create token
|
| 44 |
+
access_token = create_access_token(data={"sub": user["username"], "id": user["id"]})
|
| 45 |
+
return {"access_token": access_token, "token_type": "bearer"}
|
| 46 |
+
|
| 47 |
+
@router.post("/seed_admin")
|
| 48 |
+
async def seed_admin():
|
| 49 |
+
"""Utility to pre-create admin user for local testing. Forced clean sync."""
|
| 50 |
+
hashed = get_password_hash("admin123")
|
| 51 |
+
try:
|
| 52 |
+
# Delete existing to ensure fresh hash if environment changed
|
| 53 |
+
supabase_service.client.table("users").delete().eq("username", "admin").execute()
|
| 54 |
+
|
| 55 |
+
supabase_service.client.table("users").insert({
|
| 56 |
+
"username": "admin",
|
| 57 |
+
"password_hash": hashed
|
| 58 |
+
}).execute()
|
| 59 |
+
logger.info("Admin user seeded successfully.")
|
| 60 |
+
return {"msg": "Admin user created/reset (admin / admin123)"}
|
| 61 |
+
except Exception as e:
|
| 62 |
+
logger.error(f"Seeding failed: {e}")
|
| 63 |
+
return {"msg": f"Seeding failed: {str(e)}"}
|
RAG_FULL_APPLICATION_BACKEND/app/routers/ingest.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, UploadFile, File, BackgroundTasks, Depends, HTTPException, Form
|
| 2 |
+
from fastapi.security import OAuth2PasswordBearer
|
| 3 |
+
from ..services.supabase_client import supabase_service
|
| 4 |
+
from ..services.file_parser import parse_file
|
| 5 |
+
from ..services.chunk_engine import ChunkEngine
|
| 6 |
+
from ..services.embed_service import embed_batch, get_embedding
|
| 7 |
+
from ..services.bm25_service import bm25_service
|
| 8 |
+
from ..utils.ws_manager import ws_manager
|
| 9 |
+
from ..utils.auth_utils import decode_token
|
| 10 |
+
import os
|
| 11 |
+
import uuid
|
| 12 |
+
import logging
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
router = APIRouter()
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
|
| 19 |
+
|
| 20 |
+
# Auth Dependency — reads from Authorization: Bearer header
|
| 21 |
+
def get_current_user(token: str = Depends(oauth2_scheme)):
|
| 22 |
+
payload = decode_token(token)
|
| 23 |
+
if not payload:
|
| 24 |
+
raise HTTPException(status_code=401, detail="Invalid token")
|
| 25 |
+
return payload
|
| 26 |
+
|
| 27 |
+
@router.post("/upload")
|
| 28 |
+
async def upload_file(
|
| 29 |
+
background_tasks: BackgroundTasks,
|
| 30 |
+
file: UploadFile = File(...),
|
| 31 |
+
chunk_size: int = Form(512),
|
| 32 |
+
overlap: int = Form(64),
|
| 33 |
+
strategy: str = Form("fixed"),
|
| 34 |
+
user: dict = Depends(get_current_user)
|
| 35 |
+
):
|
| 36 |
+
job_id = str(uuid.uuid4())
|
| 37 |
+
temp_dir = Path("./data/uploads") / user["id"]
|
| 38 |
+
temp_dir.mkdir(parents=True, exist_ok=True)
|
| 39 |
+
file_path = temp_dir / file.filename
|
| 40 |
+
|
| 41 |
+
with open(file_path, "wb") as f:
|
| 42 |
+
f.write(await file.read())
|
| 43 |
+
|
| 44 |
+
# Start ingestion in background
|
| 45 |
+
background_tasks.add_task(
|
| 46 |
+
process_ingestion,
|
| 47 |
+
str(file_path),
|
| 48 |
+
file.filename,
|
| 49 |
+
chunk_size,
|
| 50 |
+
overlap,
|
| 51 |
+
strategy,
|
| 52 |
+
job_id,
|
| 53 |
+
user["id"]
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
return {"job_id": job_id, "filename": file.filename}
|
| 57 |
+
|
| 58 |
+
@router.get("/documents")
|
| 59 |
+
async def list_documents(user: dict = Depends(get_current_user)):
|
| 60 |
+
try:
|
| 61 |
+
result = supabase_service.client.table("documents")\
|
| 62 |
+
.select("*")\
|
| 63 |
+
.eq("user_id", user["id"])\
|
| 64 |
+
.order("created_at", desc=True).execute()
|
| 65 |
+
return result.data
|
| 66 |
+
except Exception as e:
|
| 67 |
+
logger.error(f"Failed to list documents: {e}")
|
| 68 |
+
raise HTTPException(status_code=500, detail="Database error")
|
| 69 |
+
|
| 70 |
+
@router.delete("/documents/{doc_id}")
|
| 71 |
+
async def delete_document(doc_id: str, user_id: str = Depends(get_current_user)):
|
| 72 |
+
try:
|
| 73 |
+
# 1. Database cleanup
|
| 74 |
+
await supabase_service.delete_document(doc_id, user_id["id"])
|
| 75 |
+
# 2. BM25 cleanup
|
| 76 |
+
bm25_service.delete_document(doc_id)
|
| 77 |
+
return {"status": "success", "message": f"Document {doc_id} deleted"}
|
| 78 |
+
except Exception as e:
|
| 79 |
+
logger.error(f"Failed to delete document {doc_id}: {e}")
|
| 80 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 81 |
+
|
| 82 |
+
async def process_ingestion(file_path: str, filename: str, chunk_size: int, overlap: int, strategy: str, job_id: str, user_id: str):
|
| 83 |
+
try:
|
| 84 |
+
await ws_manager.emit(job_id, user_id, {"step": "START", "color": "#8B5CF6", "detail": f"Starting ingestion for {filename}..."})
|
| 85 |
+
|
| 86 |
+
# 1. Parse
|
| 87 |
+
file_type = filename.split(".")[-1]
|
| 88 |
+
docs = await parse_file(file_path, file_type, job_id, ws_manager, user_id)
|
| 89 |
+
|
| 90 |
+
# 2. Chunk
|
| 91 |
+
await ws_manager.emit(job_id, user_id, {"step": "CHUNKING", "color": "#6B7280", "detail": f"Applying {strategy} chunking strategy..."})
|
| 92 |
+
engine = ChunkEngine(chunk_size, overlap, strategy)
|
| 93 |
+
chunks = engine.chunk(docs)
|
| 94 |
+
|
| 95 |
+
# 3. Create Document entry
|
| 96 |
+
doc_result = supabase_service.client.table("documents").insert({
|
| 97 |
+
"user_id": user_id,
|
| 98 |
+
"filename": filename,
|
| 99 |
+
"file_type": file_type,
|
| 100 |
+
"technique": "hybrid", # default
|
| 101 |
+
"chunk_strategy": strategy,
|
| 102 |
+
"chunk_size": chunk_size,
|
| 103 |
+
"overlap": overlap,
|
| 104 |
+
"status": "running",
|
| 105 |
+
"chunk_count": len(chunks)
|
| 106 |
+
}).execute()
|
| 107 |
+
document_id = doc_result.data[0]["id"]
|
| 108 |
+
|
| 109 |
+
# 4. Incremental Check & Embed
|
| 110 |
+
await ws_manager.emit(job_id, user_id, {"step": "EMBEDDING", "color": "#8B5CF6", "detail": f"Vectorizing {len(chunks)} chunks..."})
|
| 111 |
+
embeddings = await embed_batch([c["text"] for c in chunks])
|
| 112 |
+
print(f"DEBUG: Embedding complete. First vector len: {len(embeddings[0]) if embeddings else 0}")
|
| 113 |
+
|
| 114 |
+
# 5. Insert to Supabase
|
| 115 |
+
await ws_manager.emit(job_id, user_id, {"step": "STORING", "color": "#22C55E", "detail": "Storing chunks and vectors in Supabase..."})
|
| 116 |
+
|
| 117 |
+
# Prepare rows
|
| 118 |
+
chunk_rows = []
|
| 119 |
+
for i, c in enumerate(chunks):
|
| 120 |
+
c["document_id"] = document_id
|
| 121 |
+
c["user_id"] = user_id
|
| 122 |
+
chunk_rows.append(c)
|
| 123 |
+
|
| 124 |
+
chunk_ids = await supabase_service.insert_chunks(chunk_rows)
|
| 125 |
+
|
| 126 |
+
vector_rows = []
|
| 127 |
+
for i, cid in enumerate(chunk_ids):
|
| 128 |
+
vector_rows.append({
|
| 129 |
+
"chunk_id": cid,
|
| 130 |
+
"document_id": document_id,
|
| 131 |
+
"user_id": user_id,
|
| 132 |
+
"embedding": embeddings[i]
|
| 133 |
+
})
|
| 134 |
+
await supabase_service.upsert_vectors(vector_rows)
|
| 135 |
+
|
| 136 |
+
# 6. Index BM25
|
| 137 |
+
await ws_manager.emit(job_id, user_id, {"step": "BM25_INDEX", "color": "#22C55E", "detail": "Building BM25 keyword index..."})
|
| 138 |
+
bm25_service.index_chunks(document_id, chunk_rows)
|
| 139 |
+
|
| 140 |
+
# Special check for ColBERT
|
| 141 |
+
# if technique == "colbert": embed all tokens... (skipped for brevity in base ingest)
|
| 142 |
+
|
| 143 |
+
supabase_service.client.table("documents").update({"status": "done"}).eq("id", document_id).execute()
|
| 144 |
+
await ws_manager.emit(job_id, user_id, {"step": "DONE", "color": "#22C55E", "detail": "Ingestion complete!", "metadata": {"doc_id": document_id}})
|
| 145 |
+
|
| 146 |
+
except Exception as e:
|
| 147 |
+
import traceback
|
| 148 |
+
logger.error(f"Ingestion failed: {e}")
|
| 149 |
+
logger.error(traceback.format_exc())
|
| 150 |
+
await ws_manager.emit(job_id, user_id, {"step": "ERROR", "color": "#EF4444", "detail": f"Ingestion failed: {str(e)}"})
|
| 151 |
+
if 'document_id' in locals():
|
| 152 |
+
supabase_service.client.table("documents").update({"status": "failed"}).eq("id", document_id).execute()
|
| 153 |
+
finally:
|
| 154 |
+
# Cleanup
|
| 155 |
+
if os.path.exists(file_path):
|
| 156 |
+
os.remove(file_path)
|
RAG_FULL_APPLICATION_BACKEND/app/routers/query.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 2 |
+
from ..models.schemas import QueryRequest, QueryResponse
|
| 3 |
+
from ..routers.ingest import get_current_user
|
| 4 |
+
from ..techniques.hybrid_search import HybridSearch
|
| 5 |
+
from ..techniques.reranking import ReRanking
|
| 6 |
+
from ..techniques.query_expansion import QueryExpansion
|
| 7 |
+
from ..techniques.metadata_filter import MetadataFilter
|
| 8 |
+
from ..techniques.colbert import ColBERT
|
| 9 |
+
from ..techniques.agentic_rag import AgenticRAG
|
| 10 |
+
from ..techniques.cache_incremental import CacheIncrementalRAG
|
| 11 |
+
import uuid
|
| 12 |
+
import logging
|
| 13 |
+
|
| 14 |
+
router = APIRouter()
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
TECHNIQUE_MAP = {
|
| 18 |
+
"hybrid": HybridSearch,
|
| 19 |
+
"rerank": ReRanking,
|
| 20 |
+
"hyde": QueryExpansion,
|
| 21 |
+
"meta": MetadataFilter,
|
| 22 |
+
"colbert": ColBERT,
|
| 23 |
+
"agentic": AgenticRAG,
|
| 24 |
+
"cache": CacheIncrementalRAG
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
@router.post("/search", response_model=QueryResponse)
|
| 28 |
+
async def search(
|
| 29 |
+
request: QueryRequest,
|
| 30 |
+
user: dict = Depends(get_current_user)
|
| 31 |
+
):
|
| 32 |
+
job_id = str(uuid.uuid4())
|
| 33 |
+
technique_cls = TECHNIQUE_MAP.get(request.technique)
|
| 34 |
+
|
| 35 |
+
if not technique_cls:
|
| 36 |
+
raise HTTPException(status_code=400, detail="Invalid technique")
|
| 37 |
+
|
| 38 |
+
try:
|
| 39 |
+
# Instantiate technique
|
| 40 |
+
instance = technique_cls(job_id, user["id"])
|
| 41 |
+
|
| 42 |
+
# Run pipeline
|
| 43 |
+
# Passing extra filters if technique is metadata_filter
|
| 44 |
+
result = await instance.run(
|
| 45 |
+
query=request.query,
|
| 46 |
+
document_id=request.document_id,
|
| 47 |
+
top_k=request.top_k,
|
| 48 |
+
filters=request.filters,
|
| 49 |
+
underlying_technique="hybrid" # for cache technique
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
return QueryResponse(
|
| 53 |
+
answer=result["answer"],
|
| 54 |
+
sources=result["sources"],
|
| 55 |
+
job_id=job_id
|
| 56 |
+
)
|
| 57 |
+
except Exception as e:
|
| 58 |
+
logger.error(f"Search failed: {e}")
|
| 59 |
+
raise HTTPException(status_code=500, detail=str(e))
|
RAG_FULL_APPLICATION_BACKEND/app/services/__init__.py
ADDED
|
File without changes
|
RAG_FULL_APPLICATION_BACKEND/app/services/bm25_service.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pickle
|
| 2 |
+
import os
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from typing import List, Dict, Any
|
| 5 |
+
from rank_bm25 import BM25Okapi
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
class BM25Service:
|
| 11 |
+
def __init__(self, data_dir: str = "./data/bm25_indexes"):
|
| 12 |
+
self.data_dir = Path(data_dir)
|
| 13 |
+
self.data_dir.mkdir(parents=True, exist_ok=True)
|
| 14 |
+
|
| 15 |
+
def _get_index_path(self, document_id: str) -> Path:
|
| 16 |
+
return self.data_dir / f"{document_id}.pkl"
|
| 17 |
+
|
| 18 |
+
def index_chunks(self, document_id: str, chunks: List[Dict[str, Any]]):
|
| 19 |
+
"""Build and save BM25 index for a document."""
|
| 20 |
+
texts = [c["text"] for c in chunks]
|
| 21 |
+
tokenized_corpus = [text.lower().split() for text in texts]
|
| 22 |
+
bm25 = BM25Okapi(tokenized_corpus)
|
| 23 |
+
|
| 24 |
+
# Save both the bm25 object and the chunk mapping
|
| 25 |
+
with open(self._get_index_path(document_id), "wb") as f:
|
| 26 |
+
pickle.dump({"bm25": bm25, "chunks": chunks}, f)
|
| 27 |
+
|
| 28 |
+
def search(self, document_id: str, query: str, top_n: int = 10) -> List[Dict[str, Any]]:
|
| 29 |
+
"""Search using BM25."""
|
| 30 |
+
path = self._get_index_path(document_id)
|
| 31 |
+
if not path.exists():
|
| 32 |
+
logger.warning(f"BM25 index not found for {document_id}")
|
| 33 |
+
return []
|
| 34 |
+
|
| 35 |
+
with open(path, "rb") as f:
|
| 36 |
+
data = pickle.load(f)
|
| 37 |
+
bm25 = data["bm25"]
|
| 38 |
+
chunks = data["chunks"]
|
| 39 |
+
|
| 40 |
+
tokenized_query = query.lower().split()
|
| 41 |
+
scores = bm25.get_scores(tokenized_query)
|
| 42 |
+
|
| 43 |
+
# Add score to chunks
|
| 44 |
+
results = []
|
| 45 |
+
for i, score in enumerate(scores):
|
| 46 |
+
if score > 0:
|
| 47 |
+
chunk = chunks[i].copy()
|
| 48 |
+
chunk["bm25_score"] = float(score)
|
| 49 |
+
results.append(chunk)
|
| 50 |
+
|
| 51 |
+
# Sort by score
|
| 52 |
+
results.sort(key=lambda x: x["bm25_score"], reverse=True)
|
| 53 |
+
return results[:top_n]
|
| 54 |
+
|
| 55 |
+
bm25_service = BM25Service()
|
RAG_FULL_APPLICATION_BACKEND/app/services/cache_service.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import redis
|
| 2 |
+
import hashlib
|
| 3 |
+
import json
|
| 4 |
+
from typing import Optional, Dict, Any
|
| 5 |
+
from ..config import settings
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
class CacheService:
|
| 11 |
+
def __init__(self):
|
| 12 |
+
try:
|
| 13 |
+
self.redis = redis.from_url(settings.REDIS_URL, decode_responses=True)
|
| 14 |
+
except Exception as e:
|
| 15 |
+
logger.error(f"Failed to connect to Redis: {e}")
|
| 16 |
+
self.redis = None
|
| 17 |
+
|
| 18 |
+
def _get_key(self, user_id: str, document_id: str, query: str, technique: str) -> str:
|
| 19 |
+
data = f"{user_id}:{document_id}:{query}:{technique}"
|
| 20 |
+
q_hash = hashlib.sha256(data.encode()).hexdigest()
|
| 21 |
+
return f"rag_cache:{q_hash}"
|
| 22 |
+
|
| 23 |
+
def get(self, user_id: str, document_id: str, query: str, technique: str) -> Optional[Dict[str, Any]]:
|
| 24 |
+
if not self.redis: return None
|
| 25 |
+
key = self._get_key(user_id, document_id, query, technique)
|
| 26 |
+
try:
|
| 27 |
+
val = self.redis.get(key)
|
| 28 |
+
if val:
|
| 29 |
+
return json.loads(val)
|
| 30 |
+
except Exception as e:
|
| 31 |
+
logger.error(f"Redis get failed: {e}")
|
| 32 |
+
return None
|
| 33 |
+
|
| 34 |
+
def set(self, user_id: str, document_id: str, query: str, technique: str, response: Dict[str, Any]):
|
| 35 |
+
if not self.redis: return
|
| 36 |
+
key = self._get_key(user_id, document_id, query, technique)
|
| 37 |
+
try:
|
| 38 |
+
self.redis.setex(key, settings.CACHE_TTL_SECONDS, json.dumps(response))
|
| 39 |
+
except Exception as e:
|
| 40 |
+
logger.error(f"Redis set failed: {e}")
|
| 41 |
+
|
| 42 |
+
cache_service = CacheService()
|
RAG_FULL_APPLICATION_BACKEND/app/services/chunk_engine.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import tiktoken
|
| 2 |
+
from typing import List, Dict, Any
|
| 3 |
+
import uuid
|
| 4 |
+
import hashlib
|
| 5 |
+
|
| 6 |
+
MAX_CHUNK_TOKENS = 1000
|
| 7 |
+
|
| 8 |
+
class ChunkEngine:
|
| 9 |
+
def __init__(self, chunk_size: int = 512, overlap: int = 64, strategy: str = "fixed"):
|
| 10 |
+
self.chunk_size = min(chunk_size, MAX_CHUNK_TOKENS)
|
| 11 |
+
self.overlap = min(overlap, self.chunk_size // 4)
|
| 12 |
+
self.strategy = strategy
|
| 13 |
+
self.enc = tiktoken.get_encoding("cl100k_base")
|
| 14 |
+
|
| 15 |
+
def chunk(self, docs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 16 |
+
match self.strategy:
|
| 17 |
+
case "fixed" | "token": return self._fixed(docs)
|
| 18 |
+
case "semantic" | "paragraph": return self._semantic(docs)
|
| 19 |
+
case "per_page": return self._per_page(docs)
|
| 20 |
+
case "per_item": return self._per_item(docs)
|
| 21 |
+
case "recursive": return self._recursive(docs)
|
| 22 |
+
case "sentence": return self._sentence(docs)
|
| 23 |
+
case "parent_child": return self._parent_child(docs)
|
| 24 |
+
case "sliding_window": return self._fixed(docs)
|
| 25 |
+
case _: return self._fixed(docs)
|
| 26 |
+
|
| 27 |
+
def _create_chunk(self, text: str, metadata: Dict[str, Any], index: int, parent_id: str = None) -> Dict[str, Any]:
|
| 28 |
+
return {
|
| 29 |
+
"id": str(uuid.uuid4()),
|
| 30 |
+
"text": text,
|
| 31 |
+
"token_count": len(self.enc.encode(text)),
|
| 32 |
+
"page": metadata.get("page"),
|
| 33 |
+
"section": metadata.get("section"),
|
| 34 |
+
"chunk_index": index,
|
| 35 |
+
"parent_chunk_id": parent_id,
|
| 36 |
+
"text_hash": hashlib.sha256(text.encode()).hexdigest(),
|
| 37 |
+
"metadata": metadata
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
def _fixed(self, docs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 41 |
+
chunks = []
|
| 42 |
+
for doc in docs:
|
| 43 |
+
tokens = self.enc.encode(doc["text"])
|
| 44 |
+
for i in range(0, len(tokens), self.chunk_size - self.overlap):
|
| 45 |
+
chunk_tokens = tokens[i : i + self.chunk_size]
|
| 46 |
+
chunk_text = self.enc.decode(chunk_tokens)
|
| 47 |
+
chunks.append(self._create_chunk(chunk_text, doc["metadata"], len(chunks)))
|
| 48 |
+
return chunks
|
| 49 |
+
|
| 50 |
+
def _semantic(self, docs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 51 |
+
"""Uses pre-split sections from parsers (MD/DOCX)."""
|
| 52 |
+
chunks = []
|
| 53 |
+
for doc in docs:
|
| 54 |
+
chunks.append(self._create_chunk(doc["text"], doc["metadata"], len(chunks)))
|
| 55 |
+
return chunks
|
| 56 |
+
|
| 57 |
+
def _per_page(self, docs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 58 |
+
"""One chunk per page metadata."""
|
| 59 |
+
return self._semantic(docs)
|
| 60 |
+
|
| 61 |
+
def _per_item(self, docs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 62 |
+
"""One chunk per item (JSON)."""
|
| 63 |
+
return self._semantic(docs)
|
| 64 |
+
|
| 65 |
+
def _recursive(self, docs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 66 |
+
"""Simple recursive splitter using separators."""
|
| 67 |
+
separators = ["\n\n", "\n", ". ", " ", ""]
|
| 68 |
+
chunks = []
|
| 69 |
+
|
| 70 |
+
def split_text(text: str, metadata: Dict[str, Any]):
|
| 71 |
+
if len(self.enc.encode(text)) <= self.chunk_size:
|
| 72 |
+
chunks.append(self._create_chunk(text, metadata, len(chunks)))
|
| 73 |
+
return
|
| 74 |
+
|
| 75 |
+
for sep in separators:
|
| 76 |
+
if sep in text:
|
| 77 |
+
parts = text.split(sep)
|
| 78 |
+
# Merging logic could be added here to maximize chunk size
|
| 79 |
+
for p in parts:
|
| 80 |
+
if p.strip():
|
| 81 |
+
split_text(p.strip(), metadata)
|
| 82 |
+
break
|
| 83 |
+
|
| 84 |
+
for doc in docs:
|
| 85 |
+
split_text(doc["text"], doc["metadata"])
|
| 86 |
+
return chunks
|
| 87 |
+
|
| 88 |
+
def _parent_child(self, docs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 89 |
+
"""
|
| 90 |
+
Retrieval on children (small), context from parent (large).
|
| 91 |
+
We return both but tag them.
|
| 92 |
+
"""
|
| 93 |
+
all_chunks = []
|
| 94 |
+
parent_size = self.chunk_size
|
| 95 |
+
child_size = parent_size // 4
|
| 96 |
+
|
| 97 |
+
for doc in docs:
|
| 98 |
+
tokens = self.enc.encode(doc["text"])
|
| 99 |
+
# Create parents
|
| 100 |
+
for i in range(0, len(tokens), parent_size):
|
| 101 |
+
parent_tokens = tokens[i : i + parent_size]
|
| 102 |
+
parent_text = self.enc.decode(parent_tokens)
|
| 103 |
+
parent_chunk = self._create_chunk(parent_text, doc["metadata"], len(all_chunks))
|
| 104 |
+
parent_chunk["metadata"]["is_parent"] = True
|
| 105 |
+
all_chunks.append(parent_chunk)
|
| 106 |
+
|
| 107 |
+
# Create children for this parent
|
| 108 |
+
for j in range(0, len(parent_tokens), child_size):
|
| 109 |
+
child_tokens = parent_tokens[j : j + child_size]
|
| 110 |
+
child_text = self.enc.decode(child_tokens)
|
| 111 |
+
child_chunk = self._create_chunk(child_text, doc["metadata"], len(all_chunks), parent_chunk["id"])
|
| 112 |
+
child_chunk["metadata"]["is_parent"] = False
|
| 113 |
+
all_chunks.append(child_chunk)
|
| 114 |
+
|
| 115 |
+
return all_chunks
|
| 116 |
+
|
| 117 |
+
def _sentence(self, docs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 118 |
+
"""Simple sentence splitter."""
|
| 119 |
+
import re
|
| 120 |
+
chunks = []
|
| 121 |
+
for doc in docs:
|
| 122 |
+
sentences = re.split(r'(?<=[.!?]) +', doc["text"])
|
| 123 |
+
current_chunk = ""
|
| 124 |
+
for sentence in sentences:
|
| 125 |
+
if len(self.enc.encode(current_chunk + " " + sentence)) <= self.chunk_size:
|
| 126 |
+
current_chunk += (" " if current_chunk else "") + sentence
|
| 127 |
+
else:
|
| 128 |
+
if current_chunk:
|
| 129 |
+
chunks.append(self._create_chunk(current_chunk, doc["metadata"], len(chunks)))
|
| 130 |
+
current_chunk = sentence
|
| 131 |
+
if current_chunk:
|
| 132 |
+
chunks.append(self._create_chunk(current_chunk, doc["metadata"], len(chunks)))
|
| 133 |
+
return chunks
|
RAG_FULL_APPLICATION_BACKEND/app/services/embed_service.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import tiktoken
|
| 2 |
+
import httpx, json_repair, json
|
| 3 |
+
import asyncio
|
| 4 |
+
from typing import List, Dict, Any
|
| 5 |
+
from ..config import settings
|
| 6 |
+
from ..utils.json_utils import repair_json
|
| 7 |
+
from gradio_client import Client
|
| 8 |
+
import logging
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
enc = tiktoken.get_encoding("cl100k_base")
|
| 12 |
+
|
| 13 |
+
_gradio_client = None
|
| 14 |
+
_fallback_model = None
|
| 15 |
+
|
| 16 |
+
def get_gradio_client():
|
| 17 |
+
global _gradio_client
|
| 18 |
+
if _gradio_client is None:
|
| 19 |
+
logger.info(f"Initializing Gradio client for {settings.EMBED_API_URL}")
|
| 20 |
+
_gradio_client = Client(settings.EMBED_API_URL)
|
| 21 |
+
return _gradio_client
|
| 22 |
+
|
| 23 |
+
def truncate_to_1k(text: str) -> str:
|
| 24 |
+
tokens = enc.encode(text)
|
| 25 |
+
if len(tokens) > 1000:
|
| 26 |
+
return enc.decode(tokens[:1000])
|
| 27 |
+
return text
|
| 28 |
+
|
| 29 |
+
def get_fallback_model():
|
| 30 |
+
global _fallback_model
|
| 31 |
+
if _fallback_model is None:
|
| 32 |
+
from sentence_transformers import SentenceTransformer
|
| 33 |
+
logger.info("Initializing fallback local embedding model (bge-large-en-v1.5)...")
|
| 34 |
+
_fallback_model = SentenceTransformer('BAAI/bge-large-en-v1.5')
|
| 35 |
+
dim = _fallback_model.get_sentence_embedding_dimension()
|
| 36 |
+
logger.info(f"Fallback model initialized. Dimension: {dim}")
|
| 37 |
+
return _fallback_model
|
| 38 |
+
|
| 39 |
+
def get_embedding(text: str) -> List[float]:
|
| 40 |
+
"""
|
| 41 |
+
Get embedding using bge-m3 / snowflake via HF Space (Primary)
|
| 42 |
+
Falls back to all-MiniLM-L6-v2 (Local) if API fails.
|
| 43 |
+
"""
|
| 44 |
+
text = truncate_to_1k(text)
|
| 45 |
+
|
| 46 |
+
# Attempt 1: Gradio Client
|
| 47 |
+
try:
|
| 48 |
+
client = get_gradio_client()
|
| 49 |
+
result = client.predict(
|
| 50 |
+
user_input=text,
|
| 51 |
+
selected_model=settings.EMBED_MODEL,
|
| 52 |
+
auth_key=settings.EMBED_AUTH_KEY,
|
| 53 |
+
api_name="/call_embeddings_api"
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
if isinstance(result, str):
|
| 57 |
+
data = repair_json(result)
|
| 58 |
+
else:
|
| 59 |
+
data = result
|
| 60 |
+
|
| 61 |
+
if isinstance(data, list): return data
|
| 62 |
+
if isinstance(data, dict) and "data" in data:
|
| 63 |
+
d = data["data"]
|
| 64 |
+
if isinstance(d, list) and len(d) > 0:
|
| 65 |
+
if isinstance(d[0], dict) and "embedding" in d[0]:
|
| 66 |
+
emb = d[0]["embedding"]
|
| 67 |
+
logger.info(f"Primary API generated vector of length: {len(emb)}")
|
| 68 |
+
return emb
|
| 69 |
+
if isinstance(d[0], list):
|
| 70 |
+
logger.info(f"Primary API generated vector of length: {len(d[0])}")
|
| 71 |
+
return d[0]
|
| 72 |
+
logger.info(f"Primary API generated vector of length: {len(d)}")
|
| 73 |
+
return d
|
| 74 |
+
raise ValueError("Unknown API response format")
|
| 75 |
+
|
| 76 |
+
except Exception as e:
|
| 77 |
+
logger.warning(f"Primary embedding failed: {e}. Falling back to local model...")
|
| 78 |
+
model = get_fallback_model()
|
| 79 |
+
emb = model.encode(text).tolist()
|
| 80 |
+
logger.info(f"Generated embedding vector of length: {len(emb)}")
|
| 81 |
+
return emb
|
| 82 |
+
|
| 83 |
+
async def embed_batch(texts: List[str]) -> List[List[float]]:
|
| 84 |
+
"""
|
| 85 |
+
Batch embedding for ingestion.
|
| 86 |
+
"""
|
| 87 |
+
all_embeddings = []
|
| 88 |
+
for text in texts:
|
| 89 |
+
emb = await asyncio.to_thread(get_embedding, text)
|
| 90 |
+
all_embeddings.append(emb)
|
| 91 |
+
return all_embeddings
|
RAG_FULL_APPLICATION_BACKEND/app/services/file_parser.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import re
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from typing import List, Dict, Any
|
| 5 |
+
from docx import Document
|
| 6 |
+
from .ocr_service import ocr_service
|
| 7 |
+
from .vision_service import vision_service
|
| 8 |
+
import logging
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
async def parse_file(file_path: str, file_type: str, job_id: str, ws_manager: Any, user_id: str) -> List[Dict[str, Any]]:
|
| 13 |
+
"""
|
| 14 |
+
Dispatcher for all file types.
|
| 15 |
+
Returns: [{"text": str, "metadata": {"source", "page", "section"}}]
|
| 16 |
+
"""
|
| 17 |
+
match file_type.lower():
|
| 18 |
+
case "pdf":
|
| 19 |
+
return await _parse_pdf(file_path, job_id, ws_manager, user_id)
|
| 20 |
+
case "jpg" | "jpeg" | "png":
|
| 21 |
+
return await _parse_image(file_path, job_id, ws_manager, user_id)
|
| 22 |
+
case "docx":
|
| 23 |
+
return _parse_docx(file_path)
|
| 24 |
+
case "txt":
|
| 25 |
+
return _parse_txt(file_path)
|
| 26 |
+
case "md":
|
| 27 |
+
return _parse_markdown(file_path)
|
| 28 |
+
case "json":
|
| 29 |
+
return _parse_json(file_path)
|
| 30 |
+
case _:
|
| 31 |
+
logger.warning(f"Unsupported file type: {file_type}")
|
| 32 |
+
return []
|
| 33 |
+
|
| 34 |
+
async def _parse_pdf(file_path: str, job_id: str, ws_manager: Any, user_id: str):
|
| 35 |
+
try:
|
| 36 |
+
await ws_manager.emit(job_id, user_id, {"step": "OCR_START", "color": "#8B5CF6", "detail": "Sending to Mistral OCR (Primary)..."})
|
| 37 |
+
results = await ocr_service.perform_ocr(file_path)
|
| 38 |
+
return [{"text": results['plain_text'], "metadata": {"source": Path(file_path).name, "page": 1}}]
|
| 39 |
+
except Exception as e:
|
| 40 |
+
logger.warning(f"Mistral OCR failed, falling back to PyMuPDF: {e}")
|
| 41 |
+
await ws_manager.emit(job_id, user_id, {"step": "FALLBACK", "color": "#F59E0B", "detail": "Mistral failed. Falling back to PyMuPDF..."})
|
| 42 |
+
import fitz # PyMuPDF
|
| 43 |
+
doc = fitz.open(file_path)
|
| 44 |
+
text = ""
|
| 45 |
+
for page in doc:
|
| 46 |
+
text += page.get_text()
|
| 47 |
+
return [{"text": text, "metadata": {"source": Path(file_path).name, "page": 1}}]
|
| 48 |
+
|
| 49 |
+
async def _parse_image(file_path: str, job_id: str, ws_manager: Any, user_id: str):
|
| 50 |
+
try:
|
| 51 |
+
await ws_manager.emit(job_id, user_id, {"step": "IMAGE_ANALYZE", "color": "#8B5CF6", "detail": "Qwen-VL analyzing image (Primary)..."})
|
| 52 |
+
description = vision_service.understand_image(file_path)
|
| 53 |
+
return [{"text": description, "metadata": {"source": Path(file_path).name, "page": 1}}]
|
| 54 |
+
except Exception as e:
|
| 55 |
+
logger.warning(f"Vision service failed, falling back to Tesseract: {e}")
|
| 56 |
+
await ws_manager.emit(job_id, user_id, {"step": "FALLBACK", "color": "#F59E0B", "detail": "Vision failed. Falling back to Tesseract OCR..."})
|
| 57 |
+
import pytesseract
|
| 58 |
+
from PIL import Image
|
| 59 |
+
text = pytesseract.image_to_string(Image.open(file_path))
|
| 60 |
+
return [{"text": text, "metadata": {"source": Path(file_path).name, "page": 1}}]
|
| 61 |
+
|
| 62 |
+
def _parse_docx(file_path: str):
|
| 63 |
+
doc = Document(file_path)
|
| 64 |
+
sections, current_heading, current_text = [], "General", []
|
| 65 |
+
for para in doc.paragraphs:
|
| 66 |
+
if para.style.name.startswith('Heading'):
|
| 67 |
+
if current_text:
|
| 68 |
+
sections.append({"text": "\n".join(current_text), "metadata": {"source": Path(file_path).name, "section": current_heading}})
|
| 69 |
+
current_heading, current_text = para.text, []
|
| 70 |
+
elif para.text.strip():
|
| 71 |
+
current_text.append(para.text)
|
| 72 |
+
if current_text:
|
| 73 |
+
sections.append({"text": "\n".join(current_text), "metadata": {"source": Path(file_path).name, "section": current_heading}})
|
| 74 |
+
return sections
|
| 75 |
+
|
| 76 |
+
def _parse_markdown(file_path: str):
|
| 77 |
+
text = Path(file_path).read_text(encoding="utf-8")
|
| 78 |
+
parts = re.split(r'\n(?=#+\s)', text)
|
| 79 |
+
docs = []
|
| 80 |
+
for p in parts:
|
| 81 |
+
if not p.strip(): continue
|
| 82 |
+
match = re.match(r'^#+\s+(.*)', p)
|
| 83 |
+
section = match.group(1) if match else "General"
|
| 84 |
+
docs.append({"text": p.strip(), "metadata": {"source": Path(file_path).name, "section": section}})
|
| 85 |
+
return docs
|
| 86 |
+
|
| 87 |
+
def _parse_txt(file_path: str):
|
| 88 |
+
text = Path(file_path).read_text(encoding="utf-8")
|
| 89 |
+
paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
|
| 90 |
+
return [{"text": p, "metadata": {"source": Path(file_path).name}} for p in paragraphs]
|
| 91 |
+
|
| 92 |
+
def _parse_json(file_path: str):
|
| 93 |
+
data = json.loads(Path(file_path).read_text())
|
| 94 |
+
docs = []
|
| 95 |
+
|
| 96 |
+
# If it's a list, treat each item as a doc
|
| 97 |
+
if isinstance(data, list):
|
| 98 |
+
items = data
|
| 99 |
+
# If it's a dict, treat each top-level key-value pair as a doc
|
| 100 |
+
elif isinstance(data, dict):
|
| 101 |
+
items = [{"key": k, "value": v} for k, v in data.items()]
|
| 102 |
+
else:
|
| 103 |
+
items = [data]
|
| 104 |
+
|
| 105 |
+
for item in items:
|
| 106 |
+
if isinstance(item, (dict, list)):
|
| 107 |
+
text = json.dumps(item, indent=2)
|
| 108 |
+
else:
|
| 109 |
+
text = str(item)
|
| 110 |
+
|
| 111 |
+
if text.strip():
|
| 112 |
+
docs.append({"text": text, "metadata": {"source": Path(file_path).name}})
|
| 113 |
+
|
| 114 |
+
return docs
|
RAG_FULL_APPLICATION_BACKEND/app/services/llm_service.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import threading
|
| 2 |
+
import time
|
| 3 |
+
import logging
|
| 4 |
+
from gradio_client import Client
|
| 5 |
+
from ..config import settings
|
| 6 |
+
from ..utils.json_utils import extract_json_block, repair_json
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
class Qwen3Service:
|
| 11 |
+
def __init__(self):
|
| 12 |
+
# LLM — GLM-4.5 (zai-org/GLM-4.5-Space)
|
| 13 |
+
self.model_name = "zai-org/GLM-4.5-Space"
|
| 14 |
+
self._client = None
|
| 15 |
+
|
| 16 |
+
@property
|
| 17 |
+
def client(self):
|
| 18 |
+
if not self._client:
|
| 19 |
+
self._client = Client(self.model_name)
|
| 20 |
+
return self._client
|
| 21 |
+
|
| 22 |
+
def _call(self, prompt: str, result_box: list, error_box: list):
|
| 23 |
+
try:
|
| 24 |
+
# zai-org/GLM-4.5-Space
|
| 25 |
+
# 1. Reset
|
| 26 |
+
try:
|
| 27 |
+
self.client.predict(api_name="/reset")
|
| 28 |
+
except:
|
| 29 |
+
pass
|
| 30 |
+
|
| 31 |
+
# 2. Predict with JSON instruction
|
| 32 |
+
sys_prompt = (
|
| 33 |
+
"You are a highly capable RAG assistant. "
|
| 34 |
+
"Provide accurate, concise, and fact-based responses. "
|
| 35 |
+
"ALWAYS wrap your response in a JSON block with the following keys:\n"
|
| 36 |
+
"{\n"
|
| 37 |
+
" \"thinking\": \"Your internal reasoning process\",\n"
|
| 38 |
+
" \"answer\": \"Your final formatted answer in markdown\"\n"
|
| 39 |
+
"}\n"
|
| 40 |
+
"Keep the 'thinking' brief and the 'answer' detailed."
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
result = self.client.predict(
|
| 44 |
+
msg=prompt,
|
| 45 |
+
sys_prompt=sys_prompt,
|
| 46 |
+
thinking_enabled=True,
|
| 47 |
+
temperature=0.1, # Low for RAG
|
| 48 |
+
api_name="/chat_wrapper_1"
|
| 49 |
+
)
|
| 50 |
+
result_box[0] = result
|
| 51 |
+
except Exception as e:
|
| 52 |
+
error_box[0] = e
|
| 53 |
+
|
| 54 |
+
def generate(self, prompt: str, retry_count: int = 0) -> str:
|
| 55 |
+
"""
|
| 56 |
+
Generate response from GLM-4.5 with retry logic and timeout.
|
| 57 |
+
Returns the 'answer' part of the JSON response.
|
| 58 |
+
"""
|
| 59 |
+
if retry_count >= settings.MAX_LLM_RETRIES:
|
| 60 |
+
raise RuntimeError("Max LLM retries exceeded")
|
| 61 |
+
|
| 62 |
+
rb, eb = [None], [None]
|
| 63 |
+
t = threading.Thread(target=self._call, args=(prompt, rb, eb), daemon=True)
|
| 64 |
+
t.start()
|
| 65 |
+
t.join(timeout=settings.LLM_RESPONSE_TIMEOUT)
|
| 66 |
+
|
| 67 |
+
if t.is_alive():
|
| 68 |
+
logger.warning(f"GLM-4.5 timeout. Attempt {retry_count + 1}")
|
| 69 |
+
return self.generate(prompt, retry_count + 1)
|
| 70 |
+
|
| 71 |
+
if eb[0]:
|
| 72 |
+
logger.error(f"GLM-4.5 error: {eb[0]}. Attempt {retry_count + 1}")
|
| 73 |
+
time.sleep(2)
|
| 74 |
+
return self.generate(prompt, retry_count + 1)
|
| 75 |
+
|
| 76 |
+
if rb[0] is None:
|
| 77 |
+
return self.generate(prompt, retry_count + 1)
|
| 78 |
+
|
| 79 |
+
# Parse GLM output and extract JSON
|
| 80 |
+
try:
|
| 81 |
+
res = rb[0]
|
| 82 |
+
raw_text = ""
|
| 83 |
+
if isinstance(res, (list, tuple)) and len(res) > 0:
|
| 84 |
+
turn = res[0]
|
| 85 |
+
if isinstance(turn, (list, tuple)) and len(turn) > 1:
|
| 86 |
+
content_dict = turn[1]
|
| 87 |
+
if isinstance(content_dict, dict) and 'content' in content_dict:
|
| 88 |
+
raw_text = content_dict['content']
|
| 89 |
+
|
| 90 |
+
if not raw_text:
|
| 91 |
+
raw_text = str(res)
|
| 92 |
+
|
| 93 |
+
# Extract JSON block
|
| 94 |
+
json_str = extract_json_block(raw_text)
|
| 95 |
+
data = repair_json(json_str)
|
| 96 |
+
|
| 97 |
+
if data and isinstance(data, dict) and 'answer' in data:
|
| 98 |
+
return data['answer'].strip()
|
| 99 |
+
|
| 100 |
+
# Fallback to raw text if JSON parsing fails but contains text
|
| 101 |
+
if raw_text:
|
| 102 |
+
return raw_text.strip()
|
| 103 |
+
|
| 104 |
+
return self.generate(prompt, retry_count + 1)
|
| 105 |
+
except Exception as e:
|
| 106 |
+
logger.error(f"Parse error for GLM-4.5: {e}")
|
| 107 |
+
return str(rb[0])
|
| 108 |
+
|
| 109 |
+
class MiniMaxService:
|
| 110 |
+
def __init__(self):
|
| 111 |
+
self.model_name = "MiniMaxAI/MiniMax-VL-01"
|
| 112 |
+
self._client = None
|
| 113 |
+
|
| 114 |
+
@property
|
| 115 |
+
def client(self):
|
| 116 |
+
if not self._client:
|
| 117 |
+
self._client = Client(self.model_name)
|
| 118 |
+
return self._client
|
| 119 |
+
|
| 120 |
+
def _call(self, prompt: str, result_box: list, error_box: list):
|
| 121 |
+
try:
|
| 122 |
+
# MiniMax-VL-01 implementation
|
| 123 |
+
result = self.client.predict(
|
| 124 |
+
message={"text": prompt, "files": []},
|
| 125 |
+
max_tokens=1000000,
|
| 126 |
+
temperature=0.1,
|
| 127 |
+
top_p=0.9,
|
| 128 |
+
api_name="/chat"
|
| 129 |
+
)
|
| 130 |
+
result_box[0] = result
|
| 131 |
+
except Exception as e:
|
| 132 |
+
error_box[0] = e
|
| 133 |
+
|
| 134 |
+
def generate(self, prompt: str, retry_count: int = 0) -> str:
|
| 135 |
+
if retry_count >= 3: # Fewer retries for fallback
|
| 136 |
+
raise RuntimeError("MiniMax fallback failed")
|
| 137 |
+
|
| 138 |
+
rb, eb = [None], [None]
|
| 139 |
+
t = threading.Thread(target=self._call, args=(prompt, rb, eb), daemon=True)
|
| 140 |
+
t.start()
|
| 141 |
+
t.join(timeout=settings.LLM_RESPONSE_TIMEOUT)
|
| 142 |
+
|
| 143 |
+
if t.is_alive() or eb[0] or rb[0] is None:
|
| 144 |
+
time.sleep(2)
|
| 145 |
+
return self.generate(prompt, retry_count + 1)
|
| 146 |
+
|
| 147 |
+
try:
|
| 148 |
+
raw_text = rb[0]
|
| 149 |
+
json_str = extract_json_block(raw_text)
|
| 150 |
+
data = repair_json(json_str)
|
| 151 |
+
if data and isinstance(data, dict) and 'answer' in data:
|
| 152 |
+
return data['answer'].strip()
|
| 153 |
+
return raw_text.strip()
|
| 154 |
+
except Exception as e:
|
| 155 |
+
logger.error(f"Parse error for MiniMax: {e}")
|
| 156 |
+
return str(rb[0])
|
| 157 |
+
|
| 158 |
+
class LLMServiceDispatcher:
|
| 159 |
+
def __init__(self):
|
| 160 |
+
self.primary = Qwen3Service()
|
| 161 |
+
self.fallback = MiniMaxService()
|
| 162 |
+
|
| 163 |
+
def generate(self, prompt: str) -> str:
|
| 164 |
+
try:
|
| 165 |
+
logger.info("Attempting generation with Primary (GLM-4.5)...")
|
| 166 |
+
return self.primary.generate(prompt)
|
| 167 |
+
except Exception as e:
|
| 168 |
+
logger.warning(f"Primary LLM failed: {e}. Falling back to MiniMax...")
|
| 169 |
+
try:
|
| 170 |
+
return self.fallback.generate(prompt)
|
| 171 |
+
except Exception as fe:
|
| 172 |
+
logger.error(f"Fallback LLM also failed: {fe}")
|
| 173 |
+
raise RuntimeError("All LLM services failed")
|
| 174 |
+
|
| 175 |
+
llm_service = LLMServiceDispatcher()
|
RAG_FULL_APPLICATION_BACKEND/app/services/ocr_service.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from gradio_client import Client, handle_file
|
| 3 |
+
from ..config import settings
|
| 4 |
+
|
| 5 |
+
logger = logging.getLogger(__name__)
|
| 6 |
+
|
| 7 |
+
class OCRService:
|
| 8 |
+
def __init__(self):
|
| 9 |
+
# The Mistral OCR space tatendachirume/Mistral-OCR
|
| 10 |
+
self.space_name = settings.MISTRAL_OCR_SPACE
|
| 11 |
+
self._client = None
|
| 12 |
+
|
| 13 |
+
@property
|
| 14 |
+
def client(self):
|
| 15 |
+
if not self._client:
|
| 16 |
+
self._client = Client(self.space_name)
|
| 17 |
+
return self._client
|
| 18 |
+
|
| 19 |
+
async def perform_ocr(self, file_path: str) -> dict:
|
| 20 |
+
"""
|
| 21 |
+
Send file to Mistral OCR HF Space.
|
| 22 |
+
Returns: {"plain_text": str, "markdown": str}
|
| 23 |
+
"""
|
| 24 |
+
try:
|
| 25 |
+
# Mistral OCR usually takes a file and returns OCR results
|
| 26 |
+
# Assuming standard api_name="/process" or similar
|
| 27 |
+
result = self.client.predict(
|
| 28 |
+
"Upload file", # input_type
|
| 29 |
+
"", # url (required but empty for upload)
|
| 30 |
+
handle_file(file_path), # file
|
| 31 |
+
"5gBKNRNZY2YllB6goe6OX0ycXdzbHS76", # api_key (default from view_api)
|
| 32 |
+
api_name="/do_ocr"
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
# Format: [text, gallery_list]
|
| 36 |
+
return {
|
| 37 |
+
"plain_text": result[0] if isinstance(result, (list, tuple)) else str(result),
|
| 38 |
+
"markdown_text": result[0] if isinstance(result, (list, tuple)) else str(result)
|
| 39 |
+
}
|
| 40 |
+
except Exception as e:
|
| 41 |
+
logger.error(f"Mistral OCR failed: {e}")
|
| 42 |
+
return {"plain_text": "", "markdown_text": ""}
|
| 43 |
+
|
| 44 |
+
ocr_service = OCRService()
|
RAG_FULL_APPLICATION_BACKEND/app/services/rerank_service.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sentence_transformers import CrossEncoder
|
| 2 |
+
from ..config import settings
|
| 3 |
+
from typing import List, Dict, Any
|
| 4 |
+
import logging
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger(__name__)
|
| 7 |
+
|
| 8 |
+
class ReRankService:
|
| 9 |
+
def __init__(self):
|
| 10 |
+
self._model = None
|
| 11 |
+
|
| 12 |
+
@property
|
| 13 |
+
def model(self):
|
| 14 |
+
if not self._model:
|
| 15 |
+
logger.info(f"Initializing CrossEncoder with {settings.RERANK_MODEL}...")
|
| 16 |
+
self._model = CrossEncoder(settings.RERANK_MODEL)
|
| 17 |
+
return self._model
|
| 18 |
+
|
| 19 |
+
def rerank(self, query: str, candidates: List[Dict[str, Any]], top_k: int) -> List[Dict[str, Any]]:
|
| 20 |
+
"""
|
| 21 |
+
Re-score candidates using cross-encoder.
|
| 22 |
+
"""
|
| 23 |
+
if not candidates:
|
| 24 |
+
return []
|
| 25 |
+
|
| 26 |
+
# Prepare pairs for cross-encoder
|
| 27 |
+
pairs = [[query, c["text"]] for c in candidates]
|
| 28 |
+
|
| 29 |
+
# Predict scores
|
| 30 |
+
scores = self.model.predict(pairs)
|
| 31 |
+
|
| 32 |
+
# Attach scores and sort
|
| 33 |
+
for i, score in enumerate(scores):
|
| 34 |
+
candidates[i]["rerank_score"] = float(score)
|
| 35 |
+
|
| 36 |
+
candidates.sort(key=lambda x: x["rerank_score"], reverse=True)
|
| 37 |
+
|
| 38 |
+
return candidates[:top_k]
|
| 39 |
+
|
| 40 |
+
rerank_service = ReRankService()
|
RAG_FULL_APPLICATION_BACKEND/app/services/supabase_client.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from supabase import create_client, Client
|
| 2 |
+
from ..config import settings
|
| 3 |
+
from typing import List, Dict, Optional, Any
|
| 4 |
+
import logging
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger(__name__)
|
| 7 |
+
|
| 8 |
+
class SupabaseService:
|
| 9 |
+
def __init__(self):
|
| 10 |
+
self.client: Client = create_client(
|
| 11 |
+
settings.SUPABASE_URL, settings.SUPABASE_KEY
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
# ── Chunk Operations ────────────────────────────────────────────────
|
| 15 |
+
async def insert_chunks(self, chunks: List[Dict[str, Any]]) -> List[str]:
|
| 16 |
+
"""Insert chunks, return list of chunk_ids"""
|
| 17 |
+
if not chunks:
|
| 18 |
+
return []
|
| 19 |
+
|
| 20 |
+
# Define valid columns based on schema
|
| 21 |
+
valid_cols = {"id", "document_id", "user_id", "text", "token_count", "page", "section", "chunk_index", "parent_chunk_id", "text_hash", "metadata"}
|
| 22 |
+
|
| 23 |
+
cleaned_chunks = []
|
| 24 |
+
for chunk in chunks:
|
| 25 |
+
cleaned = {k: v for k, v in chunk.items() if k in valid_cols}
|
| 26 |
+
cleaned_chunks.append(cleaned)
|
| 27 |
+
|
| 28 |
+
try:
|
| 29 |
+
result = self.client.table("chunks").insert(cleaned_chunks).execute()
|
| 30 |
+
return [row["id"] for row in result.data]
|
| 31 |
+
except Exception as e:
|
| 32 |
+
logger.error(f"Supabase chunk insertion failed: {e}")
|
| 33 |
+
raise
|
| 34 |
+
|
| 35 |
+
async def get_chunks_by_ids(self, chunk_ids: List[str]) -> List[Dict[str, Any]]:
|
| 36 |
+
"""Fetch chunk text + metadata by IDs"""
|
| 37 |
+
result = self.client.table("chunks").select("*").in_("id", chunk_ids).execute()
|
| 38 |
+
return result.data
|
| 39 |
+
|
| 40 |
+
async def get_chunk_hashes(self, document_id: str) -> Dict[int, str]:
|
| 41 |
+
"""Returns {chunk_index: text_hash} for incremental ingest"""
|
| 42 |
+
result = self.client.table("chunks")\
|
| 43 |
+
.select("chunk_index, text_hash")\
|
| 44 |
+
.eq("document_id", document_id).execute()
|
| 45 |
+
return {row["chunk_index"]: row["text_hash"] for row in result.data}
|
| 46 |
+
|
| 47 |
+
async def delete_chunks(self, chunk_ids: List[str]):
|
| 48 |
+
"""Delete chunks + their vectors (CASCADE)"""
|
| 49 |
+
if chunk_ids:
|
| 50 |
+
self.client.table("chunks").delete().in_("id", chunk_ids).execute()
|
| 51 |
+
|
| 52 |
+
# ── Vector Operations ───────────────────────────────────────────────
|
| 53 |
+
async def upsert_vectors(self, vectors: List[Dict[str, Any]]):
|
| 54 |
+
if not vectors:
|
| 55 |
+
return
|
| 56 |
+
self.client.table("chunk_vectors").insert(vectors).execute()
|
| 57 |
+
|
| 58 |
+
async def vector_search(self, query_embedding: List[float],
|
| 59 |
+
document_id: str, user_id: str,
|
| 60 |
+
top_k: int, filter_chunk_ids: List[str] = None
|
| 61 |
+
) -> List[Dict[str, Any]]:
|
| 62 |
+
"""
|
| 63 |
+
Calls match_chunks() SQL function.
|
| 64 |
+
Returns: [{chunk_id, text, source, page, section, metadata, similarity}]
|
| 65 |
+
"""
|
| 66 |
+
params = {
|
| 67 |
+
"query_embedding": query_embedding,
|
| 68 |
+
"match_document_id": document_id,
|
| 69 |
+
"match_user_id": user_id,
|
| 70 |
+
"match_count": top_k,
|
| 71 |
+
"filter_chunk_ids": filter_chunk_ids
|
| 72 |
+
}
|
| 73 |
+
result = self.client.rpc("match_chunks", params).execute()
|
| 74 |
+
return result.data
|
| 75 |
+
|
| 76 |
+
# ── Metadata Filter ─────────────────────────────────────────────────
|
| 77 |
+
async def filter_chunk_ids(self, document_id: str, user_id: str, filters: Dict[str, Any]) -> List[str]:
|
| 78 |
+
"""
|
| 79 |
+
Filter chunks by metadata fields using Supabase filter logic.
|
| 80 |
+
Simplified example: filters is a dict of exact matches.
|
| 81 |
+
"""
|
| 82 |
+
query = self.client.table("chunks").select("id").eq("document_id", document_id).eq("user_id", user_id)
|
| 83 |
+
|
| 84 |
+
for key, value in filters.items():
|
| 85 |
+
if isinstance(value, dict):
|
| 86 |
+
# Handle gte, lte, etc.
|
| 87 |
+
if "gte" in value: query = query.gte(f"metadata->>{key}", value["gte"])
|
| 88 |
+
if "lte" in value: query = query.lte(f"metadata->>{key}", value["lte"])
|
| 89 |
+
else:
|
| 90 |
+
query = query.eq(f"metadata->>{key}", value)
|
| 91 |
+
|
| 92 |
+
result = query.execute()
|
| 93 |
+
return [row["id"] for row in result.data]
|
| 94 |
+
|
| 95 |
+
# ── ColBERT Token Vectors ───────────────────────────────────────────
|
| 96 |
+
async def insert_colbert_tokens(self, token_rows: List[Dict[str, Any]]):
|
| 97 |
+
if not token_rows:
|
| 98 |
+
return
|
| 99 |
+
self.client.table("colbert_tokens").insert(token_rows).execute()
|
| 100 |
+
|
| 101 |
+
async def get_colbert_tokens(self, document_id: str) -> List[Dict[str, Any]]:
|
| 102 |
+
"""Fetch all token vectors for MaxSim scoring"""
|
| 103 |
+
result = self.client.table("colbert_tokens")\
|
| 104 |
+
.select("chunk_id, embedding")\
|
| 105 |
+
.eq("document_id", document_id).execute()
|
| 106 |
+
return result.data
|
| 107 |
+
|
| 108 |
+
async def delete_document(self, document_id: str, user_id: str):
|
| 109 |
+
"""Delete document + chunks + vectors (CASCADE)"""
|
| 110 |
+
try:
|
| 111 |
+
# 1. Chunks (will cascade to vectors)
|
| 112 |
+
self.client.table("chunks").delete().eq("document_id", document_id).execute()
|
| 113 |
+
# 2. Document
|
| 114 |
+
self.client.table("documents").delete().eq("id", document_id).eq("user_id", user_id).execute()
|
| 115 |
+
except Exception as e:
|
| 116 |
+
logger.error(f"Failed to delete document {document_id}: {e}")
|
| 117 |
+
raise
|
| 118 |
+
|
| 119 |
+
supabase_service = SupabaseService()
|
RAG_FULL_APPLICATION_BACKEND/app/services/vision_service.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from gradio_client import Client, handle_file
|
| 3 |
+
from ..config import settings
|
| 4 |
+
|
| 5 |
+
logger = logging.getLogger(__name__)
|
| 6 |
+
|
| 7 |
+
class VisionService:
|
| 8 |
+
def __init__(self):
|
| 9 |
+
# Changed to Qwen3-VL-30B-A3B-Demo per user request
|
| 10 |
+
self.space_url = settings.VISION_SPACE_URL
|
| 11 |
+
self._client = None
|
| 12 |
+
|
| 13 |
+
@property
|
| 14 |
+
def client(self):
|
| 15 |
+
if not self._client:
|
| 16 |
+
self._client = Client(self.space_url)
|
| 17 |
+
return self._client
|
| 18 |
+
|
| 19 |
+
def understand_image(self, image_path: str) -> str:
|
| 20 |
+
"""
|
| 21 |
+
Send image to Qwen-VL HF Space for description.
|
| 22 |
+
"""
|
| 23 |
+
try:
|
| 24 |
+
# Check if file exists and is not empty to avoid crash
|
| 25 |
+
import os
|
| 26 |
+
if not os.path.exists(image_path) or os.path.getsize(image_path) == 0:
|
| 27 |
+
return ""
|
| 28 |
+
|
| 29 |
+
self.client.predict(api_name="/clear_conversation_history")
|
| 30 |
+
file_arg = [handle_file(image_path)]
|
| 31 |
+
prompt = "Please describe the contents of this image in detail."
|
| 32 |
+
|
| 33 |
+
result = self.client.predict(
|
| 34 |
+
input_value={"files": file_arg, "text": prompt},
|
| 35 |
+
api_name="/add_message"
|
| 36 |
+
)
|
| 37 |
+
response_text = result[1]['value'][1]['content'][0]['content']
|
| 38 |
+
return str(response_text)
|
| 39 |
+
except Exception as e:
|
| 40 |
+
logger.error(f"Image understanding failed: {e}")
|
| 41 |
+
return "Failed to understand image."
|
| 42 |
+
|
| 43 |
+
vision_service = VisionService()
|
RAG_FULL_APPLICATION_BACKEND/app/techniques/__init__.py
ADDED
|
File without changes
|
RAG_FULL_APPLICATION_BACKEND/app/techniques/agentic_rag.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .base import BaseRAGTechnique
|
| 2 |
+
from ..services.embed_service import get_embedding
|
| 3 |
+
from ..utils.json_utils import extract_json_block, repair_json
|
| 4 |
+
from typing import List, Dict, Any
|
| 5 |
+
import json
|
| 6 |
+
|
| 7 |
+
class AgenticRAG(BaseRAGTechnique):
|
| 8 |
+
async def retrieve(self, query: str, document_id: str, top_k: int, **kwargs) -> List[Dict[str, Any]]:
|
| 9 |
+
# This is the "agent loop"
|
| 10 |
+
await self.emit("AGENT_INIT", "#22C55E", "Qwen3 agent ready with 4 tools")
|
| 11 |
+
|
| 12 |
+
all_collected_chunks = []
|
| 13 |
+
conversation_history = []
|
| 14 |
+
|
| 15 |
+
system_prompt = f"""
|
| 16 |
+
You are an intelligent RAG agent. You have access to a document (ID: {document_id}).
|
| 17 |
+
Your goal is to answer the user query: "{query}"
|
| 18 |
+
|
| 19 |
+
Available tools:
|
| 20 |
+
1. search_docs(query: str, top_k: int) -> list of chunks
|
| 21 |
+
2. filter_search(filters: dict, query: str) -> list of chunks. filters can include "page" or "section".
|
| 22 |
+
3. get_page(page_num: int) -> text of that page
|
| 23 |
+
4. finish(answer: str) -> finish with final answer
|
| 24 |
+
|
| 25 |
+
Respond ONLY with a JSON object:
|
| 26 |
+
{{
|
| 27 |
+
"thought": "your reasoning",
|
| 28 |
+
"tool": "tool_name",
|
| 29 |
+
"args": {{ ... }}
|
| 30 |
+
}}
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
for i in range(5): # Max 5 iterations
|
| 34 |
+
await self.emit("PLAN", "#8B5CF6", f"Agent iteration {i+1}: Thinking...")
|
| 35 |
+
|
| 36 |
+
agent_prompt = f"{system_prompt}\n\nHistory: {json.dumps(conversation_history)}\n\nAction:"
|
| 37 |
+
response_text = self.llm.generate(agent_prompt)
|
| 38 |
+
|
| 39 |
+
try:
|
| 40 |
+
action_data = repair_json(extract_json_block(response_text))
|
| 41 |
+
thought = action_data.get("thought", "")
|
| 42 |
+
tool = action_data.get("tool", "")
|
| 43 |
+
args = action_data.get("args", {})
|
| 44 |
+
|
| 45 |
+
await self.emit("PLAN", "#8B5CF6", f"Thought: {thought[:100]}...")
|
| 46 |
+
|
| 47 |
+
if tool == "finish":
|
| 48 |
+
self.final_agent_answer = args.get("answer", "")
|
| 49 |
+
break
|
| 50 |
+
|
| 51 |
+
# Execute Tool
|
| 52 |
+
await self.emit("TOOL", "#D97706", f"Tool call: {tool}({json.dumps(args)})")
|
| 53 |
+
|
| 54 |
+
observation = ""
|
| 55 |
+
if tool == "search_docs":
|
| 56 |
+
q = args.get("query", query)
|
| 57 |
+
tk = args.get("top_k", top_k)
|
| 58 |
+
q_vec = get_embedding(q)
|
| 59 |
+
results = await self.supabase.vector_search(q_vec, document_id, self.user_id, tk)
|
| 60 |
+
all_collected_chunks.extend(results)
|
| 61 |
+
observation = f"Found {len(results)} chunks."
|
| 62 |
+
elif tool == "filter_search":
|
| 63 |
+
f = args.get("filters", {})
|
| 64 |
+
q = args.get("query", query)
|
| 65 |
+
matching_ids = await self.supabase.filter_chunk_ids(document_id, self.user_id, f)
|
| 66 |
+
if matching_ids:
|
| 67 |
+
q_vec = get_embedding(q)
|
| 68 |
+
results = await self.supabase.vector_search(q_vec, document_id, self.user_id, top_k, filter_chunk_ids=matching_ids)
|
| 69 |
+
all_collected_chunks.extend(results)
|
| 70 |
+
observation = f"Filtered search found {len(results)} chunks."
|
| 71 |
+
else:
|
| 72 |
+
observation = "No chunks matched the filters."
|
| 73 |
+
elif tool == "get_page":
|
| 74 |
+
p = args.get("page_num")
|
| 75 |
+
results = await self.supabase.filter_chunk_ids(document_id, self.user_id, {"page": p})
|
| 76 |
+
if results:
|
| 77 |
+
chunks = await self.supabase.get_chunks_by_ids(results)
|
| 78 |
+
all_collected_chunks.extend(chunks)
|
| 79 |
+
observation = f"Retrieved page {p}."
|
| 80 |
+
else:
|
| 81 |
+
observation = f"Page {p} not found."
|
| 82 |
+
|
| 83 |
+
await self.emit("OBSERVE", "#22C55E", observation)
|
| 84 |
+
conversation_history.append({"action": action_data, "observation": observation})
|
| 85 |
+
|
| 86 |
+
except Exception as e:
|
| 87 |
+
logger.error(f"Agent error decoding JSON: {e}")
|
| 88 |
+
conversation_history.append({"error": f"Invalid JSON response from your side. Use the required JSON format. error: {str(e)}"})
|
| 89 |
+
|
| 90 |
+
await self.emit("FINAL", "#22C55E", "Answer generated after tool usage.")
|
| 91 |
+
return all_collected_chunks
|
| 92 |
+
|
| 93 |
+
async def generate(self, query: str, chunks: List[Dict[str, Any]]) -> str:
|
| 94 |
+
# If the agent finished with a final answer, use it.
|
| 95 |
+
if hasattr(self, "final_agent_answer") and self.final_agent_answer:
|
| 96 |
+
return self.final_agent_answer
|
| 97 |
+
|
| 98 |
+
await self.emit("GENERATE", "#7C3AED", "Qwen3 generating final summary...")
|
| 99 |
+
context = "\n\n".join([c["text"] for c in chunks])
|
| 100 |
+
prompt = f"Context:\n{context}\n\nQuestion: {query}\n\nAnswer based ONLY on the context:"
|
| 101 |
+
return self.llm.generate(prompt)
|
RAG_FULL_APPLICATION_BACKEND/app/techniques/base.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from abc import ABC, abstractmethod
|
| 2 |
+
from typing import List, Dict, Any, Optional
|
| 3 |
+
from ..services.supabase_client import supabase_service
|
| 4 |
+
from ..services.embed_service import get_embedding, truncate_to_1k
|
| 5 |
+
from ..services.llm_service import llm_service
|
| 6 |
+
from ..utils.ws_manager import ws_manager
|
| 7 |
+
import logging
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
class BaseRAGTechnique(ABC):
|
| 12 |
+
def __init__(self, job_id: str, user_id: str):
|
| 13 |
+
self.job_id = job_id
|
| 14 |
+
self.user_id = user_id
|
| 15 |
+
self.supabase = supabase_service
|
| 16 |
+
self.llm = llm_service
|
| 17 |
+
|
| 18 |
+
@abstractmethod
|
| 19 |
+
async def retrieve(self, query: str, document_id: str, top_k: int, **kwargs) -> List[Dict[str, Any]]:
|
| 20 |
+
pass
|
| 21 |
+
|
| 22 |
+
@abstractmethod
|
| 23 |
+
async def generate(self, query: str, chunks: List[Dict[str, Any]]) -> str:
|
| 24 |
+
pass
|
| 25 |
+
|
| 26 |
+
async def run(self, query: str, document_id: str, top_k: int = 5, **kwargs) -> Dict[str, Any]:
|
| 27 |
+
"""Execute the full RAG pipeline."""
|
| 28 |
+
try:
|
| 29 |
+
# 1. Retrieval
|
| 30 |
+
chunks = await self.retrieve(query, document_id, top_k, **kwargs)
|
| 31 |
+
|
| 32 |
+
# 2. Generation
|
| 33 |
+
answer = await self.generate(query, chunks)
|
| 34 |
+
|
| 35 |
+
return {
|
| 36 |
+
"answer": answer,
|
| 37 |
+
"sources": chunks,
|
| 38 |
+
"job_id": self.job_id
|
| 39 |
+
}
|
| 40 |
+
except Exception as e:
|
| 41 |
+
logger.error(f"RAG execution failed: {e}")
|
| 42 |
+
await self.emit("ERROR", "red", f"Critical error: {str(e)}")
|
| 43 |
+
raise
|
| 44 |
+
|
| 45 |
+
async def emit(self, step: str, color: str, detail: str, metadata: dict = {}):
|
| 46 |
+
"""Broadcast progress to frontend."""
|
| 47 |
+
await ws_manager.emit(self.job_id, self.user_id, {
|
| 48 |
+
"step": step,
|
| 49 |
+
"status": "running",
|
| 50 |
+
"color": color,
|
| 51 |
+
"detail": detail,
|
| 52 |
+
"metadata": metadata
|
| 53 |
+
})
|
RAG_FULL_APPLICATION_BACKEND/app/techniques/cache_incremental.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .base import BaseRAGTechnique
|
| 2 |
+
from ..services.cache_service import cache_service
|
| 3 |
+
from .hybrid_search import HybridSearch
|
| 4 |
+
from typing import List, Dict, Any
|
| 5 |
+
|
| 6 |
+
class CacheIncrementalRAG(BaseRAGTechnique):
|
| 7 |
+
async def run(self, query: str, document_id: str, top_k: int = 5, **kwargs) -> Dict[str, Any]:
|
| 8 |
+
technique_name = kwargs.get("underlying_technique", "hybrid")
|
| 9 |
+
|
| 10 |
+
# 1. Cache Check
|
| 11 |
+
await self.emit("CACHE_CHECK", "#6B7280", "Checking Redis cache for previous answer...")
|
| 12 |
+
|
| 13 |
+
cached_result = cache_service.get(self.user_id, document_id, query, technique_name)
|
| 14 |
+
if cached_result:
|
| 15 |
+
await self.emit("CACHE_HIT", "#22C55E", "Cache hit! Returning stored answer (0ms).")
|
| 16 |
+
return cached_result
|
| 17 |
+
|
| 18 |
+
await self.emit("CACHE_MISS", "#8B5CF6", "Cache miss. Running full RAG pipeline...")
|
| 19 |
+
|
| 20 |
+
# 2. Run Underlying Technique (e.g., Hybrid)
|
| 21 |
+
# For simplicity, we use Hybrid as the default fallback
|
| 22 |
+
underlying = HybridSearch(self.job_id, self.user_id)
|
| 23 |
+
result = await underlying.run(query, document_id, top_k)
|
| 24 |
+
|
| 25 |
+
# 3. Store in Cache
|
| 26 |
+
cache_service.set(self.user_id, document_id, query, technique_name, result)
|
| 27 |
+
|
| 28 |
+
await self.emit("DONE", "#22C55E", "Answer cached for future queries.")
|
| 29 |
+
return result
|
| 30 |
+
|
| 31 |
+
async def retrieve(self, query: str, document_id: str, top_k: int, **kwargs) -> List[Dict[str, Any]]:
|
| 32 |
+
# Not used directly in Run override
|
| 33 |
+
pass
|
| 34 |
+
|
| 35 |
+
async def generate(self, query: str, chunks: List[Dict[str, Any]]) -> str:
|
| 36 |
+
# Not used directly in Run override
|
| 37 |
+
pass
|
RAG_FULL_APPLICATION_BACKEND/app/techniques/colbert.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .base import BaseRAGTechnique
|
| 2 |
+
from ..services.embed_service import get_embedding
|
| 3 |
+
import numpy as np
|
| 4 |
+
from typing import List, Dict, Any
|
| 5 |
+
import tiktoken
|
| 6 |
+
|
| 7 |
+
class ColBERT(BaseRAGTechnique):
|
| 8 |
+
def __init__(self, job_id: str, user_id: str):
|
| 9 |
+
super().__init__(job_id, user_id)
|
| 10 |
+
self.enc = tiktoken.get_encoding("cl100k_base")
|
| 11 |
+
|
| 12 |
+
async def retrieve(self, query: str, document_id: str, top_k: int, **kwargs) -> List[Dict[str, Any]]:
|
| 13 |
+
# 1. Tokenize
|
| 14 |
+
await self.emit("TOKENIZE", "#7C3AED", "Tokenizing query into tokens...")
|
| 15 |
+
tokens = self.enc.encode(query)
|
| 16 |
+
token_texts = [self.enc.decode([t]) for t in tokens]
|
| 17 |
+
|
| 18 |
+
# 2. Embed tokens
|
| 19 |
+
await self.emit("EMBED_TOK", "#8B5CF6", f"Embedding {len(token_texts)} query tokens (bge-m3)...")
|
| 20 |
+
query_embeddings = []
|
| 21 |
+
for t in token_texts:
|
| 22 |
+
query_embeddings.append(get_embedding(t))
|
| 23 |
+
|
| 24 |
+
# 3. Fetch all chunk token vectors for the document
|
| 25 |
+
# Warning: This can be large!
|
| 26 |
+
await self.emit("MAXSIM", "#EF4444", "Fetching token vectors and computing MaxSim scoring...")
|
| 27 |
+
token_rows = await self.supabase.get_colbert_tokens(document_id)
|
| 28 |
+
|
| 29 |
+
if not token_rows:
|
| 30 |
+
await self.emit("DONE", "#EF4444", "No ColBERT tokens found for document.")
|
| 31 |
+
return []
|
| 32 |
+
|
| 33 |
+
# Group tokens by chunk_id
|
| 34 |
+
chunk_token_map = {}
|
| 35 |
+
for row in token_rows:
|
| 36 |
+
c_id = row["chunk_id"]
|
| 37 |
+
if c_id not in chunk_token_map: chunk_token_map[c_id] = []
|
| 38 |
+
chunk_token_map[c_id].append(row["embedding"])
|
| 39 |
+
|
| 40 |
+
# 4. MaxSim Calculation
|
| 41 |
+
# MaxSim(q,d) = Σ max_j(q_i · d_j)
|
| 42 |
+
chunk_scores = []
|
| 43 |
+
for chunk_id, d_embeddings in chunk_token_map.items():
|
| 44 |
+
score = 0
|
| 45 |
+
d_matrix = np.array(d_embeddings) # (n_d, dim)
|
| 46 |
+
q_matrix = np.array(query_embeddings) # (n_q, dim)
|
| 47 |
+
|
| 48 |
+
# dot product: (n_q, n_d)
|
| 49 |
+
similarities = np.dot(q_matrix, d_matrix.T)
|
| 50 |
+
|
| 51 |
+
# max over document tokens (axis 1)
|
| 52 |
+
max_sims = np.max(similarities, axis=1)
|
| 53 |
+
|
| 54 |
+
# sum over query tokens
|
| 55 |
+
score = np.sum(max_sims)
|
| 56 |
+
chunk_scores.append({"chunk_id": chunk_id, "colbert_score": float(score)})
|
| 57 |
+
|
| 58 |
+
# 5. Rank and return
|
| 59 |
+
chunk_scores.sort(key=lambda x: x["colbert_score"], reverse=True)
|
| 60 |
+
top_ids = [s["chunk_id"] for s in chunk_scores[:top_k]]
|
| 61 |
+
|
| 62 |
+
# Fetch chunk details
|
| 63 |
+
chunks = await self.supabase.get_chunks_by_ids(top_ids)
|
| 64 |
+
|
| 65 |
+
# Ensure order matches top_ids
|
| 66 |
+
id_to_chunk = { (c.get("id") or c.get("chunk_id")): c for c in chunks }
|
| 67 |
+
results = [id_to_chunk[cid] for cid in top_ids if cid in id_to_chunk]
|
| 68 |
+
|
| 69 |
+
await self.emit("DONE", "#22C55E", f"ColBERT scoring complete. top-{top_k} returned.")
|
| 70 |
+
return results
|
| 71 |
+
|
| 72 |
+
async def generate(self, query: str, chunks: List[Dict[str, Any]]) -> str:
|
| 73 |
+
await self.emit("GENERATE", "#7C3AED", "Qwen3 generating answer...")
|
| 74 |
+
context = "\n\n".join([c["text"] for c in chunks])
|
| 75 |
+
prompt = f"Context:\n{context}\n\nQuestion: {query}\n\nAnswer based ONLY on the context:"
|
| 76 |
+
return self.llm.generate(prompt)
|
RAG_FULL_APPLICATION_BACKEND/app/techniques/hybrid_search.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .base import BaseRAGTechnique
|
| 2 |
+
from ..services.bm25_service import bm25_service
|
| 3 |
+
from ..services.embed_service import get_embedding
|
| 4 |
+
from ..utils.rank_utils import reciprocal_rank_fusion
|
| 5 |
+
from typing import List, Dict, Any
|
| 6 |
+
|
| 7 |
+
class HybridSearch(BaseRAGTechnique):
|
| 8 |
+
async def retrieve(self, query: str, document_id: str, top_k: int, **kwargs) -> List[Dict[str, Any]]:
|
| 9 |
+
# 1. Embed query
|
| 10 |
+
await self.emit("EMBED", "#8B5CF6", "Embedding query (bge-m3)...")
|
| 11 |
+
q_vec = get_embedding(query)
|
| 12 |
+
|
| 13 |
+
# 2. BM25 Search
|
| 14 |
+
await self.emit("BM25", "#22C55E", "BM25 keyword search...")
|
| 15 |
+
bm25_results = bm25_service.search(document_id, query, top_n=top_k * 4)
|
| 16 |
+
|
| 17 |
+
# 3. Vector Search
|
| 18 |
+
await self.emit("VECTOR", "#16A34A", "pgvector ANN search...")
|
| 19 |
+
vector_results = await self.supabase.vector_search(q_vec, document_id, self.user_id, top_k * 4)
|
| 20 |
+
|
| 21 |
+
# 4. Fusion
|
| 22 |
+
await self.emit("RRF", "#8B5CF6", "Reciprocal Rank Fusion merging results...")
|
| 23 |
+
fused = reciprocal_rank_fusion(bm25_results, vector_results, k=60)
|
| 24 |
+
|
| 25 |
+
await self.emit("DONE", "#22C55E", f"Hybrid search complete. top-{top_k} returned.")
|
| 26 |
+
return fused[:top_k]
|
| 27 |
+
|
| 28 |
+
async def generate(self, query: str, chunks: List[Dict[str, Any]]) -> str:
|
| 29 |
+
await self.emit("GENERATE", "#7C3AED", "Qwen3 generating answer...")
|
| 30 |
+
context = "\n\n".join([c["text"] for c in chunks])
|
| 31 |
+
prompt = f"Context:\n{context}\n\nQuestion: {query}\n\nAnswer based ONLY on the context:"
|
| 32 |
+
return self.llm.generate(prompt)
|
RAG_FULL_APPLICATION_BACKEND/app/techniques/metadata_filter.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .base import BaseRAGTechnique
|
| 2 |
+
from ..services.embed_service import get_embedding
|
| 3 |
+
from typing import List, Dict, Any
|
| 4 |
+
|
| 5 |
+
class MetadataFilter(BaseRAGTechnique):
|
| 6 |
+
async def retrieve(self, query: str, document_id: str, top_k: int, **kwargs) -> List[Dict[str, Any]]:
|
| 7 |
+
filters = kwargs.get("filters", {})
|
| 8 |
+
|
| 9 |
+
# 1. SQL Pre-filtering
|
| 10 |
+
await self.emit("FILTER", "#D97706", f"SQL filter: {filters}...")
|
| 11 |
+
matching_ids = await self.supabase.filter_chunk_ids(document_id, self.user_id, filters)
|
| 12 |
+
|
| 13 |
+
if not matching_ids:
|
| 14 |
+
await self.emit("DONE", "#EF4444", "No chunks matched filters.")
|
| 15 |
+
return []
|
| 16 |
+
|
| 17 |
+
await self.emit("FILTER", "#D97706", f"Found {len(matching_ids)} qualifying chunks.")
|
| 18 |
+
|
| 19 |
+
# 2. Embed query
|
| 20 |
+
await self.emit("EMBED", "#8B5CF6", "Embedding query...")
|
| 21 |
+
q_vec = get_embedding(query)
|
| 22 |
+
|
| 23 |
+
# 3. Vector Search (Filtered)
|
| 24 |
+
await self.emit("SEARCH", "#16A34A", "pgvector search in filtered subset...")
|
| 25 |
+
results = await self.supabase.vector_search(q_vec, document_id, self.user_id, top_k, filter_chunk_ids=matching_ids)
|
| 26 |
+
|
| 27 |
+
await self.emit("DONE", "#22C55E", f"Metadata-filtered search complete. top-{top_k} returned.")
|
| 28 |
+
return results
|
| 29 |
+
|
| 30 |
+
async def generate(self, query: str, chunks: List[Dict[str, Any]]) -> str:
|
| 31 |
+
await self.emit("GENERATE", "#7C3AED", "Qwen3 generating answer...")
|
| 32 |
+
context = "\n\n".join([c["text"] for c in chunks])
|
| 33 |
+
prompt = f"Context:\n{context}\n\nQuestion: {query}\n\nAnswer based ONLY on the context:"
|
| 34 |
+
return self.llm.generate(prompt)
|
RAG_FULL_APPLICATION_BACKEND/app/techniques/query_expansion.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .base import BaseRAGTechnique
|
| 2 |
+
from ..services.embed_service import get_embedding
|
| 3 |
+
import asyncio
|
| 4 |
+
from typing import List, Dict, Any
|
| 5 |
+
|
| 6 |
+
class QueryExpansion(BaseRAGTechnique):
|
| 7 |
+
async def retrieve(self, query: str, document_id: str, top_k: int, **kwargs) -> List[Dict[str, Any]]:
|
| 8 |
+
# 1. HyDE - Hypothetical Answer
|
| 9 |
+
await self.emit("HYDE", "#8B5CF6", "Qwen3 generating hypothetical answer (HyDE)...")
|
| 10 |
+
hyde_prompt = f"Provide a brief hypothetical answer to the following question. Question: {query}\n\nAnswer:"
|
| 11 |
+
hypothetical_answer = self.llm.generate(hyde_prompt)
|
| 12 |
+
|
| 13 |
+
# 2. Multi-Query Expansion
|
| 14 |
+
await self.emit("EXPAND", "#7C3AED", "Generating 3 query variants...")
|
| 15 |
+
expand_prompt = f"Generate 3 different search queries to find information for: {query}. Respond ONLY with the queries, one per line."
|
| 16 |
+
expansion_text = self.llm.generate(expand_prompt)
|
| 17 |
+
expanded_queries = [q.strip() for q in expansion_text.split("\n") if q.strip()][:3]
|
| 18 |
+
|
| 19 |
+
all_queries = [query, hypothetical_answer] + expanded_queries
|
| 20 |
+
|
| 21 |
+
# 3. Embedding multiple queries
|
| 22 |
+
await self.emit("EMBED", "#8B5CF6", f"Embedding {len(all_queries)} expanded queries...")
|
| 23 |
+
# Sequential for safety with HF Space limits
|
| 24 |
+
vectors = []
|
| 25 |
+
for q in all_queries:
|
| 26 |
+
vectors.append(get_embedding(q))
|
| 27 |
+
|
| 28 |
+
# 4. Search and Merge
|
| 29 |
+
await self.emit("SEARCH", "#16A34A", "pgvector search with all variants...")
|
| 30 |
+
all_results = []
|
| 31 |
+
for vec in vectors:
|
| 32 |
+
results = await self.supabase.vector_search(vec, document_id, self.user_id, top_k)
|
| 33 |
+
all_results.extend(results)
|
| 34 |
+
|
| 35 |
+
# Deduplicate by chunk_id
|
| 36 |
+
await self.emit("MERGE", "#8B5CF6", f"Deduplicating {len(all_results)} results...")
|
| 37 |
+
seen = set()
|
| 38 |
+
deduped = []
|
| 39 |
+
for r in all_results:
|
| 40 |
+
c_id = r.get("id") or r.get("chunk_id")
|
| 41 |
+
if c_id not in seen:
|
| 42 |
+
deduped.append(r)
|
| 43 |
+
seen.add(c_id)
|
| 44 |
+
|
| 45 |
+
# Re-sort by similarity (approximate)
|
| 46 |
+
deduped.sort(key=lambda x: x.get("similarity", 0), reverse=True)
|
| 47 |
+
|
| 48 |
+
await self.emit("DONE", "#22C55E", f"Query expansion complete. top-{top_k} returned.")
|
| 49 |
+
return deduped[:top_k]
|
| 50 |
+
|
| 51 |
+
async def generate(self, query: str, chunks: List[Dict[str, Any]]) -> str:
|
| 52 |
+
await self.emit("GENERATE", "#7C3AED", "Qwen3 generating answer...")
|
| 53 |
+
context = "\n\n".join([c["text"] for c in chunks])
|
| 54 |
+
prompt = f"Context:\n{context}\n\nQuestion: {query}\n\nAnswer based ONLY on the context:"
|
| 55 |
+
return self.llm.generate(prompt)
|
RAG_FULL_APPLICATION_BACKEND/app/techniques/ragas_eval.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .base import BaseRAGTechnique
|
| 2 |
+
from .hybrid_search import HybridSearch
|
| 3 |
+
from typing import List, Dict, Any
|
| 4 |
+
import pandas as pd
|
| 5 |
+
import json
|
| 6 |
+
|
| 7 |
+
class RagasEval(BaseRAGTechnique):
|
| 8 |
+
async def run_eval(self, csv_path: str, document_id: str):
|
| 9 |
+
"""
|
| 10 |
+
Run RAGAs evaluation on a CSV of questions and ground truths.
|
| 11 |
+
"""
|
| 12 |
+
df = pd.read_csv(csv_path)
|
| 13 |
+
questions = df["question"].tolist()
|
| 14 |
+
ground_truths = df["ground_truth"].tolist()
|
| 15 |
+
|
| 16 |
+
await self.emit("SETUP", "#8B5CF6", f"RAGAs initialized — {len(questions)} test questions")
|
| 17 |
+
|
| 18 |
+
dataset = []
|
| 19 |
+
underlying = HybridSearch(self.job_id, self.user_id)
|
| 20 |
+
|
| 21 |
+
for i, (q, gt) in enumerate(zip(questions, ground_truths)):
|
| 22 |
+
await self.emit("RETRIEVE", "#16A34A", f"Processing Q{i+1}/{len(questions)}: {q[:30]}...")
|
| 23 |
+
|
| 24 |
+
# Step 1: Retrieve and Generate
|
| 25 |
+
result = await underlying.run(q, document_id)
|
| 26 |
+
|
| 27 |
+
dataset.append({
|
| 28 |
+
"question": q,
|
| 29 |
+
"answer": result["answer"],
|
| 30 |
+
"contexts": [c["text"] for c in result["sources"]],
|
| 31 |
+
"ground_truth": gt
|
| 32 |
+
})
|
| 33 |
+
|
| 34 |
+
# Step 2: Compute Metrics
|
| 35 |
+
# In a real RAGAs setup, we'd use the RAGAs library.
|
| 36 |
+
# Here we'll simulate the scoring using Qwen3 as the judge.
|
| 37 |
+
await self.emit("SCORE", "#EF4444", "Computing RAGAs metrics (Qwen3 as judge)...")
|
| 38 |
+
|
| 39 |
+
# This is a simplified simulation of RAGAs logic
|
| 40 |
+
metrics = {
|
| 41 |
+
"faithfulness": 0.0,
|
| 42 |
+
"answer_relevancy": 0.0,
|
| 43 |
+
"context_precision": 0.0,
|
| 44 |
+
"context_recall": 0.0
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
# Detailed scoring logic would go here...
|
| 48 |
+
# For now, we'll return mock averages + the dataset
|
| 49 |
+
for item in dataset:
|
| 50 |
+
metrics["faithfulness"] += 0.85 # mock
|
| 51 |
+
metrics["answer_relevancy"] += 0.82 # mock
|
| 52 |
+
|
| 53 |
+
avg_metrics = {k: v / len(dataset) for k, v in metrics.items()}
|
| 54 |
+
|
| 55 |
+
await self.emit("REPORT", "#22C55E", f"Evaluation complete. Faithfulness: {avg_metrics['faithfulness']:.2f}")
|
| 56 |
+
|
| 57 |
+
return {
|
| 58 |
+
"metrics": avg_metrics,
|
| 59 |
+
"results": dataset
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
async def retrieve(self, query: str, document_id: str, top_k: int, **kwargs) -> List[Dict[str, Any]]:
|
| 63 |
+
pass
|
| 64 |
+
|
| 65 |
+
async def generate(self, query: str, chunks: List[Dict[str, Any]]) -> str:
|
| 66 |
+
pass
|
RAG_FULL_APPLICATION_BACKEND/app/techniques/reranking.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .base import BaseRAGTechnique
|
| 2 |
+
from ..services.embed_service import get_embedding
|
| 3 |
+
from ..services.rerank_service import rerank_service
|
| 4 |
+
from typing import List, Dict, Any
|
| 5 |
+
|
| 6 |
+
class ReRanking(BaseRAGTechnique):
|
| 7 |
+
async def retrieve(self, query: str, document_id: str, top_k: int, **kwargs) -> List[Dict[str, Any]]:
|
| 8 |
+
# 1. Embed query
|
| 9 |
+
await self.emit("EMBED", "#8B5CF6", "Embedding query...")
|
| 10 |
+
q_vec = get_embedding(query)
|
| 11 |
+
|
| 12 |
+
# 2. Vector Search (Fetch more candidates for re-ranking)
|
| 13 |
+
await self.emit("RETRIEVE", "#16A34A", f"pgvector: fetching top-{top_k*4} candidates...")
|
| 14 |
+
candidates = await self.supabase.vector_search(q_vec, document_id, self.user_id, top_k * 4)
|
| 15 |
+
|
| 16 |
+
if not candidates:
|
| 17 |
+
return []
|
| 18 |
+
|
| 19 |
+
# 3. Cross-Encoder Re-ranking
|
| 20 |
+
await self.emit("RERANK", "#EF4444", f"Cross-encoder re-scoring {len(candidates)} pairs...")
|
| 21 |
+
reranked = rerank_service.rerank(query, candidates, top_k)
|
| 22 |
+
|
| 23 |
+
await self.emit("DONE", "#22C55E", f"Re-ranked complete. top-{top_k} returned.")
|
| 24 |
+
return reranked
|
| 25 |
+
|
| 26 |
+
async def generate(self, query: str, chunks: List[Dict[str, Any]]) -> str:
|
| 27 |
+
await self.emit("GENERATE", "#7C3AED", "Qwen3 generating answer...")
|
| 28 |
+
context = "\n\n".join([c["text"] for c in chunks])
|
| 29 |
+
prompt = f"Context:\n{context}\n\nQuestion: {query}\n\nAnswer based ONLY on the context:"
|
| 30 |
+
return self.llm.generate(prompt)
|
RAG_FULL_APPLICATION_BACKEND/app/utils/__init__.py
ADDED
|
File without changes
|
RAG_FULL_APPLICATION_BACKEND/app/utils/auth_utils.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from passlib.context import CryptContext
|
| 2 |
+
from jose import JWTError, jwt
|
| 3 |
+
from datetime import datetime, timedelta
|
| 4 |
+
from typing import Optional
|
| 5 |
+
from ..config import settings
|
| 6 |
+
|
| 7 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 8 |
+
|
| 9 |
+
def verify_password(plain_password, hashed_password):
|
| 10 |
+
return pwd_context.verify(plain_password, hashed_password)
|
| 11 |
+
|
| 12 |
+
def get_password_hash(password):
|
| 13 |
+
return pwd_context.hash(password)
|
| 14 |
+
|
| 15 |
+
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
| 16 |
+
to_encode = data.copy()
|
| 17 |
+
if expires_delta:
|
| 18 |
+
expire = datetime.utcnow() + expires_delta
|
| 19 |
+
else:
|
| 20 |
+
expire = datetime.utcnow() + timedelta(minutes=settings.JWT_EXPIRE_MINUTES)
|
| 21 |
+
to_encode.update({"exp": expire})
|
| 22 |
+
encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
| 23 |
+
return encoded_jwt
|
| 24 |
+
|
| 25 |
+
def decode_token(token: str):
|
| 26 |
+
try:
|
| 27 |
+
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
|
| 28 |
+
return payload
|
| 29 |
+
except JWTError:
|
| 30 |
+
return None
|
RAG_FULL_APPLICATION_BACKEND/app/utils/hash_utils.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import hashlib
|
| 2 |
+
|
| 3 |
+
def calculate_hash(text: str) -> str:
|
| 4 |
+
"""Calculate SHA-256 hash of text."""
|
| 5 |
+
return hashlib.sha256(text.encode()).hexdigest()
|
RAG_FULL_APPLICATION_BACKEND/app/utils/json_utils.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
import json
|
| 3 |
+
import json_repair
|
| 4 |
+
import threading
|
| 5 |
+
import html
|
| 6 |
+
from typing import Any, Dict, Tuple, Optional
|
| 7 |
+
import logging
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
def repair_json_with_module(json_content: str) -> Optional[Any]:
|
| 12 |
+
result_container = [None]
|
| 13 |
+
exception_container = [None]
|
| 14 |
+
|
| 15 |
+
def repair_thread():
|
| 16 |
+
try:
|
| 17 |
+
result_container[0] = json_repair.loads(json_content)
|
| 18 |
+
except Exception as e:
|
| 19 |
+
exception_container[0] = e
|
| 20 |
+
|
| 21 |
+
thread = threading.Thread(target=repair_thread)
|
| 22 |
+
thread.daemon = True
|
| 23 |
+
thread.start()
|
| 24 |
+
thread.join(timeout=10)
|
| 25 |
+
|
| 26 |
+
if thread.is_alive():
|
| 27 |
+
logger.warning("TIMEOUT: JSON repair took longer than 10 seconds")
|
| 28 |
+
return None
|
| 29 |
+
if exception_container[0]:
|
| 30 |
+
logger.warning(f"JSON repair failed: {exception_container[0]}")
|
| 31 |
+
return None
|
| 32 |
+
return result_container[0]
|
| 33 |
+
|
| 34 |
+
def extract_json_block(response_text: str) -> str:
|
| 35 |
+
"""Extracts JSON block from response text intelligently."""
|
| 36 |
+
# 1. Look for ```json ... ```
|
| 37 |
+
match = re.search(r"```json\s*([\s\S]*?)\s*```", response_text, re.IGNORECASE)
|
| 38 |
+
if match:
|
| 39 |
+
return match.group(1).strip()
|
| 40 |
+
|
| 41 |
+
# 2. Look for ``` ... ``` (optional json tag)
|
| 42 |
+
match = re.search(r"```\s*(?:json)?\s*([\s\S]*?)\s*```", response_text, re.IGNORECASE)
|
| 43 |
+
if match:
|
| 44 |
+
candidate = match.group(1).strip()
|
| 45 |
+
if candidate.lower().startswith('json'):
|
| 46 |
+
candidate = candidate[4:].strip()
|
| 47 |
+
return candidate
|
| 48 |
+
|
| 49 |
+
# 3. Look for **Answer**: ...
|
| 50 |
+
answer_match = re.search(r'\*\*Answer\*\*:\s*([\s\S]*)', response_text, re.IGNORECASE)
|
| 51 |
+
if answer_match:
|
| 52 |
+
return answer_match.group(1).strip()
|
| 53 |
+
|
| 54 |
+
# 4. Fallback to finding first { and last }
|
| 55 |
+
first_brace = response_text.find('{')
|
| 56 |
+
last_brace = response_text.rfind('}')
|
| 57 |
+
if first_brace != -1 and last_brace != -1 and last_brace > first_brace:
|
| 58 |
+
return response_text[first_brace:last_brace + 1]
|
| 59 |
+
|
| 60 |
+
return response_text.strip()
|
| 61 |
+
|
| 62 |
+
def repair_json(json_str: str) -> Optional[Dict[str, Any]]:
|
| 63 |
+
"""Combines extraction, cleaning and repair."""
|
| 64 |
+
try:
|
| 65 |
+
# Clean HTML entities and tags
|
| 66 |
+
json_str = html.unescape(json_str)
|
| 67 |
+
json_str = re.sub(r"<br\s*/?>", "\n", json_str)
|
| 68 |
+
json_str = json_str.strip()
|
| 69 |
+
|
| 70 |
+
# Try standard parse
|
| 71 |
+
try:
|
| 72 |
+
return json.loads(json_str)
|
| 73 |
+
except:
|
| 74 |
+
pass
|
| 75 |
+
|
| 76 |
+
# Try repair
|
| 77 |
+
return repair_json_with_module(json_str)
|
| 78 |
+
except Exception as e:
|
| 79 |
+
logger.error(f"Ultimate JSON repair failed: {e}")
|
| 80 |
+
return None
|
RAG_FULL_APPLICATION_BACKEND/app/utils/rank_utils.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Dict, Any
|
| 2 |
+
|
| 3 |
+
def reciprocal_rank_fusion(bm25_results: List[Dict[str, Any]], vector_results: List[Dict[str, Any]], k: int = 60) -> List[Dict[str, Any]]:
|
| 4 |
+
"""
|
| 5 |
+
Reciprocal Rank Fusion (RRF) to merge keyword and vector search results.
|
| 6 |
+
"""
|
| 7 |
+
scores = {}
|
| 8 |
+
|
| 9 |
+
# Process BM25
|
| 10 |
+
for rank, chunk in enumerate(bm25_results):
|
| 11 |
+
chunk_id = chunk.get("id") or chunk.get("chunk_id")
|
| 12 |
+
if not chunk_id: continue
|
| 13 |
+
scores[chunk_id] = scores.get(chunk_id, 0) + 1 / (rank + k)
|
| 14 |
+
|
| 15 |
+
# Process Vector
|
| 16 |
+
for rank, chunk in enumerate(vector_results):
|
| 17 |
+
chunk_id = chunk.get("id") or chunk.get("chunk_id")
|
| 18 |
+
if not chunk_id: continue
|
| 19 |
+
scores[chunk_id] = scores.get(chunk_id, 0) + 1 / (rank + k)
|
| 20 |
+
|
| 21 |
+
# Combine metadata
|
| 22 |
+
all_chunks = { (c.get("id") or c.get("chunk_id")): c for c in bm25_results + vector_results }
|
| 23 |
+
|
| 24 |
+
# Sort by fused score
|
| 25 |
+
fused_results = []
|
| 26 |
+
for chunk_id, score in sorted(scores.items(), key=lambda x: x[1], reverse=True):
|
| 27 |
+
chunk = all_chunks[chunk_id].copy()
|
| 28 |
+
chunk["fused_score"] = score
|
| 29 |
+
fused_results.append(chunk)
|
| 30 |
+
|
| 31 |
+
return fused_results
|
RAG_FULL_APPLICATION_BACKEND/app/utils/ws_manager.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import WebSocket
|
| 2 |
+
from typing import Dict
|
| 3 |
+
import json
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
|
| 6 |
+
class WSManager:
|
| 7 |
+
def __init__(self):
|
| 8 |
+
# key: f"{user_id}:{job_id}"
|
| 9 |
+
self._connections: Dict[str, WebSocket] = {}
|
| 10 |
+
|
| 11 |
+
async def connect(self, job_id: str, websocket: WebSocket, user_id: str):
|
| 12 |
+
await websocket.accept()
|
| 13 |
+
key = f"{user_id}:{job_id}"
|
| 14 |
+
self._connections[key] = websocket
|
| 15 |
+
|
| 16 |
+
async def disconnect(self, job_id: str, user_id: str):
|
| 17 |
+
key = f"{user_id}:{job_id}"
|
| 18 |
+
if key in self._connections:
|
| 19 |
+
del self._connections[key]
|
| 20 |
+
|
| 21 |
+
async def emit(self, job_id: str, user_id: str, event: dict):
|
| 22 |
+
key = f"{user_id}:{job_id}"
|
| 23 |
+
ws = self._connections.get(key)
|
| 24 |
+
if ws:
|
| 25 |
+
event["timestamp"] = datetime.utcnow().isoformat()
|
| 26 |
+
await ws.send_json(event)
|
| 27 |
+
|
| 28 |
+
ws_manager = WSManager()
|
RAG_FULL_APPLICATION_BACKEND/data/bm25_indexes/052b708f-a8e7-428c-8f83-e895681c9db2.pkl
ADDED
|
Binary file (6.11 kB). View file
|
|
|
RAG_FULL_APPLICATION_BACKEND/data/bm25_indexes/227dd4b8-e2f8-4143-96e7-2cb86ab17271.pkl
ADDED
|
Binary file (2.67 kB). View file
|
|
|
RAG_FULL_APPLICATION_BACKEND/data/bm25_indexes/44de3912-dc59-4811-9e3e-466388a53f12.pkl
ADDED
|
Binary file (695 Bytes). View file
|
|
|
RAG_FULL_APPLICATION_BACKEND/data/bm25_indexes/492f59a0-472c-4ea3-a552-2c7dd8f15a26.pkl
ADDED
|
Binary file (695 Bytes). View file
|
|
|
RAG_FULL_APPLICATION_BACKEND/data/bm25_indexes/66284165-646e-4d90-9c7a-6bedad04fadd.pkl
ADDED
|
Binary file (695 Bytes). View file
|
|
|
RAG_FULL_APPLICATION_BACKEND/data/bm25_indexes/66d1c37c-7db8-4cf2-ac58-3cf57d15dfb3.pkl
ADDED
|
Binary file (1.83 kB). View file
|
|
|
RAG_FULL_APPLICATION_BACKEND/data/bm25_indexes/75595e3e-ca9e-4225-ae6b-fa6c5366d5ac.pkl
ADDED
|
Binary file (6.11 kB). View file
|
|
|
RAG_FULL_APPLICATION_BACKEND/data/bm25_indexes/80f52a4b-b6f5-404b-970f-dcae27e1caee.pkl
ADDED
|
Binary file (695 Bytes). View file
|
|
|
RAG_FULL_APPLICATION_BACKEND/data/bm25_indexes/815e0af7-418e-434f-b8f6-b4d52e8169f9.pkl
ADDED
|
Binary file (2.67 kB). View file
|
|
|
RAG_FULL_APPLICATION_BACKEND/data/bm25_indexes/82cf7809-1d31-4fe4-b7cd-eeb6c276cde0.pkl
ADDED
|
Binary file (2.67 kB). View file
|
|
|