Coverage for src/qdrant_loader/core/pipeline/workers/embedding_worker.py: 97%

101 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-20 10:15 +0000

1"""Embedding worker for processing chunks into embeddings.""" 

2 

3import asyncio 

4import gc 

5from collections.abc import AsyncIterator 

6from typing import Any 

7 

8import psutil 

9 

10from qdrant_loader.core.embedding.embedding_service import EmbeddingService 

11from qdrant_loader.core.monitoring import prometheus_metrics 

12from qdrant_loader.utils.logging import LoggingConfig 

13 

14from .base_worker import BaseWorker 

15 

16logger = LoggingConfig.get_logger(__name__) 

17 

18 

19class EmbeddingWorker(BaseWorker): 

20 """Handles chunk embedding with batching.""" 

21 

22 def __init__( 

23 self, 

24 embedding_service: EmbeddingService, 

25 max_workers: int = 4, 

26 queue_size: int = 1000, 

27 shutdown_event: asyncio.Event | None = None, 

28 ): 

29 super().__init__(max_workers, queue_size) 

30 self.embedding_service = embedding_service 

31 self.shutdown_event = shutdown_event or asyncio.Event() 

32 

33 async def process(self, chunks: list[Any]) -> list[tuple[Any, list[float] | None]]: 

34 """Process a batch of chunks into embeddings. 

35 

36 The result is aligned 1:1 with ``chunks``: a chunk whose embedding 

37 came back empty (invalid content was skipped by ``get_embeddings``) 

38 is still included, paired with ``None``, so callers can account for 

39 every chunk's fate instead of the failure silently disappearing. 

40 

41 Args: 

42 chunks: List of chunks to embed 

43 

44 Returns: 

45 List of (chunk, embedding) tuples; embedding is None on failure. 

46 """ 

47 if not chunks: 

48 return [] 

49 

50 try: 

51 logger.debug(f"EmbeddingWorker processing batch of {len(chunks)} items") 

52 

53 # Monitor memory usage 

54 memory_percent = psutil.virtual_memory().percent 

55 if memory_percent > 85: 

56 logger.warning( 

57 f"High memory usage detected: {memory_percent}%. Running garbage collection..." 

58 ) 

59 gc.collect() 

60 

61 with prometheus_metrics.EMBEDDING_DURATION.time(): 

62 # Add timeout to prevent hanging and check for shutdown 

63 embeddings = await asyncio.wait_for( 

64 self.embedding_service.get_embeddings([c.content for c in chunks]), 

65 timeout=300.0, # Increased to 5 minute timeout for large batches 

66 ) 

67 

68 # Check for shutdown before returning 

69 if self.shutdown_event.is_set(): 

70 logger.debug("EmbeddingWorker skipping result due to shutdown") 

71 return [] 

72 

73 result: list[tuple[Any, list[float] | None]] = [ 

74 (chunk, emb if emb else None) 

75 for chunk, emb in zip(chunks, embeddings, strict=False) 

76 ] 

77 skipped = sum(1 for _, emb in result if emb is None) 

78 if skipped: 

79 logger.warning( 

80 f"Skipped {skipped} chunk(s) with empty embeddings, they will not be upserted" 

81 ) 

82 logger.debug(f"EmbeddingWorker completed batch of {len(chunks)} items") 

83 

84 # Cleanup after large batches 

85 if len(chunks) > 50: 

86 gc.collect() 

87 

88 return result 

89 

90 except TimeoutError: 

91 logger.error( 

92 f"EmbeddingWorker timed out processing batch of {len(chunks)} items" 

93 ) 

94 raise 

95 except Exception as e: 

96 logger.error(f"EmbeddingWorker error processing batch: {e}") 

97 raise 

98 

99 async def _process_batch_guarded( 

100 self, batch: list[Any], batch_index: int 

101 ) -> list[tuple[Any, list[float] | None]]: 

102 """Run process() for a batch under the shared concurrency semaphore. 

103 

104 Bounds how many embedding batches run concurrently to ``max_workers`` 

105 so that ``max_embed_workers`` actually governs concurrency, instead of 

106 every batch running to completion before the next one starts. 

107 

108 If the whole batch raises (timeout, service error), every chunk in it 

109 is still returned, paired with ``None``, instead of being dropped — 

110 otherwise those chunks vanish from accounting entirely and their 

111 parent documents can end up looking untouched rather than failed. 

112 """ 

113 async with self.semaphore: 

114 try: 

115 logger.debug( 

116 f"🔄 Processing embedding batch {batch_index} " 

117 f"with {len(batch)} chunks..." 

118 ) 

119 return await self.process(batch) 

120 except Exception as e: 

121 logger.error(f"EmbeddingWorker batch processing failed: {e}") 

122 for chunk in batch: 

123 logger.error(f"Embedding failed for chunk {chunk.id}: {e}") 

124 return [(chunk, None) for chunk in batch] 

125 

126 async def process_chunks( 

127 self, chunks: AsyncIterator[Any] 

128 ) -> AsyncIterator[tuple[Any, list[float] | None]]: 

129 """Process chunks into embeddings. 

130 

131 Batches are dispatched concurrently, but the number of batches 

132 dispatched-and-not-yet-yielded is capped at ``max_workers``: once that 

133 many are in flight, the oldest is awaited before a new one is created. 

134 This bounds memory use (not just execution concurrency) when chunks 

135 arrive far faster than embedding calls complete, while still yielding 

136 results in submission order. Every dispatched chunk is yielded exactly 

137 once, paired with ``None`` if it failed to embed, so downstream stages 

138 can account for it instead of it silently vanishing. 

139 

140 Args: 

141 chunks: AsyncIterator of chunks to process 

142 

143 Yields: 

144 (chunk, embedding) tuples; embedding is None on failure. 

145 """ 

146 logger.debug("EmbeddingWorker started") 

147 logger.info( 

148 f"🔄 Starting embedding generation (max_workers={self.max_workers})..." 

149 ) 

150 batch_size = self.embedding_service.batch_size 

151 batch: list[Any] = [] 

152 pending: list[asyncio.Task] = [] 

153 batch_index = 0 

154 total_processed = 0 

155 total_failed = 0 

156 

157 async def drain_oldest() -> list[tuple[Any, list[float] | None]]: 

158 nonlocal total_processed, total_failed 

159 results = await pending.pop(0) 

160 if not results: 

161 return [] 

162 

163 succeeded = sum(1 for _, embedding in results if embedding is not None) 

164 failed = len(results) - succeeded 

165 total_processed += succeeded 

166 total_failed += failed 

167 logger.info( 

168 f"🔗 Generated embeddings: {succeeded} items in batch" 

169 + (f" ({failed} failed)" if failed else "") 

170 + f", {total_processed} total processed" 

171 ) 

172 return results 

173 

174 def dispatch(batch_to_dispatch: list[Any]) -> None: 

175 nonlocal batch_index 

176 batch_index += 1 

177 pending.append( 

178 asyncio.create_task( 

179 self._process_batch_guarded(batch_to_dispatch, batch_index) 

180 ) 

181 ) 

182 

183 try: 

184 async for chunk in chunks: 

185 if self.shutdown_event.is_set(): 

186 logger.debug("EmbeddingWorker exiting due to shutdown") 

187 break 

188 

189 batch.append(chunk) 

190 

191 # Dispatch batch when it reaches the desired size 

192 if len(batch) >= batch_size: 

193 batch_to_submit = batch 

194 batch = [] 

195 

196 # Keep at most max_workers batches in flight so memory 

197 # use stays bounded, not just execution concurrency. 

198 if len(pending) >= self.max_workers: 

199 for result in await drain_oldest(): 

200 yield result 

201 

202 dispatch(batch_to_submit) 

203 

204 # Dispatch any remaining chunks in the final batch 

205 if batch and not self.shutdown_event.is_set(): 

206 dispatch(batch) 

207 

208 while pending: 

209 for result in await drain_oldest(): 

210 yield result 

211 

212 logger.info( 

213 f"✅ Embedding completed: {total_processed} chunks processed, " 

214 f"{total_failed} failed" 

215 ) 

216 

217 except asyncio.CancelledError: 

218 logger.debug("EmbeddingWorker cancelled") 

219 for task in pending: 

220 if not task.done(): 

221 task.cancel() 

222 raise 

223 finally: 

224 logger.debug("EmbeddingWorker exited")