Skip to content

Commit e4d3c4a

Browse files
committed
feat: improve large file handling by increasing the chunk size and using batch processing
1 parent 4ce0286 commit e4d3c4a

2 files changed

Lines changed: 109 additions & 16 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,3 +210,7 @@ design_docs/
210210

211211
# Model cache directory
212212
model_cache/
213+
214+
models/
215+
216+
*.lock

app/worker.py

Lines changed: 105 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ def get_embedding_model():
3838
celery.config_from_object(settings, namespace='CELERY')
3939

4040
TEXT_SPLITTER = RecursiveCharacterTextSplitter(
41-
chunk_size=500,
42-
chunk_overlap=50,
41+
chunk_size=2000,
42+
chunk_overlap=200,
4343
length_function=len,
4444
)
4545

@@ -82,6 +82,8 @@ def dispatch_processing_task(document_id: str):
8282
def unpack_zip_task(document_id: str):
8383
"""
8484
The specialist for unpacking zip files.
85+
Extracts files to a persistent temporary location and passes file paths to ingest tasks
86+
to avoid serializing large content in Celery messages.
8587
"""
8688
logger.info(f"[UNPACKER] Received job for document_id: {document_id}. Starting to unpack...")
8789

@@ -99,40 +101,54 @@ def unpack_zip_task(document_id: str):
99101
db.commit()
100102

101103
file_path = f"./uploads/{document.id}.zip"
104+
105+
106+
temp_extract_dir = f"./uploads/temp_{document_id}"
107+
os.makedirs(temp_extract_dir, exist_ok=True)
108+
102109
ingest_tasks = []
103110
try:
104-
with tempfile.TemporaryDirectory() as temp_dir:
105-
with zipfile.ZipFile(file_path, 'r') as zip_ref:
106-
zip_ref.extractall(temp_dir)
107-
108-
logger.info(f"[UNPACKER] Fanning out tasks for files in {document.filename}")
109-
for filename in os.listdir(temp_dir):
110-
if filename.endswith('.md'):
111-
full_path = os.path.join(temp_dir, filename)
112-
with open(full_path, 'r', encoding='utf-8') as f:
113-
content = f.read()
114-
115-
ingest_tasks.append(ingest_file_task.s(document_id, content, filename))
111+
with zipfile.ZipFile(file_path, 'r') as zip_ref:
112+
zip_ref.extractall(temp_extract_dir)
113+
114+
logger.info(f"[UNPACKER] Fanning out tasks for files in {document.filename}")
115+
for filename in os.listdir(temp_extract_dir):
116+
if filename.endswith('.md'):
117+
full_path = os.path.join(temp_extract_dir, filename)
118+
119+
ingest_tasks.append(ingest_file_from_path_task.s(document_id, full_path, filename))
120+
121+
logger.info(f"[UNPACKER] Created {len(ingest_tasks)} ingest tasks for files in temp directory: {temp_extract_dir}")
122+
116123
except Exception as e:
117124
logger.exception("[UNPACKER] Failed to unpack or prepare tasks.")
118125
doc_repo.update_status_sync(document, IngestionStatus.FAILED, str(e))
126+
127+
if os.path.exists(temp_extract_dir):
128+
import shutil
129+
shutil.rmtree(temp_extract_dir, ignore_errors=True)
119130
return
120131

121132
if not ingest_tasks:
122133
logger.warning(f"[UNPACKER] No .md files found in {document.filename}. Marking as complete.")
123134
doc_repo.update_status_sync(document, IngestionStatus.COMPLETED)
135+
136+
if os.path.exists(temp_extract_dir):
137+
import shutil
138+
shutil.rmtree(temp_extract_dir, ignore_errors=True)
124139
return
125140

126-
callback = finalize_processing_task.s(document_id = document_id)
141+
callback = finalize_processing_task.s(document_id=document_id, temp_dir=temp_extract_dir)
127142
chord(ingest_tasks)(callback)
128143

129144
logger.info(f"[UNPACKER] Dispatched {len(ingest_tasks)} ingest jobs with a finalization callback.")
130145

131146
@celery.task
132-
def finalize_processing_task(results: list, document_id: str):
147+
def finalize_processing_task(results: list, document_id: str, temp_dir: str = None):
133148
"""
134149
The callback task that inspects the results of all ingest tasks
135150
and sets the final status for the parent Document.
151+
Also cleans up the temporary directory used for extracted files.
136152
"""
137153
logger.info(f"[FINALIZER] All ingest jobs finished for document {document_id}. Analyzing results...")
138154

@@ -157,6 +173,79 @@ def finalize_processing_task(results: list, document_id: str):
157173
else:
158174
logger.info(f"[FINALIZER] All {len(results)} files ingested successfully for document {document_id}. Setting status to COMPLETED.")
159175
repo.update_status_sync(document, IngestionStatus.COMPLETED)
176+
177+
if temp_dir and os.path.exists(temp_dir):
178+
try:
179+
import shutil
180+
shutil.rmtree(temp_dir, ignore_errors=True)
181+
logger.info(f"[FINALIZER] Cleaned up temporary directory: {temp_dir}")
182+
except Exception as e:
183+
logger.warning(f"[FINALIZER] Failed to clean up temp directory {temp_dir}: {e}")
184+
185+
@celery.task
186+
def ingest_file_from_path_task(document_id: str, file_path: str, original_filename: str):
187+
"""
188+
The specialist for ingesting a single file from a file path.
189+
Reads content from disk to avoid large message serialization.
190+
Uses batched processing to prevent out-of-memory errors.
191+
"""
192+
logger.info(f"[INGESTOR] Ingesting file from '{file_path}' (original: '{original_filename}') for document {document_id}.")
193+
194+
BATCH_SIZE = 500
195+
196+
try:
197+
if not os.path.exists(file_path):
198+
raise FileNotFoundError(f"File not found: {file_path}")
199+
200+
with open(file_path, 'r', encoding='utf-8') as f:
201+
text = f.read()
202+
203+
chunks_text = TEXT_SPLITTER.split_text(text)
204+
total_chunks = len(chunks_text)
205+
logger.info(f"[INGESTOR] Split '{original_filename}' into {total_chunks} chunks. Processing in batches of {BATCH_SIZE}...")
206+
207+
embedding_model = get_embedding_model()
208+
total_saved = 0
209+
210+
for batch_start in range(0, total_chunks, BATCH_SIZE):
211+
batch_end = min(batch_start + BATCH_SIZE, total_chunks)
212+
batch_chunks = chunks_text[batch_start:batch_end]
213+
214+
logger.info(f"[INGESTOR] Processing batch {batch_start}-{batch_end} of {total_chunks} chunks...")
215+
216+
217+
batch_embeddings = list(embedding_model.embed(batch_chunks))
218+
219+
220+
chunks_to_create = []
221+
for i, text_chunk in enumerate(batch_chunks):
222+
chunk = Chunk(
223+
document_id=document_id,
224+
chunk_text=text_chunk,
225+
embedding=batch_embeddings[i].tolist(),
226+
chunk_metadata={
227+
"source_filename": original_filename,
228+
"chunk_index": batch_start + i
229+
}
230+
)
231+
chunks_to_create.append(chunk)
232+
233+
234+
with get_sync_db() as db:
235+
db.bulk_save_objects(chunks_to_create)
236+
db.commit()
237+
238+
total_saved += len(chunks_to_create)
239+
logger.info(f"[INGESTOR] Saved batch to database. Progress: {total_saved}/{total_chunks} chunks ({total_saved*100//total_chunks}%)")
240+
241+
logger.info(f"[INGESTOR] Successfully saved all {total_saved} chunks to the database.")
242+
243+
return {"status": "SUCCESS", "filename": original_filename}
244+
245+
except Exception as e:
246+
logger.exception(f"[INGESTOR] Failed to ingest file '{original_filename}' from path '{file_path}'.", exc_info=True)
247+
248+
return {"status": "FAILED", "filename": original_filename, "error": str(e)}
160249

161250
@celery.task
162251
def ingest_file_task(document_id: str, file_content: str, original_filename: str):

0 commit comments

Comments
 (0)