Coverage for src/local_deep_research/web_search_engines/engines/local_embedding_manager.py: 100%

76 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +0000

1import threading 

2from typing import Any, Dict, Optional 

3 

4 

5from ...database.models.library import DocumentChunk 

6from ...database.session_context import get_user_db_session 

7from ...constants import ( 

8 DEFAULT_LOCAL_SEARCH_MODEL, 

9 DEFAULT_LOCAL_SEARCH_PROVIDER, 

10) 

11from ...security.log_sanitizer import scrub_error 

12from ...security.secure_logging import logger 

13from ...utilities.url_utils import normalize_url 

14 

15 

16class LocalEmbeddingManager: 

17 """Handles embedding generation and storage for local document search""" 

18 

19 def __init__( 

20 self, 

21 embedding_model: str = DEFAULT_LOCAL_SEARCH_MODEL, 

22 embedding_device: str = "cpu", 

23 embedding_model_type: str = DEFAULT_LOCAL_SEARCH_PROVIDER, # or 'ollama' 

24 ollama_base_url: Optional[str] = None, 

25 settings_snapshot: Optional[Dict[str, Any]] = None, 

26 ): 

27 """ 

28 Initialize the embedding manager for local document search. 

29 

30 Args: 

31 embedding_model: Name of the embedding model to use 

32 embedding_device: Device to run embeddings on ('cpu' or 'cuda') 

33 embedding_model_type: Type of embedding model ('sentence_transformers' or 'ollama') 

34 ollama_base_url: Base URL for Ollama API if using ollama embeddings 

35 settings_snapshot: Optional settings snapshot for background threads 

36 """ 

37 

38 self.embedding_model = embedding_model 

39 self.embedding_device = embedding_device 

40 self.embedding_model_type = embedding_model_type 

41 self.ollama_base_url = ollama_base_url 

42 self.settings_snapshot = settings_snapshot or {} 

43 

44 # Username for database access (extracted from settings if available) 

45 self.username = ( 

46 settings_snapshot.get("_username") if settings_snapshot else None 

47 ) 

48 # Password for encrypted database access (can be set later) 

49 self.db_password = None 

50 

51 # Initialize the embedding model (with lock for thread-safe lazy init) 

52 self._embeddings = None 

53 self._embedding_lock = threading.Lock() 

54 

55 # Track if this manager has been closed 

56 self._closed = False 

57 

58 def close(self): 

59 """Release embedding model resources. 

60 

61 For Ollama embeddings, this also closes the underlying per-instance 

62 ``httpx.Client`` / ``httpx.AsyncClient`` pair. langchain_ollama's 

63 ``OllamaEmbeddings`` eagerly constructs both clients in its Pydantic 

64 ``@model_validator(mode="after")``, so dropping the Python reference 

65 alone leaks ~2 FDs per instance — see the migration regression note 

66 in docs/developing/resource-cleanup.md. Non-Ollama providers 

67 (sentence_transformers, OpenAI's lru_cache'd shared client) are 

68 no-ops via the module-prefix check inside ``_close_base_llm``. 

69 """ 

70 if self._closed: 

71 return 

72 self._closed = True 

73 if self._embeddings is not None: 

74 from ...utilities.llm_utils import _close_base_llm 

75 

76 _close_base_llm(self._embeddings) 

77 self._embeddings = None 

78 logger.debug("LocalEmbeddingManager closed") 

79 

80 def __enter__(self): 

81 """Context manager entry.""" 

82 return self 

83 

84 def __exit__(self, exc_type, exc_val, exc_tb): 

85 """Context manager exit - ensures resources are released.""" 

86 self.close() 

87 return False 

88 

89 @property 

90 def embeddings(self): 

91 """ 

92 Lazily initialize embeddings when first accessed. 

93 This allows the LocalEmbeddingManager to be created without 

94 immediately loading models, which is helpful when no local search is performed. 

95 

96 Uses double-checked locking to ensure thread-safe initialization. 

97 Concurrent SentenceTransformer model loading causes meta tensor errors 

98 in PyTorch when multiple threads call model.to(device) simultaneously. 

99 """ 

100 if self._embeddings is None: 

101 with self._embedding_lock: 

102 if self._embeddings is None: 

103 logger.info("Initializing embeddings on first use") 

104 self._embeddings = self._initialize_embeddings() 

105 return self._embeddings 

106 

107 def _initialize_embeddings(self): 

108 """Initialize the embedding model based on configuration""" 

109 try: 

110 # Use the new unified embedding system 

111 from ...embeddings import get_embeddings 

112 

113 # Prepare kwargs for provider-specific parameters 

114 kwargs = {} 

115 

116 # Add device for sentence transformers 

117 if self.embedding_model_type == "sentence_transformers": 

118 kwargs["device"] = self.embedding_device 

119 

120 # Add base_url for ollama if specified 

121 if self.embedding_model_type == "ollama" and self.ollama_base_url: 

122 kwargs["base_url"] = normalize_url(self.ollama_base_url) 

123 

124 logger.info( 

125 f"Initializing embeddings with provider={self.embedding_model_type}, model={self.embedding_model}" 

126 ) 

127 

128 return get_embeddings( 

129 provider=self.embedding_model_type, 

130 model=self.embedding_model, 

131 settings_snapshot=self.settings_snapshot, 

132 **kwargs, 

133 ) 

134 except ImportError as exc: 

135 # Only fall back when the configured provider's dependency 

136 # genuinely isn't installed — that's a deployment shape, not 

137 # a transient runtime error. Any OTHER exception (Ollama 

138 # DNS hiccup, provider validation, policy denial) must 

139 # propagate so we don't silently fetch from huggingface.co 

140 # when the user has explicitly opted into local embeddings. 

141 safe_msg = scrub_error(exc) 

142 logger.exception( 

143 f"Embedding provider import failed ({type(exc).__name__}) — falling back to local SBERT: {safe_msg}" 

144 ) 

145 # Route the fallback through get_embeddings(sentence_transformers) 

146 # rather than constructing HuggingFaceEmbeddings directly: the SBERT 

147 # model is fetched from huggingface.co on a cache miss, which the 

148 # provider gate refuses under PRIVATE_ONLY / embeddings.require_local. 

149 # Constructing it raw here would bypass that gate and leak an 

150 # outbound HF download in offline mode. PolicyDeniedError from the 

151 # gate propagates (fail closed). 

152 from ...embeddings import get_embeddings 

153 

154 return get_embeddings( 

155 provider="sentence_transformers", 

156 model=None, # provider default (all-MiniLM-L6-v2) 

157 settings_snapshot=self.settings_snapshot, 

158 ) 

159 

160 # NOTE(cutover): the old ``_store_chunks_to_db`` (LangChain-Document-list 

161 # -> DocumentChunk rows, with chunk-hash+collection dedup/reuse for a 

162 # FAISS uuid-keyed docstore) was deleted here. Its caller 

163 # ``LibraryRAGService.index_document`` now writes chunk rows via 

164 # ``vector_stores.facade.VectorIndex.index`` instead (int-id keyed, 

165 # one row per source's chunk, no cross-source hash dedup/reuse; see 

166 # the "One row = one vector" note in vector_stores/facade.py). Grep 

167 # confirmed no other production callers before deletion. 

168 

169 def _delete_chunks_from_db( 

170 self, 

171 collection_name: str, 

172 source_path: Optional[str] = None, 

173 source_id: Optional[int] = None, 

174 ) -> int: 

175 """ 

176 Delete chunks from database. 

177 

178 Args: 

179 collection_name: Name of the collection 

180 source_path: Path to source file (for local files) 

181 source_id: ID of source document (for library documents) 

182 

183 Returns: 

184 Number of chunks deleted 

185 """ 

186 if not self.username: 

187 logger.warning( 

188 "No username available, cannot delete chunks from database" 

189 ) 

190 return 0 

191 

192 try: 

193 with get_user_db_session( 

194 self.username, self.db_password 

195 ) as session: 

196 query = session.query(DocumentChunk).filter_by( 

197 collection_name=collection_name 

198 ) 

199 

200 if source_path: 

201 query = query.filter_by(source_path=str(source_path)) 

202 if source_id: 

203 query = query.filter_by(source_id=source_id) 

204 

205 count = int(query.delete()) 

206 session.commit() 

207 

208 logger.info( 

209 f"Deleted {count} chunks from database for collection '{collection_name}'" 

210 ) 

211 return count 

212 

213 except Exception as e: 

214 safe_msg = scrub_error(e, self.db_password) 

215 logger.exception( 

216 f"Error deleting chunks from database for collection '{collection_name}' ({type(e).__name__}): {safe_msg}" 

217 ) 

218 return 0