Calcifer0323 commited on
Commit
2b3c222
·
1 Parent(s): 9ebcd3b

Add comprehensive mass indexing and matching test

Browse files

- Added test_mass_indexing.py: Large-scale test with 1000 leads and 1000 properties
- Added TEST_README.md: Documentation for running the test
- Updated requirements.txt: Added aiohttp and scikit-learn for testing
- Test validates batch processing, embedding quality, and matching performance

Files changed (3) hide show
  1. TEST_README.md +77 -0
  2. requirements.txt +3 -0
  3. test_mass_indexing.py +298 -0
TEST_README.md ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Большой тест массовой индексации и матчинга
2
+
3
+ Этот тест проверяет производительность и корректность сервиса эмбеддингов для матчинга недвижимости.
4
+
5
+ ## Что делает тест
6
+
7
+ 1. **Генерация данных**: Создает 1000 лидов и 1000 свойств с разнообразными характеристиками (районы, цены, комнаты, площади)
8
+
9
+ 2. **Массовая индексация**: Отправляет данные на сервис через `/batch` endpoint в батчах по 50 элементов
10
+
11
+ 3. **Симуляция матчинга**: Для каждого лида находит топ-5 похожих свойств по косинусному сходству эмбеддингов
12
+
13
+ 4. **Анализ результатов**: Измеряет время выполнения, статистику сходства, проверяет корректность
14
+
15
+ ## Запуск теста
16
+
17
+ ### 1. Установка зависимостей
18
+ ```bash
19
+ pip install -r requirements.txt
20
+ ```
21
+
22
+ ### 2. Настройка URL сервиса
23
+ В файле `test_mass_indexing.py` проверьте и измените при необходимости:
24
+ ```python
25
+ API_BASE_URL = "https://calcifer0323-matching.hf.space" # Или ваш URL
26
+ ```
27
+
28
+ ### 3. Запуск
29
+ ```bash
30
+ python test_mass_indexing.py
31
+ ```
32
+
33
+ ## Параметры теста
34
+
35
+ - `NUM_LEADS = 1000` - количество лидов
36
+ - `NUM_PROPERTIES = 1000` - количество свойств
37
+ - `BATCH_SIZE = 50` - размер батча для отправки
38
+ - `TOP_K = 5` - количество топ-матчей для каждого лида
39
+
40
+ ## Вывод теста
41
+
42
+ Тест покажет:
43
+ - Время выполнения индексации и матчинга
44
+ - Процент успешных запросов
45
+ - Статистику сходства (среднее, мин/макс, стандартное отклонение)
46
+ - Примеры топ-матчей для первых лидов
47
+
48
+ ## Результаты
49
+
50
+ Результаты сохраняются в `test_results.json` с полной статистикой и примерами матчей.
51
+
52
+ ## Ожидаемые результаты
53
+
54
+ При корректной работе:
55
+ - Высокий процент успешных эмбеддингов (>95%)
56
+ - Время индексации: ~5-15 минут (зависит от сервиса)
57
+ - Среднее косинусное сходство: 0.3-0.7 (зависит от качества модели и данных)
58
+ - Матчи должны быть логичными (одинаковые районы, похожие цены/комнаты)
59
+
60
+ ## Troubleshooting
61
+
62
+ - **Ошибка подключения**: Проверьте URL сервиса и доступность
63
+ - **Rate limiting**: Сервис имеет лимиты, тест может быть заблокирован
64
+ - **Timeout**: Увеличьте `ENCODE_TIMEOUT_SECONDS` в сервисе или уменьшите `BATCH_SIZE`
65
+ - **OOM**: Уменьшите `NUM_LEADS` и `NUM_PROPERTIES` для тестирования
66
+
67
+ ## Интеграция с реальным матчингом
68
+
69
+ В production матчинг происходит в PostgreSQL с pgvector:
70
+ ```sql
71
+ SELECT property_id, 1 - (embedding <=> $lead_embedding) as similarity
72
+ FROM properties
73
+ ORDER BY embedding <=> $lead_embedding
74
+ LIMIT 10;
75
+ ```
76
+
77
+ Этот тест симулирует такой матчинг локально для проверки качества эмбеддингов.
requirements.txt CHANGED
@@ -26,3 +26,6 @@ opentelemetry-api>=1.21.0 # Tracing (опционально)
26
  opentelemetry-sdk>=1.21.0
27
  opentelemetry-instrumentation-fastapi>=0.42b0
28
 
 
 
 
 
26
  opentelemetry-sdk>=1.21.0
27
  opentelemetry-instrumentation-fastapi>=0.42b0
28
 
29
+ # Тестовые зависимости
30
+ aiohttp>=3.9.0 # Асинхронные HTTP запросы для тестов
31
+ scikit-learn>=1.3.0 # Для вычисления косинусного сходства в тестах
test_mass_indexing.py ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Production-grade benchmark для семантического матчинга лидов и объектов недвижимости.
3
+
4
+ Особенности:
5
+ - Использует /batch endpoint
6
+ - Учитывает rate limit: 20 запросов / минуту
7
+ - Retry + exponential backoff на 429
8
+ - Реалистичная генерация данных
9
+ """
10
+
11
+ import asyncio
12
+ import aiohttp
13
+ import random
14
+ import time
15
+ import json
16
+ from dataclasses import dataclass
17
+ from typing import List, Dict, Optional
18
+ import numpy as np
19
+ from sklearn.metrics.pairwise import cosine_similarity
20
+
21
+
22
+ # =======================
23
+ # CONFIG
24
+ # =======================
25
+
26
+ API_BASE_URL = "https://calcifer0323-matching.hf.space"
27
+
28
+ NUM_PROPERTIES = 2000
29
+ NUM_LEADS = 500
30
+ BATCH_SIZE = 64
31
+ TOP_K = 10
32
+
33
+ # hard filter tolerances
34
+ PRICE_TOLERANCE = 0.15
35
+ AREA_TOLERANCE = 0.20
36
+
37
+ # HF Space rate limit
38
+ MAX_BATCH_REQUESTS_PER_MIN = 20
39
+ SECONDS_PER_BATCH = 60 / MAX_BATCH_REQUESTS_PER_MIN # 3.0 сек
40
+
41
+
42
+ # =======================
43
+ # DOMAIN DATA
44
+ # =======================
45
+
46
+ DISTRICTS = [
47
+ "Центральный", "Арбат", "Тверской", "Пресненский",
48
+ "Юго-Западный", "Северный", "Южный", "Восточный",
49
+ "Ясенево", "Коньково", "Черемушки", "Бутово"
50
+ ]
51
+
52
+ ROOMS = [1, 2, 3, 4, 5, "Студия"]
53
+
54
+ PROPERTY_TEMPLATES = [
55
+ "Продается {rooms}-комнатная квартира в {district}",
56
+ "Квартира {area}м², {district}",
57
+ "Жилье рядом с метро в {district}",
58
+ "Инвестиционная квартира в {district}",
59
+ "Просторная квартира для семьи"
60
+ ]
61
+
62
+ LEAD_TEMPLATES = [
63
+ "Ищу {rooms}-комнатную квартиру в {district}",
64
+ "Нужна квартира для семьи в {district}",
65
+ "Хочу купить жилье в {district}",
66
+ "Интересует квартира рядом с метро",
67
+ "Ищу недорогую квартиру"
68
+ ]
69
+
70
+ NOISE_PHRASES = [
71
+ "",
72
+ "Срочно",
73
+ "Рассмотрю варианты",
74
+ "Не принципиально",
75
+ "Без разницы"
76
+ ]
77
+
78
+
79
+ # =======================
80
+ # DATA MODELS
81
+ # =======================
82
+
83
+ @dataclass
84
+ class Property:
85
+ id: str
86
+ district: str
87
+ rooms: Optional[int]
88
+ area: int
89
+ price: int
90
+ text: str
91
+
92
+
93
+ @dataclass
94
+ class Lead:
95
+ id: str
96
+ district: Optional[str]
97
+ rooms: Optional[int]
98
+ area_min: Optional[int]
99
+ price_max: Optional[int]
100
+ text: str
101
+ gt_property_ids: List[str]
102
+
103
+
104
+ # =======================
105
+ # DATA GENERATION
106
+ # =======================
107
+
108
+ def generate_property(i: int) -> Property:
109
+ district = random.choice(DISTRICTS)
110
+ rooms = random.choice(ROOMS)
111
+ rooms_int = rooms if isinstance(rooms, int) else None
112
+ area = random.randint(25, 140)
113
+ price = area * random.randint(180_000, 350_000)
114
+
115
+ text = random.choice(PROPERTY_TEMPLATES).format(
116
+ rooms=rooms,
117
+ district=district,
118
+ area=area
119
+ )
120
+
121
+ return Property(
122
+ id=f"property-{i}",
123
+ district=district,
124
+ rooms=rooms_int,
125
+ area=area,
126
+ price=price,
127
+ text=text
128
+ )
129
+
130
+
131
+ def generate_lead(i: int, properties: List[Property]) -> Lead:
132
+ gt = random.choice(properties)
133
+
134
+ text = random.choice(LEAD_TEMPLATES).format(
135
+ rooms=gt.rooms or "любую",
136
+ district=gt.district
137
+ )
138
+ text += " " + random.choice(NOISE_PHRASES)
139
+
140
+ return Lead(
141
+ id=f"lead-{i}",
142
+ district=gt.district if random.random() > 0.2 else None,
143
+ rooms=gt.rooms if random.random() > 0.2 else None,
144
+ area_min=int(gt.area * 0.8),
145
+ price_max=int(gt.price * 1.1),
146
+ text=text,
147
+ gt_property_ids=[gt.id]
148
+ )
149
+
150
+
151
+ # =======================
152
+ # EMBEDDINGS
153
+ # =======================
154
+
155
+ async def embed_batch(session, items, endpoint="/batch", retries=5):
156
+ payload = {
157
+ "items": [
158
+ {"entity_id": x["id"], "text": x["text"]}
159
+ for x in items
160
+ ]
161
+ }
162
+
163
+ for attempt in range(retries):
164
+ async with session.post(f"{API_BASE_URL}{endpoint}", json=payload) as r:
165
+ if r.status == 429:
166
+ wait = 2 ** attempt
167
+ print(f"[429] Rate limit hit. Retry in {wait}s")
168
+ await asyncio.sleep(wait)
169
+ continue
170
+
171
+ r.raise_for_status()
172
+ return await r.json()
173
+
174
+ raise RuntimeError("Exceeded retry attempts due to rate limiting")
175
+
176
+
177
+ async def embed_entities(entities):
178
+ embeddings = {}
179
+ failed = 0
180
+ total = 0
181
+
182
+ async with aiohttp.ClientSession() as session:
183
+ for i in range(0, len(entities), BATCH_SIZE):
184
+ batch = entities[i:i + BATCH_SIZE]
185
+ total += len(batch)
186
+
187
+ result = await embed_batch(session, batch)
188
+
189
+ for r in result["results"]:
190
+ if r["success"]:
191
+ embeddings[r["entity_id"]] = np.array(r["embedding"])
192
+ else:
193
+ failed += 1
194
+
195
+ print(
196
+ f"Embedded: {len(embeddings)} / {total} "
197
+ f"(failed: {failed})"
198
+ )
199
+
200
+ await asyncio.sleep(SECONDS_PER_BATCH)
201
+
202
+ return embeddings
203
+
204
+
205
+
206
+ # =======================
207
+ # MATCHING
208
+ # =======================
209
+
210
+ def hard_filter(lead: Lead, prop: Property) -> bool:
211
+ if lead.district and prop.district != lead.district:
212
+ return False
213
+ if lead.rooms and prop.rooms and prop.rooms != lead.rooms:
214
+ return False
215
+ if lead.price_max and prop.price > lead.price_max * (1 + PRICE_TOLERANCE):
216
+ return False
217
+ if lead.area_min and prop.area < lead.area_min * (1 - AREA_TOLERANCE):
218
+ return False
219
+ return True
220
+
221
+
222
+ def evaluate(leads, properties, lead_embs, prop_embs):
223
+ prop_ids = list(prop_embs.keys())
224
+ prop_matrix = np.vstack([prop_embs[i] for i in prop_ids])
225
+
226
+ metrics = {"hits@1": 0, "hits@5": 0, "hits@10": 0}
227
+
228
+ for lead in leads:
229
+ if lead.id not in lead_embs:
230
+ continue
231
+
232
+ sims = cosine_similarity(
233
+ lead_embs[lead.id].reshape(1, -1),
234
+ prop_matrix
235
+ )[0]
236
+
237
+ ranked = sorted(
238
+ zip(prop_ids, sims),
239
+ key=lambda x: x[1],
240
+ reverse=True
241
+ )
242
+
243
+ filtered = [
244
+ pid for pid, _ in ranked
245
+ if hard_filter(lead, next(p for p in properties if p.id == pid))
246
+ ]
247
+
248
+ for k in (1, 5, 10):
249
+ if any(gt in filtered[:k] for gt in lead.gt_property_ids):
250
+ metrics[f"hits@{k}"] += 1
251
+
252
+ total = len(leads)
253
+ return {k: v / total for k, v in metrics.items()}
254
+
255
+
256
+ # =======================
257
+ # MAIN
258
+ # =======================
259
+
260
+ async def main():
261
+ print("=== Production Matching Benchmark ===")
262
+
263
+ properties = [generate_property(i) for i in range(NUM_PROPERTIES)]
264
+ leads = [generate_lead(i, properties) for i in range(NUM_LEADS)]
265
+
266
+ t0 = time.time()
267
+ prop_embs = await embed_entities(
268
+ [{"id": p.id, "text": p.text} for p in properties]
269
+ )
270
+ t1 = time.time()
271
+
272
+ lead_embs = await embed_entities(
273
+ [{"id": l.id, "text": l.text} for l in leads]
274
+ )
275
+ t2 = time.time()
276
+
277
+ metrics = evaluate(leads, properties, lead_embs, prop_embs)
278
+ t3 = time.time()
279
+
280
+ report = {
281
+ "properties": NUM_PROPERTIES,
282
+ "leads": NUM_LEADS,
283
+ "timings_sec": {
284
+ "property_embedding": round(t1 - t0, 2),
285
+ "lead_embedding": round(t2 - t1, 2),
286
+ "matching": round(t3 - t2, 2)
287
+ },
288
+ "metrics": metrics
289
+ }
290
+
291
+ print(json.dumps(report, indent=2, ensure_ascii=False))
292
+
293
+ with open("benchmark_report.json", "w", encoding="utf-8") as f:
294
+ json.dump(report, f, indent=2, ensure_ascii=False)
295
+
296
+
297
+ if __name__ == "__main__":
298
+ asyncio.run(main())