Files
zeekay 2e295af644 fix(qdrant): health await + query embedder for the Qdrant backend
Two Qdrant-backend gaps surfaced once rag-api booted on hanzoai/vector:

1. health.py: is_health_ok() is awaited by GET /health, but the Qdrant
   branch returned a plain bool (pg/mongo branches return coroutines) ->
   "TypeError: object bool can't be used in 'await' expression", failing
   the readiness probe. Make is_health_ok async and await the async
   backends; return the sync Qdrant bool directly.

2. document_routes.py: get_cached_query_embedding read
   vector_store.embedding_function, which exists on PGVector but NOT on
   langchain QdrantVectorStore (it exposes .embeddings) ->
   "QdrantVector object has no attribute embedding_function" on every
   /query. Use the module-level `embeddings` object directly (backend-
   agnostic).

Proven live: embed(proof-doc-001)->Fireworks 768-dim->Qdrant hanzo_chat,
then query->Qdrant points/search 200 OK retrieves it at score 0.7972.
2026-06-24 05:53:49 -07:00

623 lines
20 KiB
Python

# app/routes/document_routes.py
import os
import hashlib
import traceback
import aiofiles
import aiofiles.os
from shutil import copyfileobj
from typing import List, Iterable
from fastapi import (
APIRouter,
Request,
UploadFile,
HTTPException,
File,
Form,
Body,
Query,
status,
)
from langchain_core.documents import Document
from langchain_core.runnables import run_in_executor
from langchain_text_splitters import RecursiveCharacterTextSplitter
from functools import lru_cache
from app.config import (
logger,
vector_store,
embeddings,
RAG_UPLOAD_DIR,
CHUNK_SIZE,
CHUNK_OVERLAP,
)
from app.constants import ERROR_MESSAGES
from app.models import (
StoreDocument,
QueryRequestBody,
DocumentResponse,
QueryMultipleBody,
)
from app.services.vector_store.async_pg_vector import AsyncPgVector
from app.utils.document_loader import get_loader, clean_text, process_documents
from app.utils.health import is_health_ok
router = APIRouter()
@router.get("/ids")
async def get_all_ids():
try:
if isinstance(vector_store, AsyncPgVector):
ids = await vector_store.get_all_ids()
else:
ids = vector_store.get_all_ids()
return list(set(ids))
except HTTPException as http_exc:
logger.error(
"HTTP Exception in get_all_ids | Status: %d | Detail: %s",
http_exc.status_code,
http_exc.detail,
)
raise http_exc
except Exception as e:
logger.error(
"Failed to get all IDs | Error: %s | Traceback: %s",
str(e),
traceback.format_exc(),
)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/health")
async def health_check():
try:
if await is_health_ok():
return {"status": "UP"}
else:
logger.error("Health check failed")
return {"status": "DOWN"}, 503
except Exception as e:
logger.error(
"Error during health check | Error: %s | Traceback: %s",
str(e),
traceback.format_exc(),
)
return {"status": "DOWN", "error": str(e)}, 503
@router.get("/documents", response_model=list[DocumentResponse])
async def get_documents_by_ids(ids: list[str] = Query(...)):
try:
if isinstance(vector_store, AsyncPgVector):
existing_ids = await vector_store.get_filtered_ids(ids)
documents = await vector_store.get_documents_by_ids(ids)
else:
existing_ids = vector_store.get_filtered_ids(ids)
documents = vector_store.get_documents_by_ids(ids)
# Ensure all requested ids exist
if not all(id in existing_ids for id in ids):
raise HTTPException(status_code=404, detail="One or more IDs not found")
# Ensure documents list is not empty
if not documents:
raise HTTPException(
status_code=404, detail="No documents found for the given IDs"
)
return documents
except HTTPException as http_exc:
logger.error(
"HTTP Exception in get_documents_by_ids | Status: %d | Detail: %s",
http_exc.status_code,
http_exc.detail,
)
raise http_exc
except Exception as e:
logger.error(
"Error getting documents by IDs | IDs: %s | Error: %s | Traceback: %s",
ids,
str(e),
traceback.format_exc(),
)
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/documents")
async def delete_documents(document_ids: List[str] = Body(...)):
try:
if isinstance(vector_store, AsyncPgVector):
existing_ids = await vector_store.get_filtered_ids(document_ids)
await vector_store.delete(ids=document_ids)
else:
existing_ids = vector_store.get_filtered_ids(document_ids)
vector_store.delete(ids=document_ids)
if not all(id in existing_ids for id in document_ids):
raise HTTPException(status_code=404, detail="One or more IDs not found")
file_count = len(document_ids)
return {
"message": f"Documents for {file_count} file{'s' if file_count > 1 else ''} deleted successfully"
}
except HTTPException as http_exc:
logger.error(
"HTTP Exception in delete_documents | Status: %d | Detail: %s",
http_exc.status_code,
http_exc.detail,
)
raise http_exc
except Exception as e:
logger.error(
"Failed to delete documents | IDs: %s | Error: %s | Traceback: %s",
document_ids,
str(e),
traceback.format_exc(),
)
raise HTTPException(status_code=500, detail=str(e))
# Cache the embedding function with LRU cache.
# Use the module-level `embeddings` object (the configured embedder) directly
# rather than reaching into the vector store: pgvector's PGVector exposes
# `.embedding_function`, but langchain's QdrantVectorStore does not — it exposes
# `.embeddings`. Going through the shared `embeddings` instance is backend-
# agnostic and avoids that attribute mismatch.
@lru_cache(maxsize=128)
def get_cached_query_embedding(query: str):
return embeddings.embed_query(query)
@router.post("/query")
async def query_embeddings_by_file_id(
body: QueryRequestBody,
request: Request,
):
if not hasattr(request.state, "user"):
user_authorized = body.entity_id if body.entity_id else "public"
else:
user_authorized = (
body.entity_id if body.entity_id else request.state.user.get("id")
)
authorized_documents = []
try:
embedding = get_cached_query_embedding(body.query)
if isinstance(vector_store, AsyncPgVector):
documents = await run_in_executor(
None,
vector_store.similarity_search_with_score_by_vector,
embedding,
k=body.k,
filter={"file_id": body.file_id},
)
else:
documents = vector_store.similarity_search_with_score_by_vector(
embedding, k=body.k, filter={"file_id": body.file_id}
)
if not documents:
return authorized_documents
document, score = documents[0]
doc_metadata = document.metadata
doc_user_id = doc_metadata.get("user_id")
if doc_user_id is None or doc_user_id == user_authorized:
authorized_documents = documents
else:
# If using entity_id and access denied, try again with user's actual ID
if body.entity_id and hasattr(request.state, "user"):
user_authorized = request.state.user.get("id")
if doc_user_id == user_authorized:
authorized_documents = documents
else:
if body.entity_id == doc_user_id:
logger.warning(
f"Entity ID {body.entity_id} matches document user_id but user {user_authorized} is not authorized"
)
else:
logger.warning(
f"Access denied for both entity ID {body.entity_id} and user {user_authorized} to document with user_id {doc_user_id}"
)
else:
logger.warning(
f"Unauthorized access attempt by user {user_authorized} to a document with user_id {doc_user_id}"
)
return authorized_documents
except HTTPException as http_exc:
logger.error(
"HTTP Exception in query_embeddings_by_file_id | Status: %d | Detail: %s",
http_exc.status_code,
http_exc.detail,
)
raise http_exc
except Exception as e:
logger.error(
"Error in query embeddings | File ID: %s | Query: %s | Error: %s | Traceback: %s",
body.file_id,
body.query,
str(e),
traceback.format_exc(),
)
raise HTTPException(status_code=500, detail=str(e))
def generate_digest(page_content: str):
hash_obj = hashlib.md5(page_content.encode())
return hash_obj.hexdigest()
async def store_data_in_vector_db(
data: Iterable[Document],
file_id: str,
user_id: str = "",
clean_content: bool = False,
) -> bool:
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=CHUNK_SIZE, chunk_overlap=CHUNK_OVERLAP
)
documents = text_splitter.split_documents(data)
# If `clean_content` is True, clean the page_content of each document (remove null bytes)
if clean_content:
for doc in documents:
doc.page_content = clean_text(doc.page_content)
# Preparing documents with page content and metadata for insertion.
docs = [
Document(
page_content=doc.page_content,
metadata={
"file_id": file_id,
"user_id": user_id,
"digest": generate_digest(doc.page_content),
**(doc.metadata or {}),
},
)
for doc in documents
]
try:
if isinstance(vector_store, AsyncPgVector):
ids = await vector_store.aadd_documents(
docs, ids=[file_id] * len(documents)
)
else:
ids = vector_store.add_documents(docs, ids=[file_id] * len(documents))
return {"message": "Documents added successfully", "ids": ids}
except Exception as e:
logger.error(
"Failed to store data in vector DB | File ID: %s | User ID: %s | Error: %s | Traceback: %s",
file_id,
user_id,
str(e),
traceback.format_exc(),
)
return {"message": "An error occurred while adding documents.", "error": str(e)}
@router.post("/local/embed")
async def embed_local_file(
document: StoreDocument, request: Request, entity_id: str = None
):
# Check if the file exists
if not os.path.exists(document.filepath):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.FILE_NOT_FOUND,
)
if not hasattr(request.state, "user"):
user_id = entity_id if entity_id else "public"
else:
user_id = entity_id if entity_id else request.state.user.get("id")
try:
loader, known_type, file_ext = get_loader(
document.filename, document.file_content_type, document.filepath
)
data = await run_in_executor(None, loader.load)
result = await store_data_in_vector_db(
data, document.file_id, user_id, clean_content=file_ext == "pdf"
)
if result:
return {
"status": True,
"file_id": document.file_id,
"filename": document.filename,
"known_type": known_type,
}
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=ERROR_MESSAGES.DEFAULT(),
)
except HTTPException as http_exc:
logger.error(
"HTTP Exception in embed_local_file | Status: %d | Detail: %s",
http_exc.status_code,
http_exc.detail,
)
raise http_exc
except Exception as e:
logger.error(e)
if "No pandoc was found" in str(e):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.PANDOC_NOT_INSTALLED,
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(e),
)
@router.post("/embed")
async def embed_file(
request: Request,
file_id: str = Form(...),
file: UploadFile = File(...),
entity_id: str = Form(None),
):
response_status = True
response_message = "File processed successfully."
known_type = None
if not hasattr(request.state, "user"):
user_id = entity_id if entity_id else "public"
else:
user_id = entity_id if entity_id else request.state.user.get("id")
temp_base_path = os.path.join(RAG_UPLOAD_DIR, user_id)
os.makedirs(temp_base_path, exist_ok=True)
temp_file_path = os.path.join(RAG_UPLOAD_DIR, user_id, file.filename)
try:
async with aiofiles.open(temp_file_path, "wb") as temp_file:
chunk_size = 64 * 1024 # 64 KB
while content := await file.read(chunk_size):
await temp_file.write(content)
except Exception as e:
logger.error(
"Failed to save uploaded file | Path: %s | Error: %s | Traceback: %s",
temp_file_path,
str(e),
traceback.format_exc(),
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to save the uploaded file. Error: {str(e)}",
)
try:
loader, known_type, file_ext = get_loader(
file.filename, file.content_type, temp_file_path
)
data = await run_in_executor(None, loader.load)
result = await store_data_in_vector_db(
data=data, file_id=file_id, user_id=user_id, clean_content=file_ext == "pdf"
)
if not result:
response_status = False
response_message = "Failed to process/store the file data."
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to process/store the file data.",
)
elif "error" in result:
response_status = False
response_message = "Failed to process/store the file data."
if isinstance(result["error"], str):
response_message = result["error"]
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An unspecified error occurred.",
)
except HTTPException as http_exc:
response_status = False
response_message = f"HTTP Exception: {http_exc.detail}"
logger.error(
"HTTP Exception in embed_file | Status: %d | Detail: %s",
http_exc.status_code,
http_exc.detail,
)
raise http_exc
except Exception as e:
response_status = False
response_message = f"Error during file processing: {str(e)}"
logger.error(
"Error during file processing: %s\nTraceback: %s",
str(e),
traceback.format_exc(),
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Error during file processing: {str(e)}",
)
finally:
try:
await aiofiles.os.remove(temp_file_path)
except Exception as e:
logger.error(
"Failed to remove temporary file | Path: %s | Error: %s | Traceback: %s",
temp_file_path,
str(e),
traceback.format_exc(),
)
return {
"status": response_status,
"message": response_message,
"file_id": file_id,
"filename": file.filename,
"known_type": known_type,
}
@router.get("/documents/{id}/context")
async def load_document_context(id: str):
ids = [id]
try:
if isinstance(vector_store, AsyncPgVector):
existing_ids = await vector_store.get_filtered_ids(ids)
documents = await vector_store.get_documents_by_ids(ids)
else:
existing_ids = vector_store.get_filtered_ids(ids)
documents = vector_store.get_documents_by_ids(ids)
# Ensure the requested id exists
if not all(id in existing_ids for id in ids):
raise HTTPException(
status_code=404, detail="The specified file_id was not found"
)
# Ensure documents list is not empty
if not documents:
raise HTTPException(
status_code=404, detail="No document found for the given ID"
)
return process_documents(documents)
except HTTPException as http_exc:
logger.error(
"HTTP Exception in load_document_context | Status: %d | Detail: %s",
http_exc.status_code,
http_exc.detail,
)
raise http_exc
except Exception as e:
logger.error(
"Error loading document context | Document ID: %s | Error: %s | Traceback: %s",
id,
str(e),
traceback.format_exc(),
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(e),
)
@router.post("/embed-upload")
async def embed_file_upload(
request: Request,
file_id: str = Form(...),
uploaded_file: UploadFile = File(...),
entity_id: str = Form(None),
):
temp_file_path = os.path.join(RAG_UPLOAD_DIR, uploaded_file.filename)
if not hasattr(request.state, "user"):
user_id = entity_id if entity_id else "public"
else:
user_id = entity_id if entity_id else request.state.user.get("id")
try:
with open(temp_file_path, "wb") as temp_file:
copyfileobj(uploaded_file.file, temp_file)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to save the uploaded file. Error: {str(e)}",
)
try:
loader, known_type, file_ext = get_loader(
uploaded_file.filename, uploaded_file.content_type, temp_file_path
)
data = await run_in_executor(None, loader.load)
result = await store_data_in_vector_db(
data, file_id, user_id, clean_content=file_ext == "pdf"
)
if not result:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to process/store the file data.",
)
except HTTPException as http_exc:
logger.error(
"HTTP Exception in embed_file_upload | Status: %d | Detail: %s",
http_exc.status_code,
http_exc.detail,
)
raise http_exc
except Exception as e:
logger.error(
"Error during file processing | File: %s | Error: %s | Traceback: %s",
uploaded_file.filename,
str(e),
traceback.format_exc(),
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Error during file processing: {str(e)}",
)
finally:
os.remove(temp_file_path)
return {
"status": True,
"message": "File processed successfully.",
"file_id": file_id,
"filename": uploaded_file.filename,
"known_type": known_type,
}
@router.post("/query_multiple")
async def query_embeddings_by_file_ids(body: QueryMultipleBody):
try:
# Get the embedding of the query text
embedding = get_cached_query_embedding(body.query)
# Perform similarity search with the query embedding and filter by the file_ids in metadata
if isinstance(vector_store, AsyncPgVector):
documents = await run_in_executor(
None,
vector_store.similarity_search_with_score_by_vector,
embedding,
k=body.k,
filter={"file_id": {"$in": body.file_ids}},
)
else:
documents = vector_store.similarity_search_with_score_by_vector(
embedding, k=body.k, filter={"file_id": {"$in": body.file_ids}}
)
# Ensure documents list is not empty
if not documents:
raise HTTPException(
status_code=404, detail="No documents found for the given query"
)
return documents
except HTTPException as http_exc:
logger.error(
"HTTP Exception in query_embeddings_by_file_ids | Status: %d | Detail: %s",
http_exc.status_code,
http_exc.detail,
)
raise http_exc
except Exception as e:
logger.error(
"Error in query multiple embeddings | File IDs: %s | Query: %s | Error: %s | Traceback: %s",
body.file_ids,
body.query,
str(e),
traceback.format_exc(),
)
raise HTTPException(status_code=500, detail=str(e))