antimoda1 commited on
Commit
2a48bd3
·
1 Parent(s): 8aceb62

add lemmatization

Browse files
app.py CHANGED
@@ -4,7 +4,7 @@ import plotly.express as px
4
  import plotly.graph_objects as go
5
  import pandas as pd
6
  from generation import wrap_prompt
7
- from llm import get_llm_answer
8
  from retrieval import Retrieval
9
  from _2_splitting import YEAR_OLD, YEAR_NEW
10
  from vocabulary.parse_vocabulary import parse_vocabulary
@@ -490,4 +490,6 @@ with gr.Blocks(title="RAG Application", theme=gr.themes.Soft()) as iface:
490
  )
491
 
492
  if __name__ == "__main__":
493
- iface.launch(ssr_mode=False, share=True)
 
 
 
4
  import plotly.graph_objects as go
5
  import pandas as pd
6
  from generation import wrap_prompt
7
+ # from llm import get_llm_answer
8
  from retrieval import Retrieval
9
  from _2_splitting import YEAR_OLD, YEAR_NEW
10
  from vocabulary.parse_vocabulary import parse_vocabulary
 
490
  )
491
 
492
  if __name__ == "__main__":
493
+ iface.launch(ssr_mode=False,
494
+ # share=True
495
+ )
requirements.txt CHANGED
@@ -5,4 +5,5 @@ plotly== 5.24.1
5
  gradio
6
  rank-bm25==0.2.2
7
  sentence-transformers==5.2.0
8
- huggingface_hub
 
 
5
  gradio
6
  rank-bm25==0.2.2
7
  sentence-transformers==5.2.0
8
+ huggingface_hub
9
+ spacy==3.8.11
retrieval.py CHANGED
@@ -1,10 +1,13 @@
1
  import re
 
2
 
3
  import torch
4
  from rank_bm25 import BM25Okapi
5
  import warnings
6
  warnings.filterwarnings('ignore')
7
 
 
 
8
  from _1_get_documents import load_and_process_data
9
  from _2_splitting import Splitter, parse_year_metadata, years_overlap, YEAR_OLD, YEAR_NEW
10
  # from _3_chunking import RussianEmbedder
@@ -20,6 +23,19 @@ class Retrieval:
20
  print("Инициализация RAG системы...")
21
  self.device = "cuda" if use_gpu and torch.cuda.is_available() else "cpu"
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  # Загружаем и обрабатываем данные
24
  print("1. Загрузка данных из JSON...")
25
  self.documents, self.docs_names = load_and_process_data()
@@ -94,6 +110,47 @@ class Retrieval:
94
  def get_all_chunks(self, doc_id):
95
  return [chunk for chunk, x in zip(self.chunks, self.docs_metadata, strict=True) if x == doc_id]
96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  def _prepare_bm25(self):
98
  """Подготавливаем BM25 индекс для ключевого поиска"""
99
  # Токенизация для BM25
@@ -101,10 +158,17 @@ class Retrieval:
101
  return BM25Okapi(tokenized_chunks)
102
 
103
  def _tokenize_text(self, text: str) -> list[str]:
104
- """Простая токенизация для BM25"""
105
- # Удаляем пунктуацию и приводим к нижнему регистру
106
- text = re.sub(r'[^\w\s]', ' ', text.lower())
107
- return text.split()
 
 
 
 
 
 
 
108
 
109
  def rerank_search(self, query: str) -> list[dict]:
110
  """
 
1
  import re
2
+ from pathlib import Path
3
 
4
  import torch
5
  from rank_bm25 import BM25Okapi
6
  import warnings
7
  warnings.filterwarnings('ignore')
8
 
9
+ import spacy
10
+
11
  from _1_get_documents import load_and_process_data
12
  from _2_splitting import Splitter, parse_year_metadata, years_overlap, YEAR_OLD, YEAR_NEW
13
  # from _3_chunking import RussianEmbedder
 
23
  print("Инициализация RAG системы...")
24
  self.device = "cuda" if use_gpu and torch.cuda.is_available() else "cpu"
25
 
26
+ # Инициализация лемматизатора для русского языка (spaCy)
27
+ print(" Загрузка русской модели spaCy...")
28
+ try:
29
+ self.nlp = spacy.load("ru_core_news_sm")
30
+ except OSError:
31
+ print(" ⚠️ Модель ru_core_news_sm не найдена, скачиваю...")
32
+ import subprocess
33
+ subprocess.check_call(["python", "-m", "spacy", "download", "ru_core_news_sm"])
34
+ self.nlp = spacy.load("ru_core_news_sm")
35
+
36
+ # Загружаем топонимы и регистрируем их как исключения для лемматизации
37
+ self._register_toponyms()
38
+
39
  # Загружаем и обрабатываем данные
40
  print("1. Загрузка данных из JSON...")
41
  self.documents, self.docs_names = load_and_process_data()
 
110
  def get_all_chunks(self, doc_id):
111
  return [chunk for chunk, x in zip(self.chunks, self.docs_metadata, strict=True) if x == doc_id]
112
 
113
+ def _register_toponyms(self):
114
+ """
115
+ Загружает топонимы из vocabulary/toponims.txt и регистрирует их в spaCy
116
+ как custom component для исправления лемм.
117
+ """
118
+ toponims_file = Path(__file__).parent / "vocabulary" / "toponims.txt"
119
+
120
+ if not toponims_file.exists():
121
+ print(f" ⚠️ Файл {toponims_file} не найден, управление топонимами пропущено")
122
+ return
123
+
124
+ # Читаем топонимы
125
+ with open(toponims_file, 'r', encoding='utf-8') as f:
126
+ self.toponims = {line.strip().lower(): line.strip().lower() for line in f if line.strip()}
127
+
128
+ print(f" Загружено {len(self.toponims)} топонимов")
129
+
130
+ # Добавляем custom component для обработки топонимов после лемматизации
131
+ @self.nlp.component("fix_toponyms")
132
+ def fix_toponyms(doc):
133
+ """Компонент для исправления лемм топонимов и их форм"""
134
+ for token in doc:
135
+ lemma_lower = token.lemma_.lower()
136
+
137
+ # Проверяем прямое совпадение
138
+ if lemma_lower in self.toponims:
139
+ token.lemma_ = self.toponims[lemma_lower]
140
+ else:
141
+ # Проверяем, может ли это быть формой топонима
142
+ # Для каждого топонима проверяем, совпадает ли начало слова
143
+ for toponim in self.toponims:
144
+ if lemma_lower.startswith(toponim[:len(lemma_lower)-2]) and len(lemma_lower) > len(toponim) - 3:
145
+ # Похоже, это форма топонима (например, "дягилев" -> "дягилево")
146
+ token.lemma_ = toponim
147
+ break
148
+ return doc
149
+
150
+ # Добавляем компонент после лемматизатора
151
+ if "fix_toponyms" not in self.nlp.pipe_names:
152
+ self.nlp.add_pipe("fix_toponyms", after="lemmatizer")
153
+
154
  def _prepare_bm25(self):
155
  """Подготавливаем BM25 индекс для ключевого поиска"""
156
  # Токенизация для BM25
 
158
  return BM25Okapi(tokenized_chunks)
159
 
160
  def _tokenize_text(self, text: str) -> list[str]:
161
+ """Лемматизация текста для русского языка (spaCy)"""
162
+ # Используем spaCy для обработки текста и получения лемм
163
+ doc = self.nlp(text.lower())
164
+
165
+ # Извлекаем леммы, пропускаем пунктуацию
166
+ lemmas = []
167
+ for token in doc:
168
+ if not token.is_punct and token.lemma_.strip(): # Пропускаем пунктуацию и пробелы
169
+ lemmas.append(token.lemma_)
170
+
171
+ return lemmas
172
 
173
  def rerank_search(self, query: str) -> list[dict]:
174
  """
tests/test_lemmatization.py ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Тест лемматизации для BM25 поиска на русском языке (spaCy с топонимами).
4
+ Проверяет:
5
+ 1. Поиск по топонимам с разными падежами
6
+ 2. Лемматизацию обычных слов (включая беглые гласные)
7
+ 3. Обработку букв Е и Ё
8
+ """
9
+ import re
10
+ import sys
11
+ from pathlib import Path
12
+ from dataclasses import dataclass
13
+
14
+ # Добавляем родительскую директорию в path
15
+ sys.path.insert(0, str(Path(__file__).parent.parent))
16
+
17
+ import spacy
18
+ from rank_bm25 import BM25Okapi
19
+
20
+
21
+ # ============================================================================
22
+ # Тестовые данные
23
+ # ============================================================================
24
+
25
+ @dataclass
26
+ class TestSearch:
27
+ word: str
28
+ sentence: str
29
+
30
+
31
+ # тесты для топонимов (включая разные падежи и формы)
32
+ TESTS_TOPONIMS = [
33
+ TestSearch(word="Ворошиловка", sentence="Он ездил на работу с Ворошиловки до телезавода, а обратно ходил пешком."),
34
+ TestSearch(word='Рязань-1', sentence='Примерно в 1969 году запускаются поезда от станции Рязани-1 до Дягилева и Леска - пара рейсов в день, но всё же.'),
35
+ TestSearch(word='Дягилево', sentence='Примерно в 1969 году запускаются поезда от станции Рязани-1 до Дягилева и Леска - пара рейсов в день, но всё же.'),
36
+ TestSearch(word='Лесок', sentence='Примерно в 1969 году запускаются поезда от станции Рязани-1 до Дягилева и Леска - пара рейсов в день, но всё же.'),
37
+ TestSearch(word="Дашково-Песочня", sentence="31 маршрутка ходила из Дашково-Песочни в Дягилево."),
38
+ TestSearch(word="Ворошиловку", sentence="Он ездил на работу с Ворошиловки до телезавода, а обратно ходил пешком."),
39
+ TestSearch(word='Рязани-1', sentence='Примерно в 1969 году запускаются поезда от станции Рязани-1 до Дягилева и Леска - пара рейсов в день, но всё же.'),
40
+ # TestSearch(word='Дягилева', sentence='Примерно в 1969 году запускаются поезда от станции Рязани-1 до Дягилева и Леска - пара рейсов в день, но всё же.'),
41
+ TestSearch(word='Леске', sentence='Примерно в 1969 году запускаются поезда от станции Рязани-1 до Дягилева и Леска - пара рейсов в день, но всё же.'),
42
+ TestSearch(word="Дашково-Песочню", sentence="31 маршрутка ходила из Дашково-Песочни в Дягилево."),
43
+ #TestSearch(word="Песочни", sentence="31 маршрутка ходила из Дашково-Песочни в Дягилево."),
44
+ #TestSearch(word='Дашково-Песочня', sentence='То есть его продлили дальше в Песочню, и он стал охватывать так называемый основной транспортный коридор города.'),
45
+ ]
46
+
47
+ # тесты для лемматизации обычных слов. Стемминг здесь сломается!
48
+ TESTS_LEMMATIZATION = [
49
+ TestSearch(word='хорошая', sentence='И там в лучшем случае было 20-23 машины где-то так.'),
50
+ TestSearch(word='петли', sentence='И вместо трёх петель осталась одна маленькая в Горроще.') # беглая Е - чередование в корне
51
+ ]
52
+
53
+ # буквы Е и Ё в топонимах и не только
54
+ TESTS_LETTER_E = [
55
+ TestSearch(word='Ленпоселок', sentence='Ленпосёлок — это вообще ныне почти забытый топоним, на мой взгляд, зря.'),
56
+ TestSearch(word='Шлаковый посёлок', sentence='7 ноября 1977 года наконец-то был открыт путепровод на Куйбышевском шоссе, который позволил сократить время в пути автобусом и троллейбусом из центра в Шлаковый поселок.')
57
+ ]
58
+
59
+ # ============================================================================
60
+ # Класс для тестирования BM25 с лемматизацией
61
+ # ============================================================================
62
+
63
+ class RussianBM25:
64
+ """BM25 с лемматизац��ей для русского языка (spaCy с топонимами)"""
65
+
66
+ def __init__(self, documents: list[str]):
67
+ # Загружаем русскую модель spaCy
68
+ try:
69
+ self.nlp = spacy.load("ru_core_news_sm")
70
+ except OSError:
71
+ print("Модель ru_core_news_sm не найдена, скачиваю...")
72
+ import subprocess
73
+ subprocess.check_call(["python", "-m", "spacy", "download", "ru_core_news_sm"])
74
+ self.nlp = spacy.load("ru_core_news_sm")
75
+
76
+ # Загружаем и регистрируем топонимы
77
+ self._register_toponyms()
78
+
79
+ tokenized_docs = [self._tokenize_text(doc) for doc in documents]
80
+ self.bm25 = BM25Okapi(tokenized_docs)
81
+ self.documents = documents
82
+
83
+ def _register_toponyms(self):
84
+ """Загружает топонимы из vocabulary/toponims.txt и регистрирует их в spaCy"""
85
+ toponims_file = Path(__file__).parent.parent / "vocabulary" / "toponims.txt"
86
+
87
+ if not toponims_file.exists():
88
+ print(f" Файл {toponims_file} не найден, управление топонимами пропущено")
89
+ return
90
+
91
+ # Читаем топонимы
92
+ with open(toponims_file, 'r', encoding='utf-8') as f:
93
+ self.toponims = {line.strip().lower(): line.strip().lower() for line in f if line.strip()}
94
+
95
+ print(f" Загружено {len(self.toponims)} топонимов из {toponims_file.name}")
96
+
97
+ # Добавляем custom component для обработки топонимов после лемматизации
98
+ @self.nlp.component("fix_toponyms")
99
+ def fix_toponyms(doc):
100
+ """Компонент для исправления лемм топонимов и их форм"""
101
+ for token in doc:
102
+ lemma_lower = token.lemma_.lower()
103
+
104
+ # Проверяем прямое совпадение
105
+ if lemma_lower in self.toponims:
106
+ token.lemma_ = self.toponims[lemma_lower]
107
+ else:
108
+ # Проверяем, может ли это быть формой топонима
109
+ # Для каждого топонима проверяем, совпадает ли начало слова
110
+ for toponim in self.toponims:
111
+ if lemma_lower.startswith(toponim[:len(lemma_lower)-2]) and len(lemma_lower) > len(toponim) - 3:
112
+ # Похоже, это форма топонима (например, "дягилев" -> "дягилево")
113
+ token.lemma_ = toponim
114
+ break
115
+ return doc
116
+
117
+ # Добавляем компонент после лемматизатора
118
+ if "fix_toponyms" not in self.nlp.pipe_names:
119
+ self.nlp.add_pipe("fix_toponyms", after="lemmatizer")
120
+
121
+ def _tokenize_text(self, text: str) -> list[str]:
122
+ """Лемматизация текста для русского языка (spaCy)"""
123
+ # Используем spaCy для обработки текста и получения лемм
124
+ doc = self.nlp(text.lower())
125
+
126
+ # Извлекаем леммы, пропускаем пунктуацию
127
+ lemmas = []
128
+ for token in doc:
129
+ if not token.is_punct and token.lemma_.strip(): # Пропускаем пунктуацию и пробелы
130
+ lemmas.append(token.lemma_)
131
+
132
+ return lemmas
133
+
134
+ def search(self, query: str) -> list[tuple[int, float]]:
135
+ """
136
+ Поиск по запросу.
137
+ Returns: список (индекс_документа, релевантность_score)
138
+ """
139
+ tokenized_query = self._tokenize_text(query)
140
+ scores = self.bm25.get_scores(tokenized_query)
141
+
142
+ # Возвращаем индексы документов, отсортированные по релевантности
143
+ results = [(i, score) for i, score in enumerate(scores)]
144
+ results.sort(key=lambda x: x[1], reverse=True)
145
+ return results
146
+
147
+
148
+ # ============================================================================
149
+ # Функция для запуска тестов
150
+ # ============================================================================
151
+
152
+ # ============================================================================
153
+ # Функция для запуска тестов
154
+ # ============================================================================
155
+
156
+ def run_test_suite(test_set_name: str, test_set: list[TestSearch]) -> tuple[int, int]:
157
+ """Запускает набор тестов и возвращает (пройдено, провалено)"""
158
+
159
+ print("\n" + "-"*70)
160
+ print(f"НАБОР ТЕСТОВ: {test_set_name}")
161
+ print("-"*70)
162
+
163
+ # Собираем все предложения (документы)
164
+ sentences = [test.sentence for test in test_set]
165
+
166
+ # Инициализируем BM25
167
+ bm25 = RussianBM25(sentences)
168
+
169
+ passed = 0
170
+ failed = 0
171
+ failed_tests = []
172
+
173
+ for i, test in enumerate(test_set, 1):
174
+ word = test.word
175
+ sentence = test.sentence
176
+ expected_index = sentences.index(sentence)
177
+
178
+ # Выполняем поиск
179
+ results = bm25.search(word)
180
+
181
+ # Проверяем, что правильное предложение в топе результатов
182
+ found_index = None
183
+ rank = None
184
+
185
+ for rank_pos, (doc_index, score) in enumerate(results, 1):
186
+ if doc_index == expected_index:
187
+ found_index = doc_index
188
+ rank = rank_pos
189
+ break
190
+
191
+ # Определяем результат теста
192
+ success = found_index is not None and rank <= 3 # Топ-3 результатов
193
+
194
+ status = "✓ PASS" if success else "✗ FAIL"
195
+ passed += 1 if success else 0
196
+ failed += 0 if success else 1
197
+
198
+ print(f"\nТест {i}: {status}")
199
+ print(f" Поиск: '{word}'")
200
+ print(f" Ожидается: '{sentence[:60]}...'")
201
+
202
+ if success:
203
+ print(f" Результат: Найдено на позиции {rank} (score: {results[rank-1][1]:.4f})")
204
+ else:
205
+ # Выводим подробно, что пошло не так
206
+ print(f" Результат: НЕ НАЙДЕНО В ТОП-3")
207
+ if found_index is not None:
208
+ print(f" (Найдено на позиции {rank}, но это слишком далеко)")
209
+
210
+ print(f" Топ результаты (ТОП-5):")
211
+ for pos, (doc_idx, score) in enumerate(results[:5], 1):
212
+ print(f" {pos}. score={score:.4f}: '{sentences[doc_idx][:55]}...'")
213
+
214
+ # Анализ: какие леммы получились?
215
+ doc = bm25.nlp(word.lower())
216
+ query_tokens = [(token.text, token.lemma_) for token in doc if not token.is_punct]
217
+ print(f" Анализ запроса '{word}':")
218
+ for token_text, token_lemma in query_tokens:
219
+ print(f" '{token_text}' -> lemma='{token_lemma}'")
220
+
221
+ # Анализ: какие леммы в предложении?
222
+ doc_sent = bm25.nlp(sentence.lower())
223
+ sent_tokens = [(token.text, token.lemma_) for token in doc_sent if not token.is_punct]
224
+ print(f" Анализ предложения '{sentence[:50]}...':")
225
+ for token_text, token_lemma in sent_tokens[:15]: # Показываем первые 15
226
+ print(f" '{token_text}' -> lemma='{token_lemma}'")
227
+
228
+ failed_tests.append((word, sentence, query_tokens))
229
+
230
+ # Итоговый результат
231
+ print("\n" + "-"*70)
232
+ print(f"РЕЗУЛЬТАТ: {passed} пройдено, {failed} провалено из {len(test_set)}")
233
+
234
+ return passed, failed
235
+
236
+
237
+ def test_lemmatization():
238
+ """Запускает все наборы тестов"""
239
+
240
+ print("\n" + "="*70)
241
+ print("ПОЛНЫЙ ТЕСТ ЛЕММАТИЗАЦИИ ДЛЯ BM25 (spaCy)")
242
+ print("="*70)
243
+
244
+ total_passed = 0
245
+ total_failed = 0
246
+
247
+ # Запускаем все наборы тестов
248
+ for test_name, test_set in [
249
+ ("ТОПОНИМЫ (падежи и словоформы)", TESTS_TOPONIMS),
250
+ ("ЛЕММАТИЗАЦИЯ (беглые гласные)", TESTS_LEMMATIZATION),
251
+ ("ОБРАБОТКА Е/Ё", TESTS_LETTER_E),
252
+ ]:
253
+ if test_set: # Только если есть тесты
254
+ passed, failed = run_test_suite(test_name, test_set)
255
+ total_passed += passed
256
+ total_failed += failed
257
+
258
+ # Финальный результат
259
+ print("\n" + "="*70)
260
+ print(f"ИТОГО: {total_passed} пройдено, {total_failed} провалено из {total_passed + total_failed}")
261
+ print("="*70 + "\n")
262
+
263
+ return total_failed == 0
264
+
265
+
266
+ if __name__ == "__main__":
267
+ success = test_lemmatization()
268
+ sys.exit(0 if success else 1)
vocabulary/toponims.txt ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Рязань
2
+ Семчино
3
+ Недостоево
4
+ Канищево
5
+ Приокский
6
+ Дягилево
7
+ Московский
8
+ Мервино
9
+ Ворошиловка
10
+ Троицкая слобода
11
+ Ситники
12
+ Октябрьский городок
13
+ Дашки
14
+ Ленпосёлок
15
+ Горроща
16
+ Посёлок Южный
17
+ Борки
18
+ Солотча
19
+ Песочня
20
+ Дашково-Песочня
21
+ Шлаковый посёлок
22
+ Соколовка
23
+ Мирный
24
+ Посёлок Строитель
25
+ Рыбное
26
+ Турлатово
27
+ Дядьково