Spaces:
Sleeping
Sleeping
Ahmed Sadik commited on
Commit ·
7aec9cc
1
Parent(s): e7a26ed
refactor: update database connection handling and dependency injection for PostgreSQL and Qdrant
Browse files- api/chat.py +7 -5
- api/documents.py +30 -26
- db/postgres.py +28 -45
- db/qdrant.py +11 -17
- main.py +18 -16
- services/embedding.py +5 -4
- services/resource_manager.py +3 -3
api/chat.py
CHANGED
|
@@ -1,13 +1,15 @@
|
|
| 1 |
import json
|
|
|
|
|
|
|
| 2 |
from services.prompt import FALLBACK_RESPONSE
|
| 3 |
from services.generation import craft_prompt, is_grounded_response, request_llm_response, parse_llm_json, relevant_chunks_to_json
|
| 4 |
from services.resource_manager import get_llm_client
|
| 5 |
from fastapi import APIRouter, Form, Request
|
| 6 |
from fastapi.responses import JSONResponse
|
| 7 |
from pydantic import BaseModel
|
| 8 |
-
from db.qdrant import get_relevant_chunks
|
| 9 |
from services.embedding import embed_question
|
| 10 |
-
from db.postgres import update_document_activity
|
| 11 |
from services.rate_limiter import limiter
|
| 12 |
|
| 13 |
class ChatRequest(BaseModel):
|
|
@@ -18,11 +20,11 @@ route = APIRouter(prefix="/chat", tags=["Chat"])
|
|
| 18 |
|
| 19 |
@route.post("/ask")
|
| 20 |
@limiter.limit("5/minute")
|
| 21 |
-
async def ask_question(request: Request, body: ChatRequest = Form(...)):
|
| 22 |
question = embed_question(body.question)
|
| 23 |
try:
|
| 24 |
-
await update_document_activity(body.uuid)
|
| 25 |
-
chunks, distances = await get_relevant_chunks(question, body.uuid)
|
| 26 |
if not chunks:
|
| 27 |
return JSONResponse(content={"ok": False, "error": "No chunks found.", "data": None, "relevant_chunks": []}, status_code=404)
|
| 28 |
if distances and max(distances) < 0.52: # Threshold for relevance, based on empirical testing
|
|
|
|
| 1 |
import json
|
| 2 |
+
|
| 3 |
+
from fastapi.params import Depends
|
| 4 |
from services.prompt import FALLBACK_RESPONSE
|
| 5 |
from services.generation import craft_prompt, is_grounded_response, request_llm_response, parse_llm_json, relevant_chunks_to_json
|
| 6 |
from services.resource_manager import get_llm_client
|
| 7 |
from fastapi import APIRouter, Form, Request
|
| 8 |
from fastapi.responses import JSONResponse
|
| 9 |
from pydantic import BaseModel
|
| 10 |
+
from db.qdrant import get_qdrant, get_relevant_chunks
|
| 11 |
from services.embedding import embed_question
|
| 12 |
+
from db.postgres import get_pg_pool, update_document_activity
|
| 13 |
from services.rate_limiter import limiter
|
| 14 |
|
| 15 |
class ChatRequest(BaseModel):
|
|
|
|
| 20 |
|
| 21 |
@route.post("/ask")
|
| 22 |
@limiter.limit("5/minute")
|
| 23 |
+
async def ask_question(request: Request, body: ChatRequest = Form(...), pg_pool = Depends(get_pg_pool), qdrant = Depends(get_qdrant)):
|
| 24 |
question = embed_question(body.question)
|
| 25 |
try:
|
| 26 |
+
await update_document_activity(body.uuid, pg_pool)
|
| 27 |
+
chunks, distances = await get_relevant_chunks(question, body.uuid, qdrant)
|
| 28 |
if not chunks:
|
| 29 |
return JSONResponse(content={"ok": False, "error": "No chunks found.", "data": None, "relevant_chunks": []}, status_code=404)
|
| 30 |
if distances and max(distances) < 0.52: # Threshold for relevance, based on empirical testing
|
api/documents.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import asyncio
|
| 2 |
from fastapi import APIRouter, Request, UploadFile, File, BackgroundTasks
|
|
|
|
| 3 |
from fastapi.responses import JSONResponse
|
| 4 |
|
| 5 |
# Import service functions
|
|
@@ -11,14 +12,14 @@ from services.embedding import process_embeddings_background
|
|
| 11 |
from services.rate_limiter import limiter
|
| 12 |
|
| 13 |
#database imports
|
| 14 |
-
from db.qdrant import get_chunk_context_by_index, get_document_chunks_by_uuid
|
| 15 |
-
from db.postgres import get_document_by_hash, get_document_text, insert_document, get_document_by_uuid, document_exists, update_document_activity
|
| 16 |
|
| 17 |
route = APIRouter(prefix="/documents", tags=["Documents"])
|
| 18 |
|
| 19 |
@route.post("/upload")
|
| 20 |
@limiter.limit("3/minute")
|
| 21 |
-
async def upload(request: Request, file: UploadFile = File(...), background_tasks: BackgroundTasks = None):
|
| 22 |
|
| 23 |
file.filename = sanitize_for_display(file.filename)
|
| 24 |
#Check file type
|
|
@@ -33,8 +34,9 @@ async def upload(request: Request, file: UploadFile = File(...), background_task
|
|
| 33 |
#Calculate file hash and check for duplicates
|
| 34 |
file_hash = await calculate_file_hash(content)
|
| 35 |
try :
|
| 36 |
-
existing_doc = await get_document_by_hash(file_hash)
|
| 37 |
-
|
|
|
|
| 38 |
return JSONResponse(content={"success": True, "metadata": existing_doc}, status_code=200)
|
| 39 |
except Exception as e:
|
| 40 |
print(f"Database error: {e}")
|
|
@@ -51,13 +53,14 @@ async def upload(request: Request, file: UploadFile = File(...), background_task
|
|
| 51 |
|
| 52 |
#store document metadata and chunks in the database
|
| 53 |
try:
|
| 54 |
-
await insert_document(document_id, file.filename, validated_text, len(chunks), pages[-1]['page'], file_hash)
|
| 55 |
except Exception as e:
|
| 56 |
print(f"Database error: {e}")
|
| 57 |
return JSONResponse(content={"success": False, "error": "Database error, Please try again."}, status_code=500)
|
| 58 |
|
| 59 |
#embed chunks and store embeddings in the vector database
|
| 60 |
-
|
|
|
|
| 61 |
|
| 62 |
response = {
|
| 63 |
"success": True,
|
|
@@ -75,22 +78,23 @@ async def upload(request: Request, file: UploadFile = File(...), background_task
|
|
| 75 |
|
| 76 |
@route.get("/{uuid}/status")
|
| 77 |
@limiter.limit("20/minute")
|
| 78 |
-
async def check_processing_status(request: Request, uuid: str):
|
| 79 |
"""Checks if Qdrant has finished saving the chunks"""
|
| 80 |
try:
|
| 81 |
-
chunks = await get_document_chunks_by_uuid(uuid)
|
| 82 |
is_ready = len(chunks) > 0
|
| 83 |
return JSONResponse(content={"success": True, "ready": is_ready})
|
| 84 |
-
except Exception:
|
|
|
|
| 85 |
return JSONResponse(content={"success": True, "ready": False})
|
| 86 |
|
| 87 |
@route.get("/{uuid}")
|
| 88 |
@limiter.limit("30/minute")
|
| 89 |
-
async def get_document(request: Request, uuid: str):
|
| 90 |
''' Placeholder for fetching document metadata and chunks from the database '''
|
| 91 |
try:
|
| 92 |
-
await update_document_activity(uuid)
|
| 93 |
-
document = await get_document_by_uuid(uuid)
|
| 94 |
except Exception as e:
|
| 95 |
print(f"Database error: {e}")
|
| 96 |
return JSONResponse(content={"success": False, "message": "Database error"}, status_code=500)
|
|
@@ -102,11 +106,11 @@ async def get_document(request: Request, uuid: str):
|
|
| 102 |
|
| 103 |
@route.get("/{uuid}/info")
|
| 104 |
@limiter.limit("30/minute")
|
| 105 |
-
async def get_document_info(request: Request, uuid: str):
|
| 106 |
''' Placeholder for fetching document metadata and chunks from the database '''
|
| 107 |
try:
|
| 108 |
-
await update_document_activity(uuid)
|
| 109 |
-
document = await get_document_by_uuid(uuid) # Implement this function to fetch document metadata
|
| 110 |
except Exception as e:
|
| 111 |
print(f"Database error: {e}")
|
| 112 |
return JSONResponse(content={"success": False, "error": "Database error"}, status_code=500)
|
|
@@ -118,14 +122,14 @@ async def get_document_info(request: Request, uuid: str):
|
|
| 118 |
|
| 119 |
@route.get("/{uuid}/preview")
|
| 120 |
@limiter.limit("30/minute")
|
| 121 |
-
async def get_document_chunks(request: Request, uuid: str):
|
| 122 |
''' Placeholder for fetching document chunks from the database '''
|
| 123 |
try:
|
| 124 |
-
if not await document_exists(uuid):
|
| 125 |
return JSONResponse(content={"success": False, "error": "Document not found. Please try again."}, status_code=404)
|
| 126 |
-
await update_document_activity(uuid)
|
| 127 |
-
chunks = await get_document_chunks_by_uuid(uuid)
|
| 128 |
-
text_preview = await get_document_text(uuid)
|
| 129 |
except Exception as e:
|
| 130 |
print(f"Database error: {e}")
|
| 131 |
return JSONResponse(content={"success": False, "error": "Database error. Please try again."}, status_code=500)
|
|
@@ -134,16 +138,16 @@ async def get_document_chunks(request: Request, uuid: str):
|
|
| 134 |
|
| 135 |
@route.get("/{uuid}/{chunk_index}/context")
|
| 136 |
@limiter.limit("30/minute")
|
| 137 |
-
async def get_chunk_context(request: Request, uuid: str, chunk_index: int):
|
| 138 |
''' Placeholder for fetching document chunks from the database '''
|
| 139 |
try:
|
| 140 |
-
if not await document_exists(uuid):
|
| 141 |
return JSONResponse(content={"success": False, "error": "Document not found. Please try again."}, status_code=404)
|
| 142 |
-
await update_document_activity(uuid)
|
| 143 |
-
index_pool = await get_chunk_context_by_index(uuid, chunk_index)
|
| 144 |
if not index_pool:
|
| 145 |
return JSONResponse(content={"success": False, "error": "Chunk context not found. Please try again."}, status_code=404)
|
| 146 |
-
context = await get_document_text(uuid, min(index_pool), max(index_pool) - min(index_pool))
|
| 147 |
if not context:
|
| 148 |
return JSONResponse(content={"success": False, "error": "Chunk context not found. Please try again."}, status_code=404)
|
| 149 |
except Exception as e:
|
|
|
|
| 1 |
import asyncio
|
| 2 |
from fastapi import APIRouter, Request, UploadFile, File, BackgroundTasks
|
| 3 |
+
from fastapi.params import Depends
|
| 4 |
from fastapi.responses import JSONResponse
|
| 5 |
|
| 6 |
# Import service functions
|
|
|
|
| 12 |
from services.rate_limiter import limiter
|
| 13 |
|
| 14 |
#database imports
|
| 15 |
+
from db.qdrant import get_chunk_context_by_index, get_document_chunks_by_uuid, get_qdrant
|
| 16 |
+
from db.postgres import get_document_by_hash, get_document_text, get_pg_pool, insert_document, get_document_by_uuid, document_exists, update_document_activity
|
| 17 |
|
| 18 |
route = APIRouter(prefix="/documents", tags=["Documents"])
|
| 19 |
|
| 20 |
@route.post("/upload")
|
| 21 |
@limiter.limit("3/minute")
|
| 22 |
+
async def upload(request: Request, file: UploadFile = File(...), background_tasks: BackgroundTasks = None, pg_pool = Depends(get_pg_pool), qdrant = Depends(get_qdrant)):
|
| 23 |
|
| 24 |
file.filename = sanitize_for_display(file.filename)
|
| 25 |
#Check file type
|
|
|
|
| 34 |
#Calculate file hash and check for duplicates
|
| 35 |
file_hash = await calculate_file_hash(content)
|
| 36 |
try :
|
| 37 |
+
existing_doc = await get_document_by_hash(file_hash, pg_pool)
|
| 38 |
+
chunks = await get_document_chunks_by_uuid(existing_doc.get("id"), qdrant, limit=1)
|
| 39 |
+
if existing_doc and len(chunks) > 0:
|
| 40 |
return JSONResponse(content={"success": True, "metadata": existing_doc}, status_code=200)
|
| 41 |
except Exception as e:
|
| 42 |
print(f"Database error: {e}")
|
|
|
|
| 53 |
|
| 54 |
#store document metadata and chunks in the database
|
| 55 |
try:
|
| 56 |
+
await insert_document(document_id, file.filename, validated_text, len(chunks), pages[-1]['page'], file_hash, pg_pool)
|
| 57 |
except Exception as e:
|
| 58 |
print(f"Database error: {e}")
|
| 59 |
return JSONResponse(content={"success": False, "error": "Database error, Please try again."}, status_code=500)
|
| 60 |
|
| 61 |
#embed chunks and store embeddings in the vector database
|
| 62 |
+
print(f"Scheduling background embedding for {document_id}...", flush=True)
|
| 63 |
+
background_tasks.add_task(process_embeddings_background, chunks, document_id, qdrant)
|
| 64 |
|
| 65 |
response = {
|
| 66 |
"success": True,
|
|
|
|
| 78 |
|
| 79 |
@route.get("/{uuid}/status")
|
| 80 |
@limiter.limit("20/minute")
|
| 81 |
+
async def check_processing_status(request: Request, uuid: str, qdrant = Depends(get_qdrant)):
|
| 82 |
"""Checks if Qdrant has finished saving the chunks"""
|
| 83 |
try:
|
| 84 |
+
chunks = await get_document_chunks_by_uuid(uuid, qdrant, limit=1) # Fetch just one chunk to check if embedding is done
|
| 85 |
is_ready = len(chunks) > 0
|
| 86 |
return JSONResponse(content={"success": True, "ready": is_ready})
|
| 87 |
+
except Exception as e:
|
| 88 |
+
print(str(e))
|
| 89 |
return JSONResponse(content={"success": True, "ready": False})
|
| 90 |
|
| 91 |
@route.get("/{uuid}")
|
| 92 |
@limiter.limit("30/minute")
|
| 93 |
+
async def get_document(request: Request, uuid: str, pg_pool = Depends(get_pg_pool)):
|
| 94 |
''' Placeholder for fetching document metadata and chunks from the database '''
|
| 95 |
try:
|
| 96 |
+
await update_document_activity(uuid, pg_pool)
|
| 97 |
+
document = await get_document_by_uuid(uuid, pg_pool)
|
| 98 |
except Exception as e:
|
| 99 |
print(f"Database error: {e}")
|
| 100 |
return JSONResponse(content={"success": False, "message": "Database error"}, status_code=500)
|
|
|
|
| 106 |
|
| 107 |
@route.get("/{uuid}/info")
|
| 108 |
@limiter.limit("30/minute")
|
| 109 |
+
async def get_document_info(request: Request, uuid: str, pg_pool = Depends(get_pg_pool)):
|
| 110 |
''' Placeholder for fetching document metadata and chunks from the database '''
|
| 111 |
try:
|
| 112 |
+
await update_document_activity(uuid, pg_pool)
|
| 113 |
+
document = await get_document_by_uuid(uuid, pg_pool) # Implement this function to fetch document metadata
|
| 114 |
except Exception as e:
|
| 115 |
print(f"Database error: {e}")
|
| 116 |
return JSONResponse(content={"success": False, "error": "Database error"}, status_code=500)
|
|
|
|
| 122 |
|
| 123 |
@route.get("/{uuid}/preview")
|
| 124 |
@limiter.limit("30/minute")
|
| 125 |
+
async def get_document_chunks(request: Request, uuid: str, pg_pool = Depends(get_pg_pool), qdrant = Depends(get_qdrant)):
|
| 126 |
''' Placeholder for fetching document chunks from the database '''
|
| 127 |
try:
|
| 128 |
+
if not await document_exists(uuid, pg_pool):
|
| 129 |
return JSONResponse(content={"success": False, "error": "Document not found. Please try again."}, status_code=404)
|
| 130 |
+
await update_document_activity(uuid, pg_pool)
|
| 131 |
+
chunks = await get_document_chunks_by_uuid(uuid, qdrant)
|
| 132 |
+
text_preview = await get_document_text(uuid, pg_pool)
|
| 133 |
except Exception as e:
|
| 134 |
print(f"Database error: {e}")
|
| 135 |
return JSONResponse(content={"success": False, "error": "Database error. Please try again."}, status_code=500)
|
|
|
|
| 138 |
|
| 139 |
@route.get("/{uuid}/{chunk_index}/context")
|
| 140 |
@limiter.limit("30/minute")
|
| 141 |
+
async def get_chunk_context(request: Request, uuid: str, chunk_index: int, pg_pool = Depends(get_pg_pool), qdrant = Depends(get_qdrant)):
|
| 142 |
''' Placeholder for fetching document chunks from the database '''
|
| 143 |
try:
|
| 144 |
+
if not await document_exists(uuid, pg_pool):
|
| 145 |
return JSONResponse(content={"success": False, "error": "Document not found. Please try again."}, status_code=404)
|
| 146 |
+
await update_document_activity(uuid, pg_pool)
|
| 147 |
+
index_pool = await get_chunk_context_by_index(uuid, chunk_index, qdrant)
|
| 148 |
if not index_pool:
|
| 149 |
return JSONResponse(content={"success": False, "error": "Chunk context not found. Please try again."}, status_code=404)
|
| 150 |
+
context = await get_document_text(uuid, min(index_pool), max(index_pool) - min(index_pool), pg_pool)
|
| 151 |
if not context:
|
| 152 |
return JSONResponse(content={"success": False, "error": "Chunk context not found. Please try again."}, status_code=404)
|
| 153 |
except Exception as e:
|
db/postgres.py
CHANGED
|
@@ -1,54 +1,37 @@
|
|
| 1 |
from datetime import timedelta
|
| 2 |
|
| 3 |
-
import
|
| 4 |
-
from click import UUID
|
| 5 |
from services.storage import human_readable_size
|
| 6 |
from config import PREVIEW_LENGTH
|
| 7 |
|
| 8 |
-
#
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
id TEXT PRIMARY KEY,
|
| 14 |
-
original_filename TEXT NOT NULL,
|
| 15 |
-
full_text TEXT NOT NULL,
|
| 16 |
-
text_length INTEGER NOT NULL,
|
| 17 |
-
file_hash TEXT UNIQUE NOT NULL,
|
| 18 |
-
chunks_count INTEGER NOT NULL,
|
| 19 |
-
pages_count INTEGER NOT NULL,
|
| 20 |
-
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
| 21 |
-
last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
| 22 |
-
);
|
| 23 |
-
'''
|
| 24 |
|
| 25 |
-
async def
|
| 26 |
-
"""Initialize the database connection pool and create tables if they don't exist."""
|
| 27 |
-
global pool
|
| 28 |
-
# Create a connection pool
|
| 29 |
-
pool = await asyncpg.create_pool(db_url, min_size=2, max_size=10)
|
| 30 |
-
async with pool.acquire() as conn:
|
| 31 |
-
await conn.execute(INIT_QUERY)
|
| 32 |
-
|
| 33 |
-
async def close_database():
|
| 34 |
-
"""Close the database connection pool."""
|
| 35 |
-
global pool
|
| 36 |
-
if pool:
|
| 37 |
-
await pool.close()
|
| 38 |
-
|
| 39 |
-
async def insert_document(document_id, original_filename, full_text, chunks_count, pages_count, file_hash):
|
| 40 |
''' Insert a new document into the database and return its ID '''
|
| 41 |
query = '''
|
| 42 |
INSERT INTO documents (id, original_filename, full_text, text_length, chunks_count, pages_count, file_hash)
|
| 43 |
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
| 44 |
'''
|
| 45 |
-
async with
|
| 46 |
await conn.execute(query, document_id, original_filename, full_text, len(full_text), chunks_count, pages_count, file_hash)
|
| 47 |
return document_id
|
| 48 |
|
| 49 |
-
async def get_document_by_uuid(document_id):
|
| 50 |
''' Retrieve document metadata by its UUID '''
|
| 51 |
-
async with
|
| 52 |
query = 'SELECT id, original_filename, chunks_count, pages_count, text_length, created_at FROM documents WHERE id = $1'
|
| 53 |
row = await conn.fetchrow(query, document_id)
|
| 54 |
if row:
|
|
@@ -63,27 +46,27 @@ async def get_document_by_uuid(document_id):
|
|
| 63 |
}
|
| 64 |
return None
|
| 65 |
|
| 66 |
-
async def document_exists(document_id):
|
| 67 |
''' Check if a document with the given ID exists in the database '''
|
| 68 |
-
async with
|
| 69 |
row = await conn.fetchval('SELECT 1 FROM documents WHERE id = $1', document_id)
|
| 70 |
return row is not None
|
| 71 |
|
| 72 |
-
async def get_document_text(document_id, start_char=0, length=PREVIEW_LENGTH):
|
| 73 |
''' Retrieve a substring of the document's full text based on character offsets '''
|
| 74 |
-
async with
|
| 75 |
query = 'SELECT SUBSTR(full_text, $1, $2) FROM documents WHERE id = $3'
|
| 76 |
text = await conn.fetchval(query, start_char + 1, length, document_id)
|
| 77 |
return text
|
| 78 |
|
| 79 |
-
async def update_document_activity(document_id):
|
| 80 |
''' Update the last activity timestamp for a document '''
|
| 81 |
-
async with
|
| 82 |
await conn.execute('UPDATE documents SET last_activity_at = CURRENT_TIMESTAMP WHERE id = $1', document_id)
|
| 83 |
|
| 84 |
-
async def delete_inactive_documents(inactivity_threshold_hours=48):
|
| 85 |
''' Delete documents that haven't been accessed within the specified inactivity threshold '''
|
| 86 |
-
async with
|
| 87 |
# Pass a standard Python timedelta, and asyncpg translates it to a Postgres INTERVAL
|
| 88 |
threshold = timedelta(seconds=inactivity_threshold_hours)
|
| 89 |
query = '''
|
|
@@ -95,9 +78,9 @@ async def delete_inactive_documents(inactivity_threshold_hours=48):
|
|
| 95 |
|
| 96 |
return [record['id'] for record in records]
|
| 97 |
|
| 98 |
-
async def get_document_by_hash(file_hash):
|
| 99 |
''' Retrieve document metadata by its file hash '''
|
| 100 |
-
async with
|
| 101 |
query = 'SELECT id, original_filename, chunks_count, pages_count, text_length, created_at FROM documents WHERE file_hash = $1'
|
| 102 |
row = await conn.fetchrow(query, file_hash)
|
| 103 |
if row:
|
|
|
|
| 1 |
from datetime import timedelta
|
| 2 |
|
| 3 |
+
from fastapi import Request
|
|
|
|
| 4 |
from services.storage import human_readable_size
|
| 5 |
from config import PREVIEW_LENGTH
|
| 6 |
|
| 7 |
+
#CREATE TABLE IF NOT EXISTS documents (
|
| 8 |
+
# id TEXT PRIMARY KEY,
|
| 9 |
+
# original_filename TEXT NOT NULL,
|
| 10 |
+
# full_text TEXT NOT NULL,
|
| 11 |
+
# text_length INTEGER NOT NULL,
|
| 12 |
+
# file_hash TEXT UNIQUE NOT NULL,
|
| 13 |
+
# chunks_count INTEGER NOT NULL,
|
| 14 |
+
# pages_count INTEGER NOT NULL,
|
| 15 |
+
# created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
| 16 |
+
# last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
| 17 |
+
# );
|
| 18 |
|
| 19 |
+
async def get_pg_pool(request: Request):
|
| 20 |
+
return request.app.state.pg_pool
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
+
async def insert_document(document_id, original_filename, full_text, chunks_count, pages_count, file_hash, pg_pool):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
''' Insert a new document into the database and return its ID '''
|
| 24 |
query = '''
|
| 25 |
INSERT INTO documents (id, original_filename, full_text, text_length, chunks_count, pages_count, file_hash)
|
| 26 |
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
| 27 |
'''
|
| 28 |
+
async with pg_pool.acquire() as conn:
|
| 29 |
await conn.execute(query, document_id, original_filename, full_text, len(full_text), chunks_count, pages_count, file_hash)
|
| 30 |
return document_id
|
| 31 |
|
| 32 |
+
async def get_document_by_uuid(document_id, pg_pool):
|
| 33 |
''' Retrieve document metadata by its UUID '''
|
| 34 |
+
async with pg_pool.acquire() as conn:
|
| 35 |
query = 'SELECT id, original_filename, chunks_count, pages_count, text_length, created_at FROM documents WHERE id = $1'
|
| 36 |
row = await conn.fetchrow(query, document_id)
|
| 37 |
if row:
|
|
|
|
| 46 |
}
|
| 47 |
return None
|
| 48 |
|
| 49 |
+
async def document_exists(document_id, pg_pool):
|
| 50 |
''' Check if a document with the given ID exists in the database '''
|
| 51 |
+
async with pg_pool.acquire() as conn:
|
| 52 |
row = await conn.fetchval('SELECT 1 FROM documents WHERE id = $1', document_id)
|
| 53 |
return row is not None
|
| 54 |
|
| 55 |
+
async def get_document_text(document_id, pg_pool, start_char=0, length=PREVIEW_LENGTH):
|
| 56 |
''' Retrieve a substring of the document's full text based on character offsets '''
|
| 57 |
+
async with pg_pool.acquire() as conn:
|
| 58 |
query = 'SELECT SUBSTR(full_text, $1, $2) FROM documents WHERE id = $3'
|
| 59 |
text = await conn.fetchval(query, start_char + 1, length, document_id)
|
| 60 |
return text
|
| 61 |
|
| 62 |
+
async def update_document_activity(document_id, pg_pool):
|
| 63 |
''' Update the last activity timestamp for a document '''
|
| 64 |
+
async with pg_pool.acquire() as conn:
|
| 65 |
await conn.execute('UPDATE documents SET last_activity_at = CURRENT_TIMESTAMP WHERE id = $1', document_id)
|
| 66 |
|
| 67 |
+
async def delete_inactive_documents(pg_pool, inactivity_threshold_hours=48):
|
| 68 |
''' Delete documents that haven't been accessed within the specified inactivity threshold '''
|
| 69 |
+
async with pg_pool.acquire() as conn:
|
| 70 |
# Pass a standard Python timedelta, and asyncpg translates it to a Postgres INTERVAL
|
| 71 |
threshold = timedelta(seconds=inactivity_threshold_hours)
|
| 72 |
query = '''
|
|
|
|
| 78 |
|
| 79 |
return [record['id'] for record in records]
|
| 80 |
|
| 81 |
+
async def get_document_by_hash(file_hash, pg_pool):
|
| 82 |
''' Retrieve document metadata by its file hash '''
|
| 83 |
+
async with pg_pool.acquire() as conn:
|
| 84 |
query = 'SELECT id, original_filename, chunks_count, pages_count, text_length, created_at FROM documents WHERE file_hash = $1'
|
| 85 |
row = await conn.fetchrow(query, file_hash)
|
| 86 |
if row:
|
db/qdrant.py
CHANGED
|
@@ -1,23 +1,17 @@
|
|
| 1 |
import os
|
| 2 |
import uuid
|
| 3 |
-
from
|
| 4 |
-
from qdrant_client.models import
|
| 5 |
from config import PREVIEW_LENGTH
|
| 6 |
|
| 7 |
-
# Connect to your local Qdrant Docker container
|
| 8 |
-
qdrant = AsyncQdrantClient(url=os.getenv("QDRANT_URL"))
|
| 9 |
COLLECTION_NAME = os.getenv("QDRANT_COLLECTION")
|
| 10 |
|
| 11 |
-
async def
|
| 12 |
-
|
| 13 |
-
if not await qdrant.collection_exists(COLLECTION_NAME):
|
| 14 |
-
await qdrant.create_collection(
|
| 15 |
-
collection_name=COLLECTION_NAME,
|
| 16 |
-
vectors_config=VectorParams(size=384, distance=Distance.COSINE),
|
| 17 |
-
)
|
| 18 |
|
| 19 |
-
async def save_embeddings(embeddings, chunks, document_id):
|
| 20 |
"""Saves both the vector AND the text payload into Qdrant"""
|
|
|
|
| 21 |
points = []
|
| 22 |
|
| 23 |
#loop through the text chunks and their matching vectors
|
|
@@ -42,7 +36,7 @@ async def save_embeddings(embeddings, chunks, document_id):
|
|
| 42 |
points=points
|
| 43 |
)
|
| 44 |
|
| 45 |
-
async def get_relevant_chunks(question_embedding, document_id, top_k=3):
|
| 46 |
"""Searches Qdrant and filters ONLY for the active document"""
|
| 47 |
|
| 48 |
search_results = await qdrant.query_points(
|
|
@@ -65,7 +59,7 @@ async def get_relevant_chunks(question_embedding, document_id, top_k=3):
|
|
| 65 |
|
| 66 |
return retrieved_chunks, scores
|
| 67 |
|
| 68 |
-
async def get_chunk_context_by_index(document_id
|
| 69 |
"""Fetches ONLY the start and end characters for a chunk and its neighbors"""
|
| 70 |
|
| 71 |
records, _ = await qdrant.scroll(
|
|
@@ -98,7 +92,7 @@ async def get_chunk_context_by_index(document_id: str, target_index: int):
|
|
| 98 |
for index in (record.payload['start_char'], record.payload['end_char'])
|
| 99 |
]
|
| 100 |
|
| 101 |
-
async def get_document_chunks_by_uuid(document_id
|
| 102 |
"""Fetches clean, contiguous preview chunks with exactly the keys needed"""
|
| 103 |
|
| 104 |
records, _ = await qdrant.scroll(
|
|
@@ -115,7 +109,7 @@ async def get_document_chunks_by_uuid(document_id: str):
|
|
| 115 |
)
|
| 116 |
]
|
| 117 |
),
|
| 118 |
-
limit=
|
| 119 |
with_payload=["chunk_index", "start_char", "end_char"],
|
| 120 |
with_vectors=False
|
| 121 |
)
|
|
@@ -131,7 +125,7 @@ async def get_document_chunks_by_uuid(document_id: str):
|
|
| 131 |
for record in records
|
| 132 |
]
|
| 133 |
|
| 134 |
-
async def delete_document_chunks(document_id
|
| 135 |
"""Deletes all chunks and vectors belonging to a specific document"""
|
| 136 |
|
| 137 |
await qdrant.delete(
|
|
|
|
| 1 |
import os
|
| 2 |
import uuid
|
| 3 |
+
from fastapi import Request
|
| 4 |
+
from qdrant_client.models import Range, PointStruct, Filter, FieldCondition, MatchValue
|
| 5 |
from config import PREVIEW_LENGTH
|
| 6 |
|
|
|
|
|
|
|
| 7 |
COLLECTION_NAME = os.getenv("QDRANT_COLLECTION")
|
| 8 |
|
| 9 |
+
async def get_qdrant(request: Request):
|
| 10 |
+
return request.app.state.qdrant
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
+
async def save_embeddings(embeddings, chunks, document_id, qdrant):
|
| 13 |
"""Saves both the vector AND the text payload into Qdrant"""
|
| 14 |
+
|
| 15 |
points = []
|
| 16 |
|
| 17 |
#loop through the text chunks and their matching vectors
|
|
|
|
| 36 |
points=points
|
| 37 |
)
|
| 38 |
|
| 39 |
+
async def get_relevant_chunks(question_embedding, document_id, qdrant, top_k=3):
|
| 40 |
"""Searches Qdrant and filters ONLY for the active document"""
|
| 41 |
|
| 42 |
search_results = await qdrant.query_points(
|
|
|
|
| 59 |
|
| 60 |
return retrieved_chunks, scores
|
| 61 |
|
| 62 |
+
async def get_chunk_context_by_index(document_id, target_index, qdrant):
|
| 63 |
"""Fetches ONLY the start and end characters for a chunk and its neighbors"""
|
| 64 |
|
| 65 |
records, _ = await qdrant.scroll(
|
|
|
|
| 92 |
for index in (record.payload['start_char'], record.payload['end_char'])
|
| 93 |
]
|
| 94 |
|
| 95 |
+
async def get_document_chunks_by_uuid(document_id, qdrant, limit=30):
|
| 96 |
"""Fetches clean, contiguous preview chunks with exactly the keys needed"""
|
| 97 |
|
| 98 |
records, _ = await qdrant.scroll(
|
|
|
|
| 109 |
)
|
| 110 |
]
|
| 111 |
),
|
| 112 |
+
limit=limit,
|
| 113 |
with_payload=["chunk_index", "start_char", "end_char"],
|
| 114 |
with_vectors=False
|
| 115 |
)
|
|
|
|
| 125 |
for record in records
|
| 126 |
]
|
| 127 |
|
| 128 |
+
async def delete_document_chunks(document_id, qdrant):
|
| 129 |
"""Deletes all chunks and vectors belonging to a specific document"""
|
| 130 |
|
| 131 |
await qdrant.delete(
|
main.py
CHANGED
|
@@ -1,11 +1,12 @@
|
|
| 1 |
import asyncio
|
| 2 |
import os
|
|
|
|
| 3 |
from dotenv import load_dotenv
|
| 4 |
from fastapi import FastAPI
|
| 5 |
from contextlib import asynccontextmanager
|
|
|
|
|
|
|
| 6 |
from api import chat, documents
|
| 7 |
-
from db.qdrant import init_qdrant
|
| 8 |
-
from db.postgres import close_database, initialize_database
|
| 9 |
from services.resource_manager import clean_inactive_documents
|
| 10 |
from fastapi.middleware.cors import CORSMiddleware
|
| 11 |
from services.rate_limiter import limiter, custom_rate_limit_handler
|
|
@@ -19,23 +20,24 @@ origins = [
|
|
| 19 |
|
| 20 |
@asynccontextmanager
|
| 21 |
async def lifespan(app: FastAPI):
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
yield
|
| 33 |
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
except Exception as e:
|
| 38 |
-
print(f"Error closing database: {e}")
|
| 39 |
|
| 40 |
app = FastAPI(lifespan=lifespan)
|
| 41 |
# Attach the limiter to the app and set up the custom rate limit handler
|
|
|
|
| 1 |
import asyncio
|
| 2 |
import os
|
| 3 |
+
import asyncpg
|
| 4 |
from dotenv import load_dotenv
|
| 5 |
from fastapi import FastAPI
|
| 6 |
from contextlib import asynccontextmanager
|
| 7 |
+
|
| 8 |
+
from qdrant_client import AsyncQdrantClient
|
| 9 |
from api import chat, documents
|
|
|
|
|
|
|
| 10 |
from services.resource_manager import clean_inactive_documents
|
| 11 |
from fastapi.middleware.cors import CORSMiddleware
|
| 12 |
from services.rate_limiter import limiter, custom_rate_limit_handler
|
|
|
|
| 20 |
|
| 21 |
@asynccontextmanager
|
| 22 |
async def lifespan(app: FastAPI):
|
| 23 |
+
print("Connecting to databases...")
|
| 24 |
+
|
| 25 |
+
# Initialize PostgreSQL Connection Pool
|
| 26 |
+
app.state.pg_pool = await asyncpg.create_pool(os.getenv("POSTGRES_URI"))
|
| 27 |
+
|
| 28 |
+
# Initialize Qdrant Client
|
| 29 |
+
app.state.qdrant = AsyncQdrantClient(
|
| 30 |
+
url=os.getenv("QDRANT_URL"),
|
| 31 |
+
api_key=os.getenv("QDRANT_API_KEY")
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
asyncio.create_task(clean_inactive_documents(app.state.pg_pool, app.state.qdrant))
|
| 35 |
|
| 36 |
yield
|
| 37 |
|
| 38 |
+
print("Closing database connections...")
|
| 39 |
+
await app.state.pg_pool.close()
|
| 40 |
+
await app.state.qdrant.close()
|
|
|
|
|
|
|
| 41 |
|
| 42 |
app = FastAPI(lifespan=lifespan)
|
| 43 |
# Attach the limiter to the app and set up the custom rate limit handler
|
services/embedding.py
CHANGED
|
@@ -18,10 +18,11 @@ def embed_question(question):
|
|
| 18 |
embedding = model.encode([bge_query])
|
| 19 |
return embedding
|
| 20 |
|
| 21 |
-
async def process_embeddings_background(chunks, document_id):
|
|
|
|
| 22 |
try:
|
| 23 |
embeddings = await asyncio.to_thread(generate_embeddings, chunks)
|
| 24 |
-
await save_embeddings(embeddings, chunks, document_id)
|
| 25 |
-
print(f"Background embedding complete for {document_id}")
|
| 26 |
except Exception as e:
|
| 27 |
-
print(f"Background embedding failed: {e}")
|
|
|
|
| 18 |
embedding = model.encode([bge_query])
|
| 19 |
return embedding
|
| 20 |
|
| 21 |
+
async def process_embeddings_background(chunks, document_id, qdrant):
|
| 22 |
+
print(f"Starting background embedding for {document_id}...", flush=True)
|
| 23 |
try:
|
| 24 |
embeddings = await asyncio.to_thread(generate_embeddings, chunks)
|
| 25 |
+
await save_embeddings(embeddings, chunks, document_id, qdrant)
|
| 26 |
+
print(f"Background embedding complete for {document_id}", flush=True)
|
| 27 |
except Exception as e:
|
| 28 |
+
print(f"Background embedding failed: {e}", flush=True)
|
services/resource_manager.py
CHANGED
|
@@ -18,15 +18,15 @@ def get_model():
|
|
| 18 |
print("Loading model...")
|
| 19 |
return SentenceTransformer('BAAI/bge-small-en-v1.5')
|
| 20 |
|
| 21 |
-
async def clean_inactive_documents():
|
| 22 |
''' clean up documents that haven't been accessed in a while '''
|
| 23 |
while True:
|
| 24 |
await asyncio.sleep(3600) # Run cleanup every hour
|
| 25 |
print("Running cleanup of inactive documents...")
|
| 26 |
try:
|
| 27 |
-
ids = await delete_inactive_documents()
|
| 28 |
for doc_id in ids:
|
| 29 |
-
await delete_document_chunks(doc_id)
|
| 30 |
print(f"Deleted {len(ids)} inactive documents and their chunks.")
|
| 31 |
except Exception as e:
|
| 32 |
print(f"Error during cleanup: {e}")
|
|
|
|
| 18 |
print("Loading model...")
|
| 19 |
return SentenceTransformer('BAAI/bge-small-en-v1.5')
|
| 20 |
|
| 21 |
+
async def clean_inactive_documents(pg_pool, qdrant):
|
| 22 |
''' clean up documents that haven't been accessed in a while '''
|
| 23 |
while True:
|
| 24 |
await asyncio.sleep(3600) # Run cleanup every hour
|
| 25 |
print("Running cleanup of inactive documents...")
|
| 26 |
try:
|
| 27 |
+
ids = await delete_inactive_documents(pg_pool)
|
| 28 |
for doc_id in ids:
|
| 29 |
+
await delete_document_chunks(doc_id, qdrant)
|
| 30 |
print(f"Deleted {len(ids)} inactive documents and their chunks.")
|
| 31 |
except Exception as e:
|
| 32 |
print(f"Error during cleanup: {e}")
|