Ahmed Sadik commited on
Commit
7aec9cc
·
1 Parent(s): e7a26ed

refactor: update database connection handling and dependency injection for PostgreSQL and Qdrant

Browse files
Files changed (7) hide show
  1. api/chat.py +7 -5
  2. api/documents.py +30 -26
  3. db/postgres.py +28 -45
  4. db/qdrant.py +11 -17
  5. main.py +18 -16
  6. services/embedding.py +5 -4
  7. 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
- if existing_doc:
 
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
- background_tasks.add_task(process_embeddings_background, chunks, document_id)
 
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 asyncpg
4
- from click import UUID
5
  from services.storage import human_readable_size
6
  from config import PREVIEW_LENGTH
7
 
8
- # The global connection pool
9
- pool = None
 
 
 
 
 
 
 
 
 
10
 
11
- INIT_QUERY = '''
12
- CREATE TABLE IF NOT EXISTS documents (
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 initialize_database(db_url: str):
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 pool.acquire() as conn:
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 pool.acquire() as conn:
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 pool.acquire() as conn:
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 pool.acquire() as conn:
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 pool.acquire() as conn:
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 pool.acquire() as conn:
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 pool.acquire() as conn:
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 qdrant_client import AsyncQdrantClient
4
- from qdrant_client.models import Distance, Range, VectorParams, PointStruct, Filter, FieldCondition, MatchValue
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 init_qdrant():
12
- """Call this in your FastAPI lifespan alongside your Postgres init"""
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: str, target_index: int):
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: str):
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=30,
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: str):
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
- # STARTUP
23
- try:
24
- await initialize_database(os.getenv("POSTGRES_URI"))
25
- await init_qdrant()
26
- except Exception as e:
27
- print(f"Error initializing database: {e}")
28
- raise
29
-
30
- asyncio.create_task(clean_inactive_documents())
 
 
 
31
 
32
  yield
33
 
34
- # SHUTDOWN
35
- try:
36
- await close_database()
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}")