Calcifer0323 commited on
Commit
4e4150b
·
1 Parent(s): 7c31226

Simplify to stateless service - remove storage, keep only embedding generation

Browse files
Files changed (1) hide show
  1. main.py +228 -1009
main.py CHANGED
@@ -1,13 +1,22 @@
1
  """
2
  Embedding Service - FastAPI сервис для генерации эмбеддингов текста.
3
 
4
- Используется для матчинга лидов с объектами недвижимости на основе семантической близости.
 
 
 
 
 
 
 
 
 
 
5
  """
6
 
7
  import os
8
  from typing import List, Optional, Dict, Any
9
  from contextlib import asynccontextmanager
10
- from uuid import uuid4
11
 
12
  from fastapi import FastAPI, HTTPException
13
  from fastapi.middleware.cors import CORSMiddleware
@@ -19,47 +28,36 @@ from dotenv import load_dotenv
19
  load_dotenv()
20
 
21
  # Конфигурация
22
- MODEL_NAME = os.getenv("EMBEDDING_MODEL", "sentence-transformers/paraphrase-multilingual-MiniLM-L6-v2")
23
- EMBEDDING_DIMENSIONS = int(os.getenv("EMBEDDING_DIMENSIONS", "384"))
24
 
25
- # Глобальная модель (загружается при старте)
26
  model: Optional[SentenceTransformer] = None
27
 
28
- # In-memory хранилище эмбеддингов (для прототипа, в продакшене используется pgvector)
29
- # Структура: {entity_type: {entity_id: {"embedding": [...], "metadata": {...}}}}
30
- embedding_store: Dict[str, Dict[str, Dict[str, Any]]] = {
31
- "leads": {},
32
- "properties": {}
33
- }
34
-
35
 
36
  @asynccontextmanager
37
  async def lifespan(app: FastAPI):
38
- """Загрузка модели при старте приложения."""
39
  global model
40
  print(f"Loading embedding model: {MODEL_NAME}")
41
- # Оптимизация для минимального потребления памяти
42
  model = SentenceTransformer(MODEL_NAME, device='cpu')
43
- # Используем half precision для экономии памяти (если доступно)
44
  try:
45
  model.half()
46
  print("Model converted to half precision (float16)")
47
  except Exception as e:
48
  print(f"Could not convert to half precision: {e}")
49
- print(f"Model loaded successfully. Embedding dimensions: {model.get_sentence_embedding_dimension()}")
50
  yield
51
- # Cleanup
52
  model = None
53
 
54
 
55
  app = FastAPI(
56
  title="Embedding Service",
57
- description="Сервис для генерации эмбеддингов текста",
58
- version="1.0.0",
59
  lifespan=lifespan
60
  )
61
 
62
- # CORS для локальной разработки
63
  app.add_middleware(
64
  CORSMiddleware,
65
  allow_origins=["*"],
@@ -69,265 +67,144 @@ app.add_middleware(
69
  )
70
 
71
 
72
- # --- Pydantic Models ---
73
 
74
  class EmbedRequest(BaseModel):
75
- """Запрос на генерацию эмбеддинга для одного текста."""
76
- text: str = Field(..., min_length=1, description="Текст для генерации эмбеддинга")
77
 
78
 
79
  class EmbedResponse(BaseModel):
80
  """Ответ с эмбеддингом."""
81
- embedding: List[float] = Field(..., description="Векторное представление текста")
82
- model: str = Field(..., description="Название используемой модели")
83
- dimensions: int = Field(..., description="Размерность вектора")
84
-
85
-
86
- class EmbedBatchRequest(BaseModel):
87
- """Запрос на пакетную генерацию эмбеддингов."""
88
- texts: List[str] = Field(..., min_length=1, description="Список текстов")
89
-
90
-
91
- class EmbedBatchResponse(BaseModel):
92
- """Ответ с пакетными эмбеддингами."""
93
- embeddings: List[List[float]] = Field(..., description="Список векторных представлений")
94
- model: str = Field(..., description="Название исп��льзуемой модели")
95
- dimensions: int = Field(..., description="Размерность векторов")
96
-
97
-
98
- class SimilarityRequest(BaseModel):
99
- """Запрос на вычисление косинусной близости."""
100
- embedding1: List[float] = Field(..., description="Первый эмбеддинг")
101
- embedding2: List[float] = Field(..., description="Второй эмбеддинг")
102
-
103
-
104
- class SimilarityResponse(BaseModel):
105
- """Ответ с косинусной близостью."""
106
- similarity: float = Field(..., description="Косинусная близость от -1 до 1")
107
-
108
-
109
- class HealthResponse(BaseModel):
110
- """Ответ на health check."""
111
- status: str
112
- model: str
113
  dimensions: int
114
 
115
 
116
- # --- Match Models ---
117
-
118
- class MatchRequest(BaseModel):
119
- """Запрос на поиск похожих объектов по эмбеддингу."""
120
- embedding: List[float] = Field(..., description="Эмбеддинг для поиска")
121
- entity_type: str = Field(default="properties", description="Тип сущности для поиска (leads, properties)")
122
- top_k: int = Field(default=5, ge=1, le=100, description="Количество результатов")
123
- min_similarity: float = Field(default=0.0, ge=-1.0, le=1.0, description="Минимальный порог схожести")
124
-
125
-
126
- class MatchTextRequest(BaseModel):
127
- """Запрос на поиск похожих объектов по тексту."""
128
- text: str = Field(..., min_length=1, description="Текст для поиска")
129
- entity_type: str = Field(default="properties", description="Тип сущности для поиска (leads, properties)")
130
- top_k: int = Field(default=5, ge=1, le=100, description="Количество результатов")
131
- min_similarity: float = Field(default=0.0, ge=-1.0, le=1.0, description="Минимальный порог схожести")
132
-
133
-
134
- class MatchResult(BaseModel):
135
- """Результат матчинга."""
136
- entity_id: str = Field(..., description="ID найденного объекта")
137
- similarity: float = Field(..., description="Косинусная близость (0-1)")
138
- metadata: Optional[Dict[str, Any]] = Field(default=None, description="Дополнительные данные объекта")
139
-
140
-
141
- class MatchResponse(BaseModel):
142
- """Ответ с результатами матчинга."""
143
- matches: List[MatchResult] = Field(..., description="Найденные объекты")
144
- total_searched: int = Field(..., description="Количество проверенных объектов")
145
-
146
-
147
- class RegisterEmbeddingRequest(BaseModel):
148
- """Запрос на регистрацию эмбеддинга объекта."""
149
- entity_id: str = Field(..., description="ID объекта")
150
- entity_type: str = Field(..., description="Тип сущности (leads, properties)")
151
- text: str = Field(..., min_length=1, description="Текст для генерации эмбеддинга")
152
- metadata: Optional[Dict[str, Any]] = Field(default=None, description="Дополнительные данные объекта")
153
-
154
-
155
- class RegisterEmbeddingFromVectorRequest(BaseModel):
156
- """Запрос на регистрацию готового эмбеддинга."""
157
- entity_id: str = Field(..., description="ID объекта")
158
- entity_type: str = Field(..., description="Тип сущности (leads, properties)")
159
- embedding: List[float] = Field(..., description="Готовый эмбеддинг")
160
- metadata: Optional[Dict[str, Any]] = Field(default=None, description="Дополнительные данные объекта")
161
-
162
-
163
- class RegisterResponse(BaseModel):
164
- """Ответ на регистрацию эмбеддинга."""
165
- success: bool
166
- entity_id: str
167
- entity_type: str
168
-
169
-
170
- class DeleteEmbeddingRequest(BaseModel):
171
- """Запрос на удаление эмбеддинга."""
172
- entity_id: str = Field(..., description="ID объекта")
173
- entity_type: str = Field(..., description="Тип сущности (leads, properties)")
174
 
 
 
 
 
 
 
 
 
 
 
175
 
176
- class StoreStatsResponse(BaseModel):
177
- """Статистика хранилища эмбеддингов."""
178
- leads_count: int
179
- properties_count: int
180
- total_count: int
181
 
 
 
 
 
 
182
 
183
- # --- Bulk Index Models ---
184
 
185
- class BulkIndexItem(BaseModel):
186
- """Один элемент для массовой индексации."""
187
  entity_id: str = Field(..., description="ID объекта")
188
- text: str = Field(..., min_length=1, description="Текст для генерации эмбеддинга")
189
- metadata: Optional[Dict[str, Any]] = Field(default=None, description="Дополнительные данные")
 
 
 
 
 
 
190
 
191
 
192
- class BulkIndexRequest(BaseModel):
193
- """Запрос на массовую индексацию."""
194
- entity_type: str = Field(..., description="Тип сущности (leads, properties)")
195
- items: List[BulkIndexItem] = Field(..., description="Список объектов для индексации")
196
- clear_existing: bool = Field(default=False, description="Очистить существующие данные перед индексацией")
197
 
198
 
199
- class BulkIndexResult(BaseModel):
200
- """Результат индексации одного элемента."""
201
  entity_id: str
202
- success: bool
 
203
  error: Optional[str] = None
204
 
205
 
206
- class BulkIndexResponse(BaseModel):
207
- """Ответ на массовую индексацию."""
208
- total: int = Field(..., description="Всего элементов в запросе")
209
- indexed: int = Field(..., description="Успешно проиндексировано")
210
- failed: int = Field(..., description="Ошибок")
211
- results: List[BulkIndexResult] = Field(..., description="Детали по каждому элементу")
212
-
213
-
214
- class ReindexFromDBRequest(BaseModel):
215
- """Запрос на переиндексацию из внешнего источника (вызывается Go Backend)."""
216
- entity_type: str = Field(..., description="Тип сущности (leads, properties)")
217
- db_url: Optional[str] = Field(default=None, description="URL базы данных (опционально)")
218
-
219
-
220
- # --- Weighted Matching Models ---
221
-
222
- class ParameterWeights(BaseModel):
223
- """Веса для различных параметров матчинга."""
224
- price: float = Field(default=0.30, ge=0.0, le=1.0, description="Вес цены (по умолчанию 0.30)")
225
- district: float = Field(default=0.25, ge=0.0, le=1.0, description="Вес района (по умолчанию 0.25)")
226
- rooms: float = Field(default=0.20, ge=0.0, le=1.0, description="Вес количества комнат (по умолчанию 0.20)")
227
- area: float = Field(default=0.10, ge=0.0, le=1.0, description="Вес площади (по умолчанию 0.10)")
228
- semantic: float = Field(default=0.15, ge=0.0, le=1.0, description="Вес семантической близости (по умолчанию 0.15)")
229
-
230
-
231
- class PriceFilter(BaseModel):
232
- """Фильтр по цене."""
233
- min_price: Optional[float] = Field(default=None, description="Минимальная цена")
234
- max_price: Optional[float] = Field(default=None, description="Максимальная цена")
235
- tolerance_percent: float = Field(default=10.0, description="Допустимое отклонение в % (для мягкого фильтра)")
236
-
237
-
238
- class HardFilters(BaseModel):
239
- """Жёсткие фильтры (объекты не прошедшие фильтр исключаются)."""
240
- price: Optional[PriceFilter] = Field(default=None, description="Фильтр по цене")
241
- districts: Optional[List[str]] = Field(default=None, description="Список допустимых районов")
242
- rooms: Optional[List[int]] = Field(default=None, description="Список допустимого кол-ва комнат")
243
- min_area: Optional[float] = Field(default=None, description="Минимальная площадь")
244
- max_area: Optional[float] = Field(default=None, description="Максимальная площадь")
245
-
246
-
247
- class SoftCriteria(BaseModel):
248
- """Мягкие критерии для ранжирования (влияют на score, но не исключают)."""
249
- target_price: Optional[float] = Field(default=None, description="Желаемая цена")
250
- target_district: Optional[str] = Field(default=None, description="Предпочтительный район")
251
- target_rooms: Optional[int] = Field(default=None, description="Желаемое кол-во комнат")
252
- target_area: Optional[float] = Field(default=None, description="Желаемая площадь")
253
- metro_distance_km: Optional[float] = Field(default=None, description="Желаемое расстояние до метро (км)")
254
- preferred_districts: Optional[List[str]] = Field(default=None, description="Список предпочтительных районов")
255
-
256
 
257
- class WeightedMatchRequest(BaseModel):
258
- """Запрос на взвешенный матчинга с приоритетами."""
259
- text: str = Field(..., min_length=1, description="Текст запроса (описание требований)")
260
- entity_type: str = Field(default="properties", description="Тип сущности для поиска")
261
- top_k: int = Field(default=10, ge=1, le=100, description="Количество результатов")
262
 
263
- # Настройка весов
264
- weights: Optional[ParameterWeights] = Field(default=None, description="Веса параметров")
 
 
 
265
 
266
- # Фильтры
267
- hard_filters: Optional[HardFilters] = Field(default=None, description="Жёсткие фильтры")
268
- soft_criteria: Optional[SoftCriteria] = Field(default=None, description="Мягкие критерии")
269
 
270
- # Минимальный порог
271
- min_total_score: float = Field(default=0.0, ge=0.0, le=1.0, description="Минимальный общий score")
272
 
 
 
 
 
 
 
 
 
 
 
 
 
273
 
274
- class WeightedMatchResult(BaseModel):
275
- """Результат взвешенного матчинга с детализацией."""
276
- entity_id: str
277
- total_score: float = Field(..., description="Общий взвешенный score (0-1)")
278
 
279
- # Детализация по компонентам
280
- price_score: float = Field(default=0.0, description="Score по цене (0-1)")
281
- district_score: float = Field(default=0.0, description="Score по району (0-1)")
282
- rooms_score: float = Field(default=0.0, description="Score по комнатам (0-1)")
283
- area_score: float = Field(default=0.0, description="Score по площади (0-1)")
284
- semantic_score: float = Field(default=0.0, description="Семантический score (0-1)")
285
 
286
- metadata: Optional[Dict[str, Any]] = None
287
- match_explanation: Optional[str] = Field(default=None, description="Объяснение почему объект подходит")
 
 
 
 
 
 
 
 
 
288
 
 
 
289
 
290
- class WeightedMatchResponse(BaseModel):
291
- """Ответ взвешенного матчинга."""
292
- matches: List[WeightedMatchResult]
293
- total_searched: int
294
- filtered_out: int = Field(..., description="Отфильтровано жёсткими фильтрами")
295
- weights_used: ParameterWeights
296
 
297
 
298
- # --- Endpoints ---
299
 
300
  @app.get("/")
301
  async def root():
302
- """
303
- Корневая страница API.
304
-
305
- Показывает информацию о сервисе и ссылки на документацию.
306
- """
307
  return {
308
- "service": "Matching Embedding Service",
309
- "version": "1.0.0",
310
- "status": "running",
311
- "model": MODEL_NAME,
312
- "docs": "/docs",
313
- "redoc": "/redoc",
314
- "health": "/health",
315
  "endpoints": {
316
- "embedding": {
317
- "single": "POST /embed",
318
- "batch": "POST /embed-batch"
319
- },
320
- "matching": {
321
- "by_text": "POST /match-text",
322
- "by_vector": "POST /match",
323
- "weighted": "POST /match-weighted"
324
- },
325
- "management": {
326
- "register": "POST /register",
327
- "bulk_index": "POST /index/bulk",
328
- "stats": "GET /store/stats"
329
- }
330
- }
331
  }
332
 
333
 
@@ -346,822 +223,164 @@ async def health_check():
346
  @app.post("/embed", response_model=EmbedResponse)
347
  async def embed_text(request: EmbedRequest):
348
  """
349
- Генерация эмбеддинга для одного текста.
350
 
351
- Используется для получения векторного представления лида или объекта недвижимости.
352
  """
353
  if model is None:
354
  raise HTTPException(status_code=503, detail="Model not loaded")
355
 
356
- try:
357
- embedding = model.encode(request.text, convert_to_numpy=True)
358
- return EmbedResponse(
359
- embedding=embedding.tolist(),
360
- model=MODEL_NAME,
361
- dimensions=len(embedding)
362
- )
363
- except Exception as e:
364
- raise HTTPException(status_code=500, detail=f"Embedding generation failed: {str(e)}")
365
-
366
-
367
- @app.post("/embed-batch", response_model=EmbedBatchResponse)
368
- async def embed_batch(request: EmbedBatchRequest):
369
- """
370
- Пакетная генерация эмбеддингов.
371
-
372
- Эффективнее для обработки нескольких текстов за раз.
373
- """
374
- if model is None:
375
- raise HTTPException(status_code=503, detail="Model not loaded")
376
-
377
- try:
378
- embeddings = model.encode(request.texts, convert_to_numpy=True)
379
- return EmbedBatchResponse(
380
- embeddings=[emb.tolist() for emb in embeddings],
381
- model=MODEL_NAME,
382
- dimensions=embeddings.shape[1] if len(embeddings.shape) > 1 else len(embeddings)
383
- )
384
- except Exception as e:
385
- raise HTTPException(status_code=500, detail=f"Batch embedding generation failed: {str(e)}")
386
-
387
-
388
- @app.post("/similarity", response_model=SimilarityResponse)
389
- async def compute_similarity(request: SimilarityRequest):
390
- """
391
- Вычисление косинусной близости между двумя эмбеддингами.
392
-
393
- Возвращает значение от -1 (противоположные) до 1 (идентичные).
394
- """
395
- if len(request.embedding1) != len(request.embedding2):
396
- raise HTTPException(
397
- status_code=400,
398
- detail="Embeddings must have the same dimensions"
399
- )
400
-
401
- try:
402
- vec1 = np.array(request.embedding1)
403
- vec2 = np.array(request.embedding2)
404
-
405
- # Косинусная близость
406
- similarity = np.dot(vec1, vec2) / (np.linalg.norm(vec1) * np.linalg.norm(vec2))
407
-
408
- return SimilarityResponse(similarity=float(similarity))
409
- except Exception as e:
410
- raise HTTPException(status_code=500, detail=f"Similarity computation failed: {str(e)}")
411
-
412
-
413
- @app.post("/prepare-text")
414
- async def prepare_text_for_embedding(
415
- title: str = "",
416
- description: str = "",
417
- requirement: dict = None
418
- ):
419
- """
420
- Подготовка текста для генерации эмбеддинга.
421
-
422
- Объединяет title, description и requirement в один текст для эмбеддинга.
423
- """
424
- parts = []
425
-
426
- if title:
427
- parts.append(f"Название: {title}")
428
-
429
- if description:
430
- parts.append(f"Описание: {description}")
431
-
432
- if requirement:
433
- req_parts = []
434
- for key, value in requirement.items():
435
- req_parts.append(f"{key}: {value}")
436
- if req_parts:
437
- parts.append(f"Требования: {', '.join(req_parts)}")
438
-
439
- combined_text = ". ".join(parts)
440
-
441
- return {"prepared_text": combined_text}
442
-
443
-
444
- # --- Matching Endpoints ---
445
-
446
- def _cosine_similarity(vec1: np.ndarray, vec2: np.ndarray) -> float:
447
- """Вычисление косинусной близости между двумя векторами."""
448
- norm1 = np.linalg.norm(vec1)
449
- norm2 = np.linalg.norm(vec2)
450
- if norm1 == 0 or norm2 == 0:
451
- return 0.0
452
- return float(np.dot(vec1, vec2) / (norm1 * norm2))
453
-
454
-
455
- def _calculate_price_score(obj_price: Optional[float], target_price: Optional[float], tolerance_percent: float = 20.0) -> float:
456
- """
457
- Вычисление score по цене.
458
-
459
- Если цена объекта в пределах допуска от целевой - высокий score.
460
- Чем дальше - тем ниже score.
461
- """
462
- if obj_price is None or target_price is None:
463
- return 0.5 # Нейтральный score если данных нет
464
-
465
- if target_price == 0:
466
- return 0.5
467
-
468
- # Процентное отклонение
469
- deviation_percent = abs(obj_price - target_price) / target_price * 100
470
-
471
- if deviation_percent <= tolerance_percent:
472
- # В пределах допуска - линейно от 1.0 до 0.7
473
- return 1.0 - (deviation_percent / tolerance_percent) * 0.3
474
- else:
475
- # За пределами допуска - быстро падает
476
- extra_deviation = deviation_percent - tolerance_percent
477
- score = 0.7 - (extra_deviation / 100) * 0.7
478
- return max(0.0, score)
479
-
480
-
481
- def _calculate_district_score(
482
- obj_district: Optional[str],
483
- target_district: Optional[str],
484
- preferred_districts: Optional[List[str]] = None
485
- ) -> float:
486
- """
487
- Вычисление score по району.
488
-
489
- Точное совпадение = 1.0
490
- В списке предпочтительных = 0.7
491
- Иначе = 0.3
492
- """
493
- if obj_district is None:
494
- return 0.3
495
-
496
- obj_district_lower = obj_district.lower().strip()
497
-
498
- # Точное совпадение с целевым
499
- if target_district and obj_district_lower == target_district.lower().strip():
500
- return 1.0
501
-
502
- # Проверяем в списке предпочтительных
503
- if preferred_districts:
504
- for pref in preferred_districts:
505
- if obj_district_lower == pref.lower().strip():
506
- return 0.7
507
- # Частичное совпадение (например "Центральный" в "Центральный район")
508
- if pref.lower() in obj_district_lower or obj_district_lower in pref.lower():
509
- return 0.6
510
-
511
- return 0.3
512
-
513
-
514
- def _calculate_rooms_score(obj_rooms: Optional[int], target_rooms: Optional[int]) -> float:
515
- """
516
- Вычисление score по количеству комнат.
517
-
518
- Точное совпадение = 1.0
519
- ±1 комната = 0.6
520
- ±2 комнаты = 0.3
521
- Больше разницы = 0.1
522
- """
523
- if obj_rooms is None or target_rooms is None:
524
- return 0.5
525
-
526
- diff = abs(obj_rooms - target_rooms)
527
-
528
- if diff == 0:
529
- return 1.0
530
- elif diff == 1:
531
- return 0.6
532
- elif diff == 2:
533
- return 0.3
534
- else:
535
- return 0.1
536
-
537
-
538
- def _calculate_area_score(obj_area: Optional[float], target_area: Optional[float], tolerance_percent: float = 15.0) -> float:
539
- """
540
- Вычисление score по площади.
541
-
542
- Аналогично цене, но с меньшим допуском.
543
- """
544
- if obj_area is None or target_area is None:
545
- return 0.5
546
-
547
- if target_area == 0:
548
- return 0.5
549
-
550
- deviation_percent = abs(obj_area - target_area) / target_area * 100
551
-
552
- if deviation_percent <= tolerance_percent:
553
- return 1.0 - (deviation_percent / tolerance_percent) * 0.3
554
- else:
555
- extra_deviation = deviation_percent - tolerance_percent
556
- score = 0.7 - (extra_deviation / 50) * 0.7
557
- return max(0.0, score)
558
-
559
-
560
- def _passes_hard_filters(metadata: Dict[str, Any], filters: Optional[HardFilters]) -> bool:
561
- """Проверка прохождения жёстких фильтров."""
562
- if filters is None:
563
- return True
564
-
565
- # Фильтр по цене
566
- if filters.price:
567
- obj_price = metadata.get("price")
568
- if obj_price is not None:
569
- if filters.price.min_price and obj_price < filters.price.min_price:
570
- return False
571
- if filters.price.max_price and obj_price > filters.price.max_price:
572
- return False
573
-
574
- # Фильтр по районам
575
- if filters.districts:
576
- obj_district = metadata.get("district", "").lower().strip()
577
- allowed = [d.lower().strip() for d in filters.districts]
578
- if obj_district and obj_district not in allowed:
579
- # Проверяем частичное совпадение
580
- if not any(a in obj_district or obj_district in a for a in allowed):
581
- return False
582
-
583
- # Фильтр по комнатам
584
- if filters.rooms:
585
- obj_rooms = metadata.get("rooms")
586
- if obj_rooms is not None and obj_rooms not in filters.rooms:
587
- return False
588
-
589
- # Фильтр по площади
590
- obj_area = metadata.get("area")
591
- if obj_area is not None:
592
- if filters.min_area and obj_area < filters.min_area:
593
- return False
594
- if filters.max_area and obj_area > filters.max_area:
595
- return False
596
-
597
- return True
598
-
599
-
600
- def _generate_match_explanation(
601
- price_score: float,
602
- district_score: float,
603
- rooms_score: float,
604
- area_score: float,
605
- semantic_score: float,
606
- metadata: Dict[str, Any]
607
- ) -> str:
608
- """Генерация человеко-читаемого объяснения матча."""
609
- reasons = []
610
-
611
- if price_score >= 0.7:
612
- price = metadata.get("price")
613
- if price:
614
- reasons.append(f"цена {price:,.0f}₽ в бюджете")
615
-
616
- if district_score >= 0.7:
617
- district = metadata.get("district")
618
- if district:
619
- reasons.append(f"район '{district}' подходит")
620
-
621
- if rooms_score >= 0.7:
622
- rooms = metadata.get("rooms")
623
- if rooms:
624
- reasons.append(f"{rooms}-комн. как нужно")
625
-
626
- if area_score >= 0.7:
627
- area = metadata.get("area")
628
- if area:
629
- reasons.append(f"площадь {area}м² подходит")
630
-
631
- if semantic_score >= 0.6:
632
- reasons.append("описание похоже на запрос")
633
-
634
- if not reasons:
635
- return "Частичное совпадение по параметрам"
636
-
637
- return "; ".join(reasons)
638
-
639
-
640
- @app.post("/match", response_model=MatchResponse)
641
- async def match_by_embedding(request: MatchRequest):
642
- """
643
- Поиск похожих объектов по эмбеддингу.
644
-
645
- Возвращает top_k наиболее похожих объектов указанного типа.
646
- """
647
- if request.entity_type not in embedding_store:
648
- raise HTTPException(
649
- status_code=400,
650
- detail=f"Unknown entity type: {request.entity_type}. Allowed: leads, properties"
651
- )
652
-
653
- store = embedding_store[request.entity_type]
654
- if not store:
655
- return MatchResponse(matches=[], total_searched=0)
656
-
657
- query_vec = np.array(request.embedding)
658
-
659
- # Вычисляем схожесть со всеми объектами
660
- similarities = []
661
- for entity_id, data in store.items():
662
- stored_vec = np.array(data["embedding"])
663
- similarity = _cosine_similarity(query_vec, stored_vec)
664
- if similarity >= request.min_similarity:
665
- similarities.append((entity_id, similarity, data.get("metadata")))
666
-
667
- # Сортируем по убыванию схожести и берем top_k
668
- similarities.sort(key=lambda x: x[1], reverse=True)
669
- top_matches = similarities[:request.top_k]
670
-
671
- matches = [
672
- MatchResult(entity_id=eid, similarity=sim, metadata=meta)
673
- for eid, sim, meta in top_matches
674
- ]
675
-
676
- return MatchResponse(matches=matches, total_searched=len(store))
677
-
678
 
679
- @app.post("/match-text", response_model=MatchResponse)
680
- async def match_by_text(request: MatchTextRequest):
681
- """
682
- Поиск похожих объектов по тексту.
683
 
684
- Генерирует эмбеддинг для текста и ищет похожие объекты.
 
685
  """
686
- if model is None:
687
- raise HTTPException(status_code=503, detail="Model not loaded")
688
-
689
- if request.entity_type not in embedding_store:
690
- raise HTTPException(
691
- status_code=400,
692
- detail=f"Unknown entity type: {request.entity_type}. Allowed: leads, properties"
693
- )
694
-
695
- store = embedding_store[request.entity_type]
696
- if not store:
697
- return MatchResponse(matches=[], total_searched=0)
698
-
699
- try:
700
- # Генерируем эмбеддинг для текста запроса
701
- query_embedding = model.encode(request.text, convert_to_numpy=True)
702
- query_vec = np.array(query_embedding)
703
-
704
- # Вычисляем схожесть со всеми объектами
705
- similarities = []
706
- for entity_id, data in store.items():
707
- stored_vec = np.array(data["embedding"])
708
- similarity = _cosine_similarity(query_vec, stored_vec)
709
- if similarity >= request.min_similarity:
710
- similarities.append((entity_id, similarity, data.get("metadata")))
711
-
712
- # Сортируем по убыванию схожести и берем top_k
713
- similarities.sort(key=lambda x: x[1], reverse=True)
714
- top_matches = similarities[:request.top_k]
715
-
716
- matches = [
717
- MatchResult(entity_id=eid, similarity=sim, metadata=meta)
718
- for eid, sim, meta in top_matches
719
- ]
720
 
721
- return MatchResponse(matches=matches, total_searched=len(store))
722
- except Exception as e:
723
- raise HTTPException(status_code=500, detail=f"Match by text failed: {str(e)}")
724
 
 
 
 
 
 
 
 
 
 
 
725
 
726
- @app.post("/register", response_model=RegisterResponse)
727
- async def register_embedding(request: RegisterEmbeddingRequest):
728
- """
729
- Регистрация объекта с автоматической генерацией эмбеддинга.
730
-
731
- Используется для добавления лидов или объектов недвижимости в хранилище.
732
  """
733
  if model is None:
734
  raise HTTPException(status_code=503, detail="Model not loaded")
735
 
736
- if request.entity_type not in embedding_store:
737
- raise HTTPException(
738
- status_code=400,
739
- detail=f"Unknown entity type: {request.entity_type}. Allowed: leads, properties"
740
- )
741
-
742
- try:
743
- # Генерируем эмбеддинг
744
- embedding = model.encode(request.text, convert_to_numpy=True)
745
-
746
- # Сохраняем в хранилище
747
- embedding_store[request.entity_type][request.entity_id] = {
748
- "embedding": embedding.tolist(),
749
- "metadata": request.metadata or {}
750
- }
751
-
752
- return RegisterResponse(
753
- success=True,
754
- entity_id=request.entity_id,
755
- entity_type=request.entity_type
756
- )
757
- except Exception as e:
758
- raise HTTPException(status_code=500, detail=f"Register embedding failed: {str(e)}")
759
-
760
-
761
- @app.post("/register-vector", response_model=RegisterResponse)
762
- async def register_embedding_from_vector(request: RegisterEmbeddingFromVectorRequest):
763
- """
764
- Регистрация объекта с готовым эмбеддингом.
765
-
766
- Используется когда эмбеддинг уже был сгенерирован ранее.
767
- """
768
- if request.entity_type not in embedding_store:
769
- raise HTTPException(
770
- status_code=400,
771
- detail=f"Unknown entity type: {request.entity_type}. Allowed: leads, properties"
772
- )
773
-
774
- # Сохраняем в хранилище
775
- embedding_store[request.entity_type][request.entity_id] = {
776
- "embedding": request.embedding,
777
- "metadata": request.metadata or {}
778
- }
779
-
780
- return RegisterResponse(
781
- success=True,
782
- entity_id=request.entity_id,
783
- entity_type=request.entity_type
784
  )
785
 
 
 
786
 
787
- @app.delete("/register", response_model=RegisterResponse)
788
- async def delete_embedding(request: DeleteEmbeddingRequest):
789
- """
790
- Удаление эмбеддинга объекта из хранилища.
791
- """
792
- if request.entity_type not in embedding_store:
793
- raise HTTPException(
794
- status_code=400,
795
- detail=f"Unknown entity type: {request.entity_type}. Allowed: leads, properties"
796
- )
797
-
798
- store = embedding_store[request.entity_type]
799
- if request.entity_id not in store:
800
- raise HTTPException(
801
- status_code=404,
802
- detail=f"Entity {request.entity_id} not found in {request.entity_type}"
803
- )
804
-
805
- del store[request.entity_id]
806
-
807
- return RegisterResponse(
808
- success=True,
809
- entity_id=request.entity_id,
810
- entity_type=request.entity_type
811
- )
812
-
813
 
814
- @app.get("/store/stats", response_model=StoreStatsResponse)
815
- async def get_store_stats():
816
- """
817
- Получение статистики хранилища эмбеддингов.
818
- """
819
- leads_count = len(embedding_store.get("leads", {}))
820
- properties_count = len(embedding_store.get("properties", {}))
821
-
822
- return StoreStatsResponse(
823
- leads_count=leads_count,
824
- properties_count=properties_count,
825
- total_count=leads_count + properties_count
826
  )
827
 
828
 
829
- @app.get("/store/{entity_type}")
830
- async def list_registered_entities(entity_type: str):
831
  """
832
- Список зарегистрированных объектов указанного типа.
833
- """
834
- if entity_type not in embedding_store:
835
- raise HTTPException(
836
- status_code=400,
837
- detail=f"Unknown entity type: {entity_type}. Allowed: leads, properties"
838
- )
839
-
840
- store = embedding_store[entity_type]
841
- entities = [
842
- {
843
- "entity_id": eid,
844
- "metadata": data.get("metadata", {}),
845
- "embedding_dimensions": len(data.get("embedding", []))
846
- }
847
- for eid, data in store.items()
848
- ]
849
-
850
- return {"entity_type": entity_type, "count": len(entities), "entities": entities}
851
-
852
 
853
- # --- Bulk Indexing Endpoints ---
854
-
855
- @app.post("/index/bulk", response_model=BulkIndexResponse)
856
- async def bulk_index(request: BulkIndexRequest):
857
- """
858
- Массовая индексация объектов.
859
-
860
- Позволяет за один запрос проиндексировать множество лидов или объектов.
861
- Используется для первоначальной загрузки данных или переиндексации.
862
 
863
  Пример:
864
- ```
865
- POST /index/bulk
866
  {
867
- "entity_type": "properties",
868
  "items": [
869
- {"entity_id": "prop-1", "text": "3-комнатная квартира в центре", "metadata": {"price": 10000000}},
870
- {"entity_id": "prop-2", "text": "Студия у метро", "metadata": {"price": 5000000}}
871
- ],
872
- "clear_existing": false
873
  }
874
  ```
875
  """
876
  if model is None:
877
  raise HTTPException(status_code=503, detail="Model not loaded")
878
 
879
- if request.entity_type not in embedding_store:
880
- raise HTTPException(
881
- status_code=400,
882
- detail=f"Unknown entity type: {request.entity_type}. Allowed: leads, properties"
 
 
 
 
 
 
 
 
 
 
 
883
  )
884
-
885
- # Очистка если нужно
886
- if request.clear_existing:
887
- embedding_store[request.entity_type] = {}
888
-
889
- results: List[BulkIndexResult] = []
890
- indexed = 0
891
- failed = 0
892
-
893
- # Собираем все тексты для батчевой генерации эмбеддингов (быстрее)
894
- texts = [item.text for item in request.items]
895
-
896
- try:
897
- # Генерируем все эмбеддинги за один вызов модели
898
- embeddings = model.encode(texts, convert_to_numpy=True, show_progress_bar=True)
899
-
900
- # Сохраняем каждый
901
- for i, item in enumerate(request.items):
902
- try:
903
- embedding_store[request.entity_type][item.entity_id] = {
904
- "embedding": embeddings[i].tolist(),
905
- "metadata": item.metadata or {}
906
- }
907
- results.append(BulkIndexResult(entity_id=item.entity_id, success=True))
908
- indexed += 1
909
- except Exception as e:
910
- results.append(BulkIndexResult(entity_id=item.entity_id, success=False, error=str(e)))
911
- failed += 1
912
- except Exception as e:
913
- # Если батч не удался, пробуем по одному
914
- for item in request.items:
915
- try:
916
- embedding = model.encode(item.text, convert_to_numpy=True)
917
- embedding_store[request.entity_type][item.entity_id] = {
918
- "embedding": embedding.tolist(),
919
- "metadata": item.metadata or {}
920
- }
921
- results.append(BulkIndexResult(entity_id=item.entity_id, success=True))
922
- indexed += 1
923
- except Exception as item_error:
924
- results.append(BulkIndexResult(entity_id=item.entity_id, success=False, error=str(item_error)))
925
- failed += 1
926
-
927
- return BulkIndexResponse(
928
  total=len(request.items),
929
- indexed=indexed,
930
- failed=failed,
931
- results=results
932
  )
933
 
934
 
935
- @app.delete("/index/{entity_type}")
936
- async def clear_index(entity_type: str):
937
- """
938
- Очистка индекса для указанного типа сущностей.
939
-
940
- Удаляет все эмбеддинги указанного типа.
941
- """
942
- if entity_type not in embedding_store:
943
- raise HTTPException(
944
- status_code=400,
945
- detail=f"Unknown entity type: {entity_type}. Allowed: leads, properties"
946
- )
947
-
948
- count = len(embedding_store[entity_type])
949
- embedding_store[entity_type] = {}
950
-
951
- return {"message": f"Cleared {count} {entity_type} from index", "deleted_count": count}
952
-
953
-
954
- @app.post("/index/sync")
955
- async def sync_index_info():
956
- """
957
- Получение информации для синхронизации.
958
-
959
- Возвращает список всех entity_id в индексе, чтобы Go Backend мог
960
- определить какие объекты нужно добавить/удалить.
961
- """
962
- return {
963
- "leads": list(embedding_store["leads"].keys()),
964
- "properties": list(embedding_store["properties"].keys())
965
- }
966
-
967
-
968
- # --- Weighted Matching Endpoint ---
969
-
970
- @app.post("/match-weighted", response_model=WeightedMatchResponse)
971
- async def match_weighted(request: WeightedMatchRequest):
972
  """
973
- Взвешенный матчинг с настраиваемыми приоритетами параметров.
974
-
975
- Позволяет задать:
976
- - Веса для каждого параметра (цена, район, комнаты, площадь, семантика)
977
- - Жёсткие фильтры (объекты не прошедшие - исключаются)
978
- - Мягкие критерии (влияют на ранжирование)
979
 
980
- Пример использования:
981
- ```json
982
- {
983
- "text": "Ищу 2-комнатную квартиру в центре до 10 млн",
984
- "entity_type": "properties",
985
- "top_k": 10,
986
- "weights": {
987
- "price": 0.35, // Цена - главный приоритет
988
- "district": 0.30, // Район - второй по важности
989
- "rooms": 0.20, // Комнаты
990
- "area": 0.05, // Площадь менее важна
991
- "semantic": 0.10 // Семантика для "мягких" критериев
992
- },
993
- "hard_filters": {
994
- "price": {"max_price": 12000000},
995
- "districts": ["Центральный", "Арбат", "Тверской"]
996
- },
997
- "soft_criteria": {
998
- "target_price": 10000000,
999
- "target_rooms": 2,
1000
- "target_district": "Центральный"
1001
- }
1002
- }
1003
- ```
1004
  """
1005
  if model is None:
1006
  raise HTTPException(status_code=503, detail="Model not loaded")
1007
 
1008
- if request.entity_type not in embedding_store:
1009
- raise HTTPException(
1010
- status_code=400,
1011
- detail=f"Unknown entity type: {request.entity_type}. Allowed: leads, properties"
1012
- )
1013
-
1014
- store = embedding_store[request.entity_type]
1015
- if not store:
1016
- return WeightedMatchResponse(
1017
- matches=[],
1018
- total_searched=0,
1019
- filtered_out=0,
1020
- weights_used=request.weights or ParameterWeights()
1021
- )
1022
-
1023
- # Используем переданные веса или значения по умолчанию
1024
- weights = request.weights or ParameterWeights()
1025
-
1026
- # Нормализуем веса чтобы сумма = 1
1027
- total_weight = weights.price + weights.district + weights.rooms + weights.area + weights.semantic
1028
- if total_weight > 0:
1029
- w_price = weights.price / total_weight
1030
- w_district = weights.district / total_weight
1031
- w_rooms = weights.rooms / total_weight
1032
- w_area = weights.area / total_weight
1033
- w_semantic = weights.semantic / total_weight
1034
- else:
1035
- w_price = w_district = w_rooms = w_area = w_semantic = 0.2
1036
-
1037
- # Генерируем эмбеддинг для текста запроса
1038
- try:
1039
- query_embedding = model.encode(request.text, convert_to_numpy=True)
1040
- query_vec = np.array(query_embedding)
1041
- except Exception as e:
1042
- raise HTTPException(status_code=500, detail=f"Failed to generate embedding: {str(e)}")
1043
-
1044
- # Извлекаем soft criteria
1045
- soft = request.soft_criteria or SoftCriteria()
1046
-
1047
- results = []
1048
- filtered_out = 0
1049
-
1050
- for entity_id, data in store.items():
1051
- metadata = data.get("metadata", {})
1052
-
1053
- # 1. Проверяем жёсткие фильтры
1054
- if not _passes_hard_filters(metadata, request.hard_filters):
1055
- filtered_out += 1
1056
- continue
1057
-
1058
- # 2. Вычисляем score по каждому параметру
1059
-
1060
- # Цена
1061
- price_score = _calculate_price_score(
1062
- metadata.get("price"),
1063
- soft.target_price,
1064
- tolerance_percent=20.0
1065
- )
1066
-
1067
- # Район
1068
- district_score = _calculate_district_score(
1069
- metadata.get("district"),
1070
- soft.target_district,
1071
- soft.preferred_districts
1072
- )
1073
-
1074
- # Комнаты
1075
- rooms_score = _calculate_rooms_score(
1076
- metadata.get("rooms"),
1077
- soft.target_rooms
1078
- )
1079
-
1080
- # Площадь
1081
- area_score = _calculate_area_score(
1082
- metadata.get("area"),
1083
- soft.target_area
1084
- )
1085
-
1086
- # Семантика
1087
- stored_vec = np.array(data["embedding"])
1088
- semantic_score = _cosine_similarity(query_vec, stored_vec)
1089
- # Нормализуем в 0-1 (косинусная близость может быть отрицательной)
1090
- semantic_score = (semantic_score + 1) / 2
1091
-
1092
- # 3. Вычисляем взвешенный total score
1093
- total_score = (
1094
- w_price * price_score +
1095
- w_district * district_score +
1096
- w_rooms * rooms_score +
1097
- w_area * area_score +
1098
- w_semantic * semantic_score
1099
- )
1100
-
1101
- # Пропускаем если ниже минимального порога
1102
- if total_score < request.min_total_score:
1103
- continue
1104
-
1105
- # Генерируем объяснение
1106
- explanation = _generate_match_explanation(
1107
- price_score, district_score, rooms_score, area_score, semantic_score, metadata
1108
- )
1109
-
1110
- results.append(WeightedMatchResult(
1111
- entity_id=entity_id,
1112
- total_score=round(total_score, 4),
1113
- price_score=round(price_score, 4),
1114
- district_score=round(district_score, 4),
1115
- rooms_score=round(rooms_score, 4),
1116
- area_score=round(area_score, 4),
1117
- semantic_score=round(semantic_score, 4),
1118
- metadata=metadata,
1119
- match_explanation=explanation
1120
- ))
1121
-
1122
- # Сортируем по total_score и берём top_k
1123
- results.sort(key=lambda x: x.total_score, reverse=True)
1124
- top_results = results[:request.top_k]
1125
-
1126
- return WeightedMatchResponse(
1127
- matches=top_results,
1128
- total_searched=len(store),
1129
- filtered_out=filtered_out,
1130
- weights_used=weights
1131
- )
1132
-
1133
 
1134
- @app.get("/weights/presets")
1135
- async def get_weight_presets():
1136
- """
1137
- Получить предустановленные наборы весов для разных сценариев.
1138
-
1139
- Помогает фронтенду предложить пользователю готовые настройки.
1140
- """
1141
  return {
1142
- "balanced": {
1143
- "name": "Сбалансированный",
1144
- "description": "Равномерное распределение приоритетов",
1145
- "weights": {"price": 0.25, "district": 0.25, "rooms": 0.20, "area": 0.15, "semantic": 0.15}
1146
- },
1147
- "budget_first": {
1148
- "name": "Бюджет важнее всего",
1149
- "description": "Максимальный приоритет на соответствие бюджету",
1150
- "weights": {"price": 0.45, "district": 0.20, "rooms": 0.15, "area": 0.10, "semantic": 0.10}
1151
- },
1152
- "location_first": {
1153
- "name": "Локация важнее всего",
1154
- "description": "Район и расположение - главный приоритет",
1155
- "weights": {"price": 0.20, "district": 0.40, "rooms": 0.15, "area": 0.10, "semantic": 0.15}
1156
- },
1157
- "family": {
1158
- "name": "Для семьи",
1159
- "description": "Важны комнаты и площадь",
1160
- "weights": {"price": 0.20, "district": 0.20, "rooms": 0.30, "area": 0.20, "semantic": 0.10}
1161
- },
1162
- "semantic_heavy": {
1163
- "name": "Умный поиск",
1164
- "description": "Максимальный приоритет на семантическое понимание запроса",
1165
- "weights": {"price": 0.15, "district": 0.15, "rooms": 0.15, "area": 0.10, "semantic": 0.45}
1166
  }
1167
  }
 
1
  """
2
  Embedding Service - FastAPI сервис для генерации эмбеддингов текста.
3
 
4
+ STATELESS сервис - не хранит данные, только генерирует эмбеддинги.
5
+ Хранение эмбеддингов происходит на стороне бэкенда в PostgreSQL + pgvector.
6
+
7
+ Используется для матчинга лидов с объектами недвижимости.
8
+
9
+ Endpoints:
10
+ - POST /embed - генерация эмбеддинга из текста
11
+ - POST /prepare-and-embed - подготовка полей + эмбеддинг (ОСНОВНОЙ)
12
+ - POST /batch - пакетная обработка
13
+ - GET /health - проверка здоровья
14
+ - GET /model-info - информация о модели
15
  """
16
 
17
  import os
18
  from typing import List, Optional, Dict, Any
19
  from contextlib import asynccontextmanager
 
20
 
21
  from fastapi import FastAPI, HTTPException
22
  from fastapi.middleware.cors import CORSMiddleware
 
28
  load_dotenv()
29
 
30
  # Конфигурация
31
+ MODEL_NAME = os.getenv("EMBEDDING_MODEL", "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
32
+ EMBEDDING_DIMENSIONS = 384
33
 
34
+ # Глобальная модель
35
  model: Optional[SentenceTransformer] = None
36
 
 
 
 
 
 
 
 
37
 
38
  @asynccontextmanager
39
  async def lifespan(app: FastAPI):
40
+ """Загрузка модели при старте."""
41
  global model
42
  print(f"Loading embedding model: {MODEL_NAME}")
 
43
  model = SentenceTransformer(MODEL_NAME, device='cpu')
 
44
  try:
45
  model.half()
46
  print("Model converted to half precision (float16)")
47
  except Exception as e:
48
  print(f"Could not convert to half precision: {e}")
49
+ print(f"Model loaded. Dimensions: {model.get_sentence_embedding_dimension()}")
50
  yield
 
51
  model = None
52
 
53
 
54
  app = FastAPI(
55
  title="Embedding Service",
56
+ description="Stateless сервис генерации эмбеддингов для матчинга недвижимости",
57
+ version="2.0.0",
58
  lifespan=lifespan
59
  )
60
 
 
61
  app.add_middleware(
62
  CORSMiddleware,
63
  allow_origins=["*"],
 
67
  )
68
 
69
 
70
+ # ============== Pydantic Models ==============
71
 
72
  class EmbedRequest(BaseModel):
73
+ """Запрос на генерацию эмбеддинга из готового текста."""
74
+ text: str = Field(..., min_length=1, description="Текст для эмбеддинга")
75
 
76
 
77
  class EmbedResponse(BaseModel):
78
  """Ответ с эмбеддингом."""
79
+ embedding: List[float]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  dimensions: int
81
 
82
 
83
+ class PrepareAndEmbedRequest(BaseModel):
84
+ """
85
+ Запрос на подготовку текста из полей и генерацию эмбеддинга.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
 
87
+ Это ОСНОВНОЙ endpoint для интеграции с Go Backend.
88
+ """
89
+ title: str = Field(default="", description="Название")
90
+ description: str = Field(default="", description="Описание")
91
+ requirement: Optional[Dict[str, Any]] = Field(default=None, description="Требования (JSON)")
92
+ price: Optional[float] = Field(default=None, description="Цена")
93
+ district: Optional[str] = Field(default=None, description="Район")
94
+ rooms: Optional[int] = Field(default=None, description="Количество комнат")
95
+ area: Optional[float] = Field(default=None, description="Площадь")
96
+ address: Optional[str] = Field(default=None, description="Адрес")
97
 
 
 
 
 
 
98
 
99
+ class PrepareAndEmbedResponse(BaseModel):
100
+ """Ответ с эмбеддингом."""
101
+ embedding: List[float]
102
+ dimensions: int
103
+ prepared_text: str = Field(description="Подготовленный текст (для отладки)")
104
 
 
105
 
106
+ class BatchItem(BaseModel):
107
+ """Один элемент для пакетной обработки."""
108
  entity_id: str = Field(..., description="ID объекта")
109
+ title: str = Field(default="")
110
+ description: str = Field(default="")
111
+ requirement: Optional[Dict[str, Any]] = None
112
+ price: Optional[float] = None
113
+ district: Optional[str] = None
114
+ rooms: Optional[int] = None
115
+ area: Optional[float] = None
116
+ address: Optional[str] = None
117
 
118
 
119
+ class BatchRequest(BaseModel):
120
+ """Запрос на пакетную обработку."""
121
+ items: List[BatchItem]
 
 
122
 
123
 
124
+ class BatchResultItem(BaseModel):
125
+ """Результат для одного элемента."""
126
  entity_id: str
127
+ embedding: List[float]
128
+ success: bool = True
129
  error: Optional[str] = None
130
 
131
 
132
+ class BatchResponse(BaseModel):
133
+ """Ответ на пакетную обработку."""
134
+ results: List[BatchResultItem]
135
+ dimensions: int
136
+ total: int
137
+ successful: int
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
 
 
 
 
 
 
139
 
140
+ class HealthResponse(BaseModel):
141
+ """Ответ health check."""
142
+ status: str
143
+ model: str
144
+ dimensions: int
145
 
 
 
 
146
 
147
+ # ============== Helper Functions ==============
 
148
 
149
+ def prepare_text(
150
+ title: str = "",
151
+ description: str = "",
152
+ requirement: Optional[Dict[str, Any]] = None,
153
+ price: Optional[float] = None,
154
+ district: Optional[str] = None,
155
+ rooms: Optional[int] = None,
156
+ area: Optional[float] = None,
157
+ address: Optional[str] = None
158
+ ) -> str:
159
+ """Объединяет поля в текст для эмбеддинга."""
160
+ parts = []
161
 
162
+ if title:
163
+ parts.append(f"Название: {title}")
164
+ if description:
165
+ parts.append(f"Описание: {description}")
166
 
167
+ if requirement:
168
+ req_parts = [f"{k}: {v}" for k, v in requirement.items() if v is not None]
169
+ if req_parts:
170
+ parts.append(f"Требования: {', '.join(req_parts)}")
 
 
171
 
172
+ params = []
173
+ if price is not None:
174
+ params.append(f"цена {price:,.0f}₽")
175
+ if district:
176
+ params.append(f"район {district}")
177
+ if rooms is not None:
178
+ params.append(f"{rooms}-комнатная")
179
+ if area is not None:
180
+ params.append(f"площадь {area}м²")
181
+ if address:
182
+ params.append(f"адрес: {address}")
183
 
184
+ if params:
185
+ parts.append(f"Параметры: {', '.join(params)}")
186
 
187
+ return ". ".join(parts)
 
 
 
 
 
188
 
189
 
190
+ # ============== Endpoints ==============
191
 
192
  @app.get("/")
193
  async def root():
194
+ """Информация о сервисе."""
 
 
 
 
195
  return {
196
+ "service": "Embedding Service",
197
+ "version": "2.0.0",
198
+ "type": "STATELESS",
199
+ "description": "Генерирует эмбеддинги. Хранение на стороне Go Backend + pgvector.",
 
 
 
200
  "endpoints": {
201
+ "POST /embed": "Эмбеддинг из готового текста",
202
+ "POST /prepare-and-embed": "Подготовка полей + эмбеддинг (ОСНОВНОЙ)",
203
+ "POST /batch": "Пакетная обработка",
204
+ "GET /health": "Проверка здоровья",
205
+ "GET /model-info": "Информация о модели для pgvector"
206
+ },
207
+ "docs": "/docs"
 
 
 
 
 
 
 
 
208
  }
209
 
210
 
 
223
  @app.post("/embed", response_model=EmbedResponse)
224
  async def embed_text(request: EmbedRequest):
225
  """
226
+ Генерация эмбеддинга из готового текста.
227
 
228
+ Используйте если текст уже подготовлен на стороне бэкенда.
229
  """
230
  if model is None:
231
  raise HTTPException(status_code=503, detail="Model not loaded")
232
 
233
+ embedding = model.encode(request.text, convert_to_numpy=True)
234
+ return EmbedResponse(
235
+ embedding=embedding.tolist(),
236
+ dimensions=len(embedding)
237
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
 
 
 
 
 
239
 
240
+ @app.post("/prepare-and-embed", response_model=PrepareAndEmbedResponse)
241
+ async def prepare_and_embed(request: PrepareAndEmbedRequest):
242
  """
243
+ Подготовка текста из полей и генерация эмбеддинга.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
 
245
+ ОСНОВНОЙ ENDPOINT для интеграции с Go Backend.
 
 
246
 
247
+ Пример запроса:
248
+ ```json
249
+ {
250
+ "title": "Ищу квартиру в центре",
251
+ "description": "Для семьи с детьми",
252
+ "price": 10000000,
253
+ "district": "Центральный",
254
+ "rooms": 3
255
+ }
256
+ ```
257
 
258
+ Go Backend сохраняет embedding в PostgreSQL:
259
+ ```sql
260
+ UPDATE leads SET embedding = $1 WHERE lead_id = $2
261
+ ```
 
 
262
  """
263
  if model is None:
264
  raise HTTPException(status_code=503, detail="Model not loaded")
265
 
266
+ prepared = prepare_text(
267
+ title=request.title,
268
+ description=request.description,
269
+ requirement=request.requirement,
270
+ price=request.price,
271
+ district=request.district,
272
+ rooms=request.rooms,
273
+ area=request.area,
274
+ address=request.address
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
  )
276
 
277
+ if not prepared:
278
+ raise HTTPException(status_code=400, detail="All fields are empty")
279
 
280
+ embedding = model.encode(prepared, convert_to_numpy=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
 
282
+ return PrepareAndEmbedResponse(
283
+ embedding=embedding.tolist(),
284
+ dimensions=len(embedding),
285
+ prepared_text=prepared
 
 
 
 
 
 
 
 
286
  )
287
 
288
 
289
+ @app.post("/batch", response_model=BatchResponse)
290
+ async def batch_process(request: BatchRequest):
291
  """
292
+ Пакетная обработка нескольких объектов.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
 
294
+ Используйте для массовой индексации при первоначальной загрузке.
 
 
 
 
 
 
 
 
295
 
296
  Пример:
297
+ ```json
 
298
  {
 
299
  "items": [
300
+ {"entity_id": "lead-1", "title": "Ищу квартиру", "rooms": 3},
301
+ {"entity_id": "lead-2", "title": "Нужен офис", "area": 100}
302
+ ]
 
303
  }
304
  ```
305
  """
306
  if model is None:
307
  raise HTTPException(status_code=503, detail="Model not loaded")
308
 
309
+ results = []
310
+ texts = []
311
+ valid_items = []
312
+
313
+ # Подготовка текстов
314
+ for item in request.items:
315
+ prepared = prepare_text(
316
+ title=item.title,
317
+ description=item.description,
318
+ requirement=item.requirement,
319
+ price=item.price,
320
+ district=item.district,
321
+ rooms=item.rooms,
322
+ area=item.area,
323
+ address=item.address
324
  )
325
+ if prepared:
326
+ texts.append(prepared)
327
+ valid_items.append(item)
328
+ else:
329
+ results.append(BatchResultItem(
330
+ entity_id=item.entity_id,
331
+ embedding=[],
332
+ success=False,
333
+ error="All fields are empty"
334
+ ))
335
+
336
+ # Генерация эмбеддингов батчем
337
+ if texts:
338
+ embeddings = model.encode(texts, convert_to_numpy=True)
339
+ for i, item in enumerate(valid_items):
340
+ results.append(BatchResultItem(
341
+ entity_id=item.entity_id,
342
+ embedding=embeddings[i].tolist(),
343
+ success=True
344
+ ))
345
+
346
+ # Сортировка по порядку входных items
347
+ results_map = {r.entity_id: r for r in results}
348
+ sorted_results = [results_map[item.entity_id] for item in request.items]
349
+ successful = sum(1 for r in sorted_results if r.success)
350
+
351
+ return BatchResponse(
352
+ results=sorted_results,
353
+ dimensions=EMBEDDING_DIMENSIONS,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354
  total=len(request.items),
355
+ successful=successful
 
 
356
  )
357
 
358
 
359
+ @app.get("/model-info")
360
+ async def get_model_info():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
  """
362
+ Информация о модели для настройки pgvector.
 
 
 
 
 
363
 
364
+ Используйте для создания колонки правильной размерности.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
365
  """
366
  if model is None:
367
  raise HTTPException(status_code=503, detail="Model not loaded")
368
 
369
+ dims = model.get_sentence_embedding_dimension()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
370
 
 
 
 
 
 
 
 
371
  return {
372
+ "model_name": MODEL_NAME,
373
+ "dimensions": dims,
374
+ "sql_examples": {
375
+ "extension": "CREATE EXTENSION IF NOT EXISTS vector;",
376
+ "column": f"ALTER TABLE leads ADD COLUMN embedding vector({dims});",
377
+ "index": f"CREATE INDEX ON leads USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);",
378
+ "search": """
379
+ SELECT property_id, title, 1 - (embedding <=> $1) as similarity
380
+ FROM properties
381
+ WHERE embedding IS NOT NULL
382
+ ORDER BY embedding <=> $1
383
+ LIMIT 10;
384
+ """.strip()
 
 
 
 
 
 
 
 
 
 
 
385
  }
386
  }