MrSimple07 commited on
Commit
30be7bf
·
1 Parent(s): f3e59e1

simplest version

Browse files
Files changed (3) hide show
  1. documents_prep.py +126 -164
  2. index_retriever.py +3 -3
  3. utils.py +155 -113
documents_prep.py CHANGED
@@ -7,57 +7,71 @@ from llama_index.core.text_splitter import SentenceSplitter
7
  from my_logging import log_message
8
 
9
  # Configuration
10
- CHUNK_SIZE = 1500
11
  CHUNK_OVERLAP = 256
 
12
  def chunk_text_documents(documents):
13
- """Chunk with deduplication"""
14
  text_splitter = SentenceSplitter(
15
  chunk_size=CHUNK_SIZE,
16
- chunk_overlap=300 # Increased overlap
17
  )
18
 
19
- seen_texts = set()
20
  chunked = []
21
-
22
  for doc in documents:
23
- text_normalized = doc.text.strip()
24
- if len(text_normalized) < 50 or text_normalized in seen_texts:
25
- continue
26
-
27
- seen_texts.add(text_normalized)
28
-
29
  chunks = text_splitter.get_nodes_from_documents([doc])
30
  for i, chunk in enumerate(chunks):
31
  chunk.metadata.update({
32
  'chunk_id': i,
33
  'total_chunks': len(chunks),
34
- 'chunk_size': len(chunk.text),
35
- 'document_group': normalize_doc_id(doc.metadata.get('document_id', 'unknown'))
36
  })
37
  chunked.append(chunk)
38
 
 
39
  if chunked:
40
  avg_size = sum(len(c.text) for c in chunked) / len(chunked)
41
- log_message(f"✓ Text: {len(documents)} docs → {len(chunked)} chunks (avg: {avg_size:.0f} chars)")
 
 
 
42
 
43
  return chunked
44
 
45
- def chunk_table_by_rows(table_data, doc_id, max_chars=2000):
46
- """Chunk tables by content size, not fixed rows"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  headers = table_data.get('headers', [])
48
  rows = table_data.get('data', [])
49
  table_num = table_data.get('table_number', 'unknown')
50
  table_title = table_data.get('table_title', '')
51
  section = table_data.get('section', '')
52
 
 
 
 
53
  table_num_clean = str(table_num).strip()
54
 
55
- # Create unique identifier
56
  import re
57
  if 'приложени' in section.lower():
58
  appendix_match = re.search(r'приложени[еия]\s*(\d+|[а-яА-Я])', section.lower())
59
  if appendix_match:
60
- table_identifier = f"{table_num_clean} (Приложение {appendix_match.group(1).upper()})"
 
61
  else:
62
  table_identifier = table_num_clean
63
  else:
@@ -66,161 +80,128 @@ def chunk_table_by_rows(table_data, doc_id, max_chars=2000):
66
  if not rows:
67
  return []
68
 
69
- # Estimate base metadata size
70
- base_content = f"Документ: {doc_id}\nТаблица: {table_identifier}\n"
71
- if table_title:
72
- base_content += f"Название: {table_title}\n"
73
- if section:
74
- base_content += f"Раздел: {section}\n"
75
-
76
- header_content = ""
77
- if headers:
78
- header_content = "Столбцы: " + " | ".join(str(h) for h in headers) + "\n\n"
79
-
80
- base_size = len(base_content) + len(header_content)
81
-
82
- # Group rows by size
83
- chunks = []
84
- current_rows = []
85
- current_size = base_size
86
 
87
- for row in rows:
88
- # Estimate row size
89
- if isinstance(row, dict):
90
- row_str = " | ".join(f"{k}: {v}" for k, v in row.items()
91
- if v and str(v).strip() and str(v).lower() not in ['nan', 'none', ''])
92
- elif isinstance(row, list):
93
- row_str = " | ".join(str(v) for v in row
94
- if v and str(v).strip() and str(v).lower() not in ['nan', 'none', ''])
95
- else:
96
- row_str = str(row)
97
 
98
- row_size = len(row_str) + 2 # +2 for newline
 
 
 
 
 
 
 
 
 
 
99
 
100
- # If adding this row exceeds limit and we have rows, create chunk
101
- if current_size + row_size > max_chars and current_rows:
102
- chunks.append(current_rows[:])
103
- current_rows = []
104
- current_size = base_size
105
 
106
- current_rows.append(row)
107
- current_size += row_size
108
 
109
- # Add remaining rows
110
- if current_rows:
111
- chunks.append(current_rows)
112
 
113
- # Create documents
114
- documents = []
115
- for chunk_idx, chunk_rows in enumerate(chunks):
116
- content = base_content
117
- content += f"Таблица {table_identifier} документа {doc_id}\n"
118
- if len(chunks) > 1:
119
- content += f"Часть {chunk_idx+1} из {len(chunks)}\n"
120
- content += "\n" + header_content
 
 
 
 
121
 
122
- for idx, row in enumerate(chunk_rows, 1):
123
- if isinstance(row, dict):
124
- parts = [f"{k}: {v}" for k, v in row.items()
125
- if v and str(v).strip() and str(v).lower() not in ['nan', 'none', '']]
126
- if parts:
127
- content += f"{idx}. {' | '.join(parts)}\n"
128
- elif isinstance(row, list):
129
- parts = [str(v) for v in row if v and str(v).strip() and str(v).lower() not in ['nan', 'none', '']]
130
- if parts:
131
- content += f"{idx}. {' | '.join(parts)}\n"
132
 
133
  metadata = {
134
  'type': 'table',
135
  'document_id': doc_id,
136
- 'document_group': normalize_doc_id(doc_id),
137
  'table_number': table_num_clean,
138
  'table_identifier': table_identifier,
139
  'table_title': table_title,
140
  'section': section,
141
- 'chunk_id': chunk_idx,
142
- 'total_chunks': len(chunks),
143
- 'chunk_size': len(content),
144
- 'is_complete_table': len(chunks) == 1
 
 
 
145
  }
146
 
147
- documents.append(Document(text=content, metadata=metadata))
148
 
149
- log_message(f" Chunk {chunk_idx+1}: {len(chunk_rows)} rows, {len(content)} chars")
150
- log_message(f" Meta: doc={doc_id}, table={table_identifier}, group={metadata['document_group']}")
151
 
152
- log_message(f" Table {table_identifier} ({doc_id}): {len(rows)} rows → {len(chunks)} chunks")
153
-
154
- return documents
155
 
156
 
157
- def normalize_doc_id(doc_id):
158
- import re
159
- normalized = re.sub(r'\s+', ' ', str(doc_id).strip().upper())
160
- normalized = normalized.replace('ГОСТ Р', 'ГОСТР').replace('ГОСТР', 'ГОСТ Р')
161
- return normalized
162
-
163
-
164
- def format_table_content(table_data, headers, rows, doc_id, table_identifier, chunk_info=""):
165
  table_num = table_data.get('table_number', 'unknown')
166
  table_title = table_data.get('table_title', '')
167
  section = table_data.get('section', '')
168
 
169
- # Build content with multiple search variations
170
  content = f"ДОКУМЕНТ: {doc_id}\n"
171
  content += f"ТАБЛИЦА: {table_identifier}\n"
172
-
173
- # Add search variations for document ID
174
- doc_variations = [doc_id]
175
- if 'Р' in doc_id:
176
- doc_variations.append(doc_id.replace(' Р ', ' Р'))
177
- doc_variations.append(doc_id.replace(' Р ', 'Р'))
178
-
179
- for var in set(doc_variations):
180
- content += f"ДОКУМЕНТ_ВАРИАНТ: {var}\n"
181
-
182
  if table_title:
183
  content += f"НАЗВАНИЕ: {table_title}\n"
184
  if section:
185
  content += f"РАЗДЕЛ: {section}\n"
186
-
187
  content += f"{'='*70}\n\n"
188
 
189
- # Enhanced search text
190
- content += f"Документ {doc_id}. "
191
- content += f"Таблица {table_identifier}. "
192
- content += f"Номер таблицы {table_num}. "
193
-
194
- if table_title:
195
- content += f"Название: {table_title}. "
196
 
197
  if section:
198
- content += f"Раздел: {section}. "
 
 
 
 
 
 
199
 
200
- # Add more search patterns
201
- content += f"Таблицы документа {doc_id}. "
202
- content += f"Содержание {doc_id}. "
203
 
204
  if chunk_info:
205
- content += f"{chunk_info}. "
206
 
207
- content += f"\n\nДАННЫЕ ТАБЛИЦЫ {table_identifier}:\n{'='*70}\n\n"
 
208
 
209
  if headers:
210
- content += f"СТОЛБЦЫ: {' | '.join(str(h) for h in headers)}\n\n"
 
211
 
 
212
  for idx, row in enumerate(rows, 1):
213
  if isinstance(row, dict):
214
  parts = [f"{k}: {v}" for k, v in row.items()
215
- if v and str(v).strip().lower() not in ['nan', 'none', '', 'null']]
216
  if parts:
217
  content += f"{idx}. {' | '.join(parts)}\n"
218
  elif isinstance(row, list):
219
- parts = [str(v) for v in row
220
- if v and str(v).strip().lower() not in ['nan', 'none', '', 'null']]
221
  if parts:
222
  content += f"{idx}. {' | '.join(parts)}\n"
223
 
 
 
 
224
  return content
225
 
226
  def load_json_documents(repo_id, hf_token, json_dir):
@@ -352,6 +333,7 @@ def load_json_documents(repo_id, hf_token, json_dir):
352
  return documents
353
 
354
  def extract_sections_from_json(json_path):
 
355
  documents = []
356
 
357
  try:
@@ -359,8 +341,8 @@ def extract_sections_from_json(json_path):
359
  data = json.load(f)
360
 
361
  doc_id = data.get('document_metadata', {}).get('document_id', 'unknown')
362
- doc_id = normalize_doc_id(doc_id) # NORMALIZE
363
 
 
364
  for section in data.get('sections', []):
365
  if section.get('section_text', '').strip():
366
  documents.append(Document(
@@ -368,11 +350,11 @@ def extract_sections_from_json(json_path):
368
  metadata={
369
  'type': 'text',
370
  'document_id': doc_id,
371
- 'section_id': section.get('section_id', ''),
372
- 'chunk_size': len(section['section_text'])
373
  }
374
  ))
375
 
 
376
  for subsection in section.get('subsections', []):
377
  if subsection.get('subsection_text', '').strip():
378
  documents.append(Document(
@@ -380,11 +362,11 @@ def extract_sections_from_json(json_path):
380
  metadata={
381
  'type': 'text',
382
  'document_id': doc_id,
383
- 'section_id': subsection.get('subsection_id', ''),
384
- 'chunk_size': len(subsection['subsection_text'])
385
  }
386
  ))
387
 
 
388
  for sub_sub in subsection.get('sub_subsections', []):
389
  if sub_sub.get('sub_subsection_text', '').strip():
390
  documents.append(Document(
@@ -392,8 +374,7 @@ def extract_sections_from_json(json_path):
392
  metadata={
393
  'type': 'text',
394
  'document_id': doc_id,
395
- 'section_id': sub_sub.get('sub_subsection_id', ''),
396
- 'chunk_size': len(sub_sub['sub_subsection_text'])
397
  }
398
  ))
399
 
@@ -404,17 +385,13 @@ def extract_sections_from_json(json_path):
404
 
405
 
406
  def load_table_documents(repo_id, hf_token, table_dir):
407
- """Load ALL tables including from multi-document files"""
408
  log_message("Loading tables...")
409
 
410
  files = list_repo_files(repo_id=repo_id, repo_type="dataset", token=hf_token)
411
  table_files = [f for f in files if f.startswith(table_dir) and f.endswith('.json')]
412
 
413
- log_message(f"Found {len(table_files)} table files")
414
-
415
  all_chunks = []
416
- doc_id_stats = {}
417
-
418
  for file_path in table_files:
419
  try:
420
  local_path = hf_hub_download(
@@ -427,40 +404,32 @@ def load_table_documents(repo_id, hf_token, table_dir):
427
  with open(local_path, 'r', encoding='utf-8') as f:
428
  data = json.load(f)
429
 
 
430
  file_doc_id = data.get('document_id', data.get('document', 'unknown'))
431
 
432
  for sheet in data.get('sheets', []):
 
433
  sheet_doc_id = sheet.get('document_id', sheet.get('document', file_doc_id))
434
 
435
- # Track which documents we're loading
436
- if sheet_doc_id not in doc_id_stats:
437
- doc_id_stats[sheet_doc_id] = 0
438
-
439
  chunks = chunk_table_by_rows(sheet, sheet_doc_id)
440
  all_chunks.extend(chunks)
441
- doc_id_stats[sheet_doc_id] += len(chunks)
442
 
443
  except Exception as e:
444
  log_message(f"Error loading {file_path}: {e}")
445
 
446
- # Log what we loaded
447
- log_message(f"\nTable loading summary:")
448
- for doc_id, count in sorted(doc_id_stats.items()):
449
- log_message(f" {doc_id}: {count} chunks")
450
-
451
- log_message(f"\n✓ Total table chunks: {len(all_chunks)}")
452
  return all_chunks
453
 
 
454
  def load_image_documents(repo_id, hf_token, image_dir):
455
- """Load with proper linking"""
456
  log_message("Loading images...")
457
 
458
  files = list_repo_files(repo_id=repo_id, repo_type="dataset", token=hf_token)
459
  csv_files = [f for f in files if f.startswith(image_dir) and f.endswith('.csv')]
460
 
461
  documents = []
462
- seen = set()
463
-
464
  for file_path in csv_files:
465
  try:
466
  local_path = hf_hub_download(
@@ -473,28 +442,22 @@ def load_image_documents(repo_id, hf_token, image_dir):
473
  df = pd.read_csv(local_path)
474
 
475
  for _, row in df.iterrows():
476
- doc_id = str(row.get('Обозначение документа', 'unknown'))
477
- img_num = str(row.get('№ Изображения', 'unknown'))
478
-
479
- key = f"{doc_id}_{img_num}"
480
- if key in seen:
481
- continue
482
- seen.add(key)
483
-
484
- content = f"Документ: {doc_id}\n"
485
- content += f"Рисунок: {img_num}\n"
486
  content += f"Название: {row.get('Название изображения', '')}\n"
487
  content += f"Описание: {row.get('Описание изображение', '')}\n"
 
 
 
488
 
489
  documents.append(Document(
490
  text=content,
491
  metadata={
492
  'type': 'image',
493
- 'document_id': doc_id,
494
- 'document_group': normalize_doc_id(doc_id),
495
- 'image_number': img_num,
496
  'section': str(row.get('Раздел документа', '')),
497
- 'chunk_size': len(content)
498
  }
499
  ))
500
  except Exception as e:
@@ -502,12 +465,11 @@ def load_image_documents(repo_id, hf_token, image_dir):
502
 
503
  if documents:
504
  avg_size = sum(d.metadata['chunk_size'] for d in documents) / len(documents)
505
- log_message(f"✓ Images: {len(documents)} loaded (avg: {avg_size:.0f} chars)")
506
 
507
  return documents
508
 
509
 
510
-
511
  def load_all_documents(repo_id, hf_token, json_dir, table_dir, image_dir):
512
  """Main loader - combines all document types"""
513
  log_message("="*60)
 
7
  from my_logging import log_message
8
 
9
  # Configuration
10
+ CHUNK_SIZE = 1024
11
  CHUNK_OVERLAP = 256
12
+
13
  def chunk_text_documents(documents):
 
14
  text_splitter = SentenceSplitter(
15
  chunk_size=CHUNK_SIZE,
16
+ chunk_overlap=CHUNK_OVERLAP
17
  )
18
 
 
19
  chunked = []
 
20
  for doc in documents:
 
 
 
 
 
 
21
  chunks = text_splitter.get_nodes_from_documents([doc])
22
  for i, chunk in enumerate(chunks):
23
  chunk.metadata.update({
24
  'chunk_id': i,
25
  'total_chunks': len(chunks),
26
+ 'chunk_size': len(chunk.text) # Add chunk size
 
27
  })
28
  chunked.append(chunk)
29
 
30
+ # Log statistics
31
  if chunked:
32
  avg_size = sum(len(c.text) for c in chunked) / len(chunked)
33
+ min_size = min(len(c.text) for c in chunked)
34
+ max_size = max(len(c.text) for c in chunked)
35
+ log_message(f"✓ Text: {len(documents)} docs → {len(chunked)} chunks")
36
+ log_message(f" Size stats: avg={avg_size:.0f}, min={min_size}, max={max_size} chars")
37
 
38
  return chunked
39
 
40
+
41
+ def normalize_doc_id(doc_id):
42
+ """Normalize document ID for consistent matching"""
43
+ if not doc_id or doc_id == 'unknown':
44
+ return doc_id
45
+
46
+ doc_id = str(doc_id).strip()
47
+
48
+ # Normalize spacing: "ГОСТ Р" variations
49
+ import re
50
+ doc_id = re.sub(r'ГОСТ\s*Р', 'ГОСТ Р', doc_id, flags=re.IGNORECASE)
51
+ doc_id = re.sub(r'НП\s*-', 'НП-', doc_id, flags=re.IGNORECASE)
52
+
53
+ return doc_id
54
+
55
+
56
+ def chunk_table_by_rows(table_data, doc_id, max_rows=2):
57
  headers = table_data.get('headers', [])
58
  rows = table_data.get('data', [])
59
  table_num = table_data.get('table_number', 'unknown')
60
  table_title = table_data.get('table_title', '')
61
  section = table_data.get('section', '')
62
 
63
+ # NORMALIZE document ID
64
+ doc_id = normalize_doc_id(doc_id)
65
+
66
  table_num_clean = str(table_num).strip()
67
 
68
+ # Create section-aware identifier
69
  import re
70
  if 'приложени' in section.lower():
71
  appendix_match = re.search(r'приложени[еия]\s*(\d+|[а-яА-Я])', section.lower())
72
  if appendix_match:
73
+ appendix_num = appendix_match.group(1).upper()
74
+ table_identifier = f"{table_num_clean} Приложение {appendix_num}"
75
  else:
76
  table_identifier = table_num_clean
77
  else:
 
80
  if not rows:
81
  return []
82
 
83
+ log_message(f" 📊 Processing: {doc_id} - {table_identifier} ({len(rows)} rows)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
 
85
+ if len(rows) <= max_rows:
86
+ content = format_table_content(table_data, headers, rows, doc_id, table_identifier)
87
+ chunk_size = len(content)
 
 
 
 
 
 
 
88
 
89
+ metadata = {
90
+ 'type': 'table',
91
+ 'document_id': doc_id,
92
+ 'table_number': table_num_clean,
93
+ 'table_identifier': table_identifier,
94
+ 'table_title': table_title,
95
+ 'section': section,
96
+ 'total_rows': len(rows),
97
+ 'chunk_size': chunk_size,
98
+ 'is_complete_table': True
99
+ }
100
 
101
+ log_message(f" Chunk: 1/1, {chunk_size} chars, doc={doc_id}, table={table_identifier}")
 
 
 
 
102
 
103
+ return [Document(text=content, metadata=metadata)]
 
104
 
105
+ chunks = []
106
+ overlap = 1
 
107
 
108
+ for i in range(0, len(rows), max_rows - overlap):
109
+ chunk_rows = rows[i:min(i+max_rows, len(rows))]
110
+ chunk_num = i // (max_rows - overlap)
111
+
112
+ content = format_table_content(
113
+ table_data,
114
+ headers,
115
+ chunk_rows,
116
+ doc_id,
117
+ table_identifier,
118
+ chunk_info=f"Строки {i+1}-{i+len(chunk_rows)} из {len(rows)}"
119
+ )
120
 
121
+ chunk_size = len(content)
 
 
 
 
 
 
 
 
 
122
 
123
  metadata = {
124
  'type': 'table',
125
  'document_id': doc_id,
 
126
  'table_number': table_num_clean,
127
  'table_identifier': table_identifier,
128
  'table_title': table_title,
129
  'section': section,
130
+ 'chunk_id': chunk_num,
131
+ 'row_start': i,
132
+ 'row_end': i + len(chunk_rows),
133
+ 'total_rows': len(rows),
134
+ 'chunk_size': chunk_size,
135
+ 'total_chunks': (len(rows) + max_rows - overlap - 1) // (max_rows - overlap),
136
+ 'is_complete_table': False
137
  }
138
 
139
+ log_message(f" Chunk: {chunk_num+1}, rows {i}-{i+len(chunk_rows)}, {chunk_size} chars")
140
 
141
+ chunks.append(Document(text=content, metadata=metadata))
 
142
 
143
+ return chunks
 
 
144
 
145
 
146
+ def format_table_content(table_data, headers, rows, table_identifier, chunk_info=""):
147
+ doc_id = table_data.get('document_id', table_data.get('document', 'unknown'))
 
 
 
 
 
 
148
  table_num = table_data.get('table_number', 'unknown')
149
  table_title = table_data.get('table_title', '')
150
  section = table_data.get('section', '')
151
 
152
+ # Use enhanced identifier
153
  content = f"ДОКУМЕНТ: {doc_id}\n"
154
  content += f"ТАБЛИЦА: {table_identifier}\n"
155
+ content += f"ПОЛНОЕ НАЗВАНИЕ: {table_identifier}\n"
156
+ content += f"НОМЕР ТАБЛИЦЫ: {table_num}\n"
 
 
 
 
 
 
 
 
157
  if table_title:
158
  content += f"НАЗВАНИЕ: {table_title}\n"
159
  if section:
160
  content += f"РАЗДЕЛ: {section}\n"
 
161
  content += f"{'='*70}\n\n"
162
 
163
+ # Enhanced search keywords
164
+ content += f"Это таблица {table_identifier} из документа {doc_id}. "
165
+ content += f"Идентификатор таблицы: {table_identifier}. "
166
+ content += f"Номер: {table_num}. "
167
+ content += f"Документ: {doc_id}. "
 
 
168
 
169
  if section:
170
+ content += f"Находится в разделе: {section}. "
171
+ if 'приложени' in section.lower():
172
+ content += f"Таблица из приложения. "
173
+
174
+ if table_title:
175
+ content += f"Название таблицы: {table_title}. "
176
+ content += f"Таблица о: {table_title}. "
177
 
178
+ content += f"Поиск: таблица {table_identifier} {doc_id}. "
 
 
179
 
180
  if chunk_info:
181
+ content += f"\n{chunk_info}\n"
182
 
183
+ content += f"\n\nСОДЕРЖИМОЕ ТАБЛИЦЫ {table_identifier}:\n"
184
+ content += f"="*70 + "\n\n"
185
 
186
  if headers:
187
+ header_str = ' | '.join(str(h) for h in headers)
188
+ content += f"ЗАГОЛОВКИ СТОЛБЦОВ:\n{header_str}\n\n"
189
 
190
+ content += f"ДАННЫЕ ТАБЛИЦЫ:\n"
191
  for idx, row in enumerate(rows, 1):
192
  if isinstance(row, dict):
193
  parts = [f"{k}: {v}" for k, v in row.items()
194
+ if v and str(v).strip() and str(v).lower() not in ['nan', 'none', '']]
195
  if parts:
196
  content += f"{idx}. {' | '.join(parts)}\n"
197
  elif isinstance(row, list):
198
+ parts = [str(v) for v in row if v and str(v).strip() and str(v).lower() not in ['nan', 'none', '']]
 
199
  if parts:
200
  content += f"{idx}. {' | '.join(parts)}\n"
201
 
202
+ content += f"\n{'='*70}\n"
203
+ content += f"КОНЕЦ ТАБЛИЦЫ {table_identifier} ИЗ {doc_id}\n"
204
+
205
  return content
206
 
207
  def load_json_documents(repo_id, hf_token, json_dir):
 
333
  return documents
334
 
335
  def extract_sections_from_json(json_path):
336
+ """Extract sections from a single JSON file"""
337
  documents = []
338
 
339
  try:
 
341
  data = json.load(f)
342
 
343
  doc_id = data.get('document_metadata', {}).get('document_id', 'unknown')
 
344
 
345
+ # Extract all section levels
346
  for section in data.get('sections', []):
347
  if section.get('section_text', '').strip():
348
  documents.append(Document(
 
350
  metadata={
351
  'type': 'text',
352
  'document_id': doc_id,
353
+ 'section_id': section.get('section_id', '')
 
354
  }
355
  ))
356
 
357
+ # Subsections
358
  for subsection in section.get('subsections', []):
359
  if subsection.get('subsection_text', '').strip():
360
  documents.append(Document(
 
362
  metadata={
363
  'type': 'text',
364
  'document_id': doc_id,
365
+ 'section_id': subsection.get('subsection_id', '')
 
366
  }
367
  ))
368
 
369
+ # Sub-subsections
370
  for sub_sub in subsection.get('sub_subsections', []):
371
  if sub_sub.get('sub_subsection_text', '').strip():
372
  documents.append(Document(
 
374
  metadata={
375
  'type': 'text',
376
  'document_id': doc_id,
377
+ 'section_id': sub_sub.get('sub_subsection_id', '')
 
378
  }
379
  ))
380
 
 
385
 
386
 
387
  def load_table_documents(repo_id, hf_token, table_dir):
388
+ """Load and chunk tables"""
389
  log_message("Loading tables...")
390
 
391
  files = list_repo_files(repo_id=repo_id, repo_type="dataset", token=hf_token)
392
  table_files = [f for f in files if f.startswith(table_dir) and f.endswith('.json')]
393
 
 
 
394
  all_chunks = []
 
 
395
  for file_path in table_files:
396
  try:
397
  local_path = hf_hub_download(
 
404
  with open(local_path, 'r', encoding='utf-8') as f:
405
  data = json.load(f)
406
 
407
+ # Extract file-level document_id
408
  file_doc_id = data.get('document_id', data.get('document', 'unknown'))
409
 
410
  for sheet in data.get('sheets', []):
411
+ # Use sheet-level document_id if available, otherwise use file-level
412
  sheet_doc_id = sheet.get('document_id', sheet.get('document', file_doc_id))
413
 
414
+ # CRITICAL: Pass document_id to chunk function
 
 
 
415
  chunks = chunk_table_by_rows(sheet, sheet_doc_id)
416
  all_chunks.extend(chunks)
 
417
 
418
  except Exception as e:
419
  log_message(f"Error loading {file_path}: {e}")
420
 
421
+ log_message(f"✓ Loaded {len(all_chunks)} table chunks")
 
 
 
 
 
422
  return all_chunks
423
 
424
+
425
  def load_image_documents(repo_id, hf_token, image_dir):
426
+ """Load image descriptions"""
427
  log_message("Loading images...")
428
 
429
  files = list_repo_files(repo_id=repo_id, repo_type="dataset", token=hf_token)
430
  csv_files = [f for f in files if f.startswith(image_dir) and f.endswith('.csv')]
431
 
432
  documents = []
 
 
433
  for file_path in csv_files:
434
  try:
435
  local_path = hf_hub_download(
 
442
  df = pd.read_csv(local_path)
443
 
444
  for _, row in df.iterrows():
445
+ content = f"Документ: {row.get('Обозначение документа', 'unknown')}\n"
446
+ content += f"Рисунок: {row.get('№ Изображения', 'unknown')}\n"
 
 
 
 
 
 
 
 
447
  content += f"Название: {row.get('Название изображения', '')}\n"
448
  content += f"Описание: {row.get('Описание изображение', '')}\n"
449
+ content += f"Раздел: {row.get('Раздел документа', '')}\n"
450
+
451
+ chunk_size = len(content)
452
 
453
  documents.append(Document(
454
  text=content,
455
  metadata={
456
  'type': 'image',
457
+ 'document_id': str(row.get('Обозначение документа', 'unknown')),
458
+ 'image_number': str(row.get('№ Изображения', 'unknown')),
 
459
  'section': str(row.get('Раздел документа', '')),
460
+ 'chunk_size': chunk_size
461
  }
462
  ))
463
  except Exception as e:
 
465
 
466
  if documents:
467
  avg_size = sum(d.metadata['chunk_size'] for d in documents) / len(documents)
468
+ log_message(f"✓ Loaded {len(documents)} images (avg size: {avg_size:.0f} chars)")
469
 
470
  return documents
471
 
472
 
 
473
  def load_all_documents(repo_id, hf_token, json_dir, table_dir, image_dir):
474
  """Main loader - combines all document types"""
475
  log_message("="*60)
index_retriever.py CHANGED
@@ -35,19 +35,19 @@ def create_query_engine(vector_index):
35
  # Vector retriever
36
  vector_retriever = VectorIndexRetriever(
37
  index=vector_index,
38
- similarity_top_k=40
39
  )
40
 
41
  # BM25 retriever
42
  bm25_retriever = BM25Retriever.from_defaults(
43
  docstore=vector_index.docstore,
44
- similarity_top_k=40
45
  )
46
 
47
  # Hybrid fusion
48
  hybrid_retriever = QueryFusionRetriever(
49
  [vector_retriever, bm25_retriever],
50
- similarity_top_k=50,
51
  num_queries=1
52
  )
53
 
 
35
  # Vector retriever
36
  vector_retriever = VectorIndexRetriever(
37
  index=vector_index,
38
+ similarity_top_k=50
39
  )
40
 
41
  # BM25 retriever
42
  bm25_retriever = BM25Retriever.from_defaults(
43
  docstore=vector_index.docstore,
44
+ similarity_top_k=50
45
  )
46
 
47
  # Hybrid fusion
48
  hybrid_retriever = QueryFusionRetriever(
49
  [vector_retriever, bm25_retriever],
50
+ similarity_top_k=60,
51
  num_queries=1
52
  )
53
 
utils.py CHANGED
@@ -40,96 +40,74 @@ def preprocess_query(question):
40
  import re
41
 
42
  question_lower = question.lower()
 
 
 
 
43
  enhanced_query = question
44
 
45
- # Detect "list all tables" queries - handle differently
46
- if any(phrase in question_lower for phrase in ['какие таблиц', 'список таблиц', 'перечисл', 'все таблиц']):
47
- # For listing queries, just extract document ID
48
- doc_match = re.search(r'(гост|нп|му)[^\s]*\s*р?\s*[№-]*\s*([0-9\.-]+)', question_lower)
49
- if doc_match:
50
- doc_id = f"{doc_match.group(1).upper()} Р {doc_match.group(2)}"
51
- enhanced_query = f"документ {doc_id} таблица"
52
- return enhanced_query
53
-
54
- # For specific table queries
55
- table_match = re.search(r'табли[цу]\w*\s+(?:№|номер)?\s*([а-яa-z0-9\.]+)', question_lower)
56
- if table_match:
57
- table_num = table_match.group(1).upper()
58
- enhanced_query += f" таблица {table_num}"
59
-
60
- # Document detection
61
- doc_match = re.search(r'(гост|нп|му)[^\s]*\s*р?\s*[№-]*\s*([0-9\.-]+)', question_lower)
62
  if doc_match:
63
- doc_id = f"{doc_match.group(1).upper()} Р {doc_match.group(2)}"
64
- enhanced_query += f" документ {doc_id}"
 
 
 
65
 
66
  return enhanced_query
67
 
68
  def answer_question(question, query_engine, reranker):
69
  try:
70
- log_message(f"\n{'='*70}")
71
- log_message(f"QUERY: {question}")
72
 
73
  enhanced_query = preprocess_query(question)
74
- log_message(f"Enhanced: {enhanced_query}")
75
-
76
- # Detect listing queries - need MORE chunks
77
- is_listing_query = any(phrase in question.lower()
78
- for phrase in ['какие таблиц', 'список', 'перечисл', 'все таблиц'])
79
 
80
  retrieved = query_engine.retriever.retrieve(enhanced_query)
81
- log_message(f"\nRETRIEVED: {len(retrieved)} nodes")
82
 
83
- # Log retrieved docs
84
  doc_stats = {}
85
  for n in retrieved:
86
  doc_id = n.metadata.get('document_id', 'unknown')
87
- doc_group = n.metadata.get('document_group', doc_id)
88
 
89
- if doc_group not in doc_stats:
90
- doc_stats[doc_group] = {'tables': set(), 'text': 0}
91
 
92
- if n.metadata.get('type') == 'table':
93
  table_id = n.metadata.get('table_identifier', n.metadata.get('table_number', '?'))
94
- doc_stats[doc_group]['tables'].add(table_id)
 
 
95
  else:
96
- doc_stats[doc_group]['text'] += 1
97
 
98
  for doc_id in sorted(doc_stats.keys()):
99
  stats = doc_stats[doc_id]
100
- log_message(f" {doc_id}: {len(stats['tables'])} tables, {stats['text']} text")
101
  if stats['tables']:
102
- log_message(f" Tables: {sorted(stats['tables'])}")
103
-
104
- # Adjust reranking based on query type
105
- if is_listing_query:
106
- reranked = rerank_nodes(question, retrieved, reranker, top_k=50, min_score=0.2)
107
- else:
108
- reranked = rerank_nodes(question, retrieved, reranker, top_k=25, min_score=0.3)
109
-
110
- log_message(f"\nRERANKED: {len(reranked)} nodes")
 
 
 
111
 
112
- # Log reranked
113
- doc_stats_reranked = {}
114
- for n in reranked:
115
- doc_group = n.metadata.get('document_group', n.metadata.get('document_id', 'unknown'))
116
-
117
- if doc_group not in doc_stats_reranked:
118
- doc_stats_reranked[doc_group] = {'tables': set(), 'text': 0}
119
-
120
- if n.metadata.get('type') == 'table':
121
- table_id = n.metadata.get('table_identifier', n.metadata.get('table_number', '?'))
122
- doc_stats_reranked[doc_group]['tables'].add(table_id)
123
- else:
124
- doc_stats_reranked[doc_group]['text'] += 1
125
 
126
- for doc_id in sorted(doc_stats_reranked.keys()):
127
- stats = doc_stats_reranked[doc_id]
128
- log_message(f" {doc_id}: {len(stats['tables'])} tables, {stats['text']} text")
129
- if stats['tables']:
130
- log_message(f" Tables: {sorted(stats['tables'])}")
131
 
132
- # Build context
133
  context_parts = []
134
  for n in reranked:
135
  meta = n.metadata
@@ -137,48 +115,103 @@ def answer_question(question, query_engine, reranker):
137
  doc_type = meta.get('type', 'text')
138
 
139
  if doc_type == 'table':
140
- table_id = meta.get('table_identifier', meta.get('table_number', 'unknown'))
141
  title = meta.get('table_title', '')
142
- source_label = f"[{doc_id} - Таблица {table_id}]"
143
  if title:
144
  source_label += f" {title}"
 
 
 
145
  else:
146
- source_label = f"[{doc_id}]"
 
147
 
148
- context_parts.append(f"{source_label}\n{n.text[:500]}") # Limit context per chunk
149
-
150
- context = "\n\n" + ("="*50 + "\n\n").join(context_parts)
151
 
152
- # Adjust prompt for listing queries
153
- if is_listing_query:
154
- prompt = f"""Контекст содержит информацию о таблицах из документов.
 
155
 
156
- КОНТЕКСТ:
157
- {context}
158
 
159
- ВОПРОС: {question}
 
160
 
161
- ИНСТРУКЦИИ:
162
- 1. Перечисли ВСЕ таблицы, найденные в контексте для запрошенного документа
163
- 2. Укажи номер таблицы и название (если есть)
164
- 3. Если таблиц нет - скажи прямо
165
 
166
- ОТВЕТ (список таблиц):"""
167
- else:
168
- prompt = f"""Ты эксперт по технической документации.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
 
170
  КОНТЕКСТ:
171
  {context}
172
 
173
  ВОПРОС: {question}
174
-
175
- ИНСТРУКЦИИ:
176
- 1. Отвечай ТОЛЬКО на основе контекста
177
- 2. Укажи источник (документ, таблицу)
178
- 3. Если нужно показать содержимое таблицы - покажи ВСЕ данные
179
- 4. Если информации нет - скажи прямо
180
-
181
- ОТВЕТ:"""
182
 
183
  response = query_engine.query(prompt)
184
  sources = format_sources(reranked)
@@ -190,45 +223,54 @@ def answer_question(question, query_engine, reranker):
190
  import traceback
191
  log_message(traceback.format_exc())
192
  return f"Ошибка: {e}", ""
193
- def rerank_nodes(query, nodes, reranker, top_k=25, min_score=0.3):
194
- """Rerank with document grouping awareness"""
 
195
  if not nodes:
196
  return []
197
 
 
198
  pairs = [[query, n.text] for n in nodes]
199
  scores = reranker.predict(pairs)
200
 
 
201
  scored = sorted(zip(nodes, scores), key=lambda x: x[1], reverse=True)
202
 
203
- log_message(f"Top 10 reranking scores: {[f'{s:.3f}' for _, s in scored[:10]]}")
204
-
205
- # More lenient filtering
206
  filtered = [(n, s) for n, s in scored if s >= min_score]
207
 
208
  if not filtered:
209
- cutoff = max(scores) * 0.4
 
210
  filtered = [(n, s) for n, s in scored if s >= cutoff][:top_k]
211
 
212
- # Group by document for diversity
213
- doc_groups = {}
214
- for node, score in filtered:
215
- doc_group = node.metadata.get('document_group', node.metadata.get('document_id', 'unknown'))
216
- if doc_group not in doc_groups:
217
- doc_groups[doc_group] = []
218
- doc_groups[doc_group].append((node, score))
219
 
220
- # Take top chunks from each document group
221
  selected = []
222
- group_limits = max(3, top_k // max(1, len(doc_groups)))
 
 
 
 
 
 
 
 
223
 
224
- for doc_group in doc_groups:
225
- selected.extend([n for n, s in doc_groups[doc_group][:group_limits]])
 
 
 
226
 
227
- # Fill remaining slots with highest scores
228
- if len(selected) < top_k:
229
- remaining = [n for n, s in filtered if n not in selected]
230
- selected.extend(remaining[:top_k - len(selected)])
 
231
 
232
- log_message(f"Reranked: {len(filtered)} → {len(selected)} (from {len(doc_groups)} doc groups)")
233
 
234
- return selected[:top_k]
 
40
  import re
41
 
42
  question_lower = question.lower()
43
+
44
+ # Extract document ID and normalize
45
+ doc_match = re.search(r'(гост|нп|му)\s*р?\s*[№-]*\s*([0-9\.-]+)', question_lower)
46
+
47
  enhanced_query = question
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  if doc_match:
50
+ doc_type = doc_match.group(1).upper()
51
+ doc_num = doc_match.group(2)
52
+
53
+ # Add normalized versions
54
+ enhanced_query += f" {doc_type} Р {doc_num}"
55
 
56
  return enhanced_query
57
 
58
  def answer_question(question, query_engine, reranker):
59
  try:
60
+ log_message(f"Query: {question}")
 
61
 
62
  enhanced_query = preprocess_query(question)
63
+ if enhanced_query != question:
64
+ log_message(f"Enhanced query: {enhanced_query}")
 
 
 
65
 
66
  retrieved = query_engine.retriever.retrieve(enhanced_query)
67
+ log_message(f"Retrieved {len(retrieved)} nodes")
68
 
 
69
  doc_stats = {}
70
  for n in retrieved:
71
  doc_id = n.metadata.get('document_id', 'unknown')
72
+ doc_type = n.metadata.get('type', 'text')
73
 
74
+ if doc_id not in doc_stats:
75
+ doc_stats[doc_id] = {'tables': set(), 'text': 0, 'images': 0}
76
 
77
+ if doc_type == 'table':
78
  table_id = n.metadata.get('table_identifier', n.metadata.get('table_number', '?'))
79
+ doc_stats[doc_id]['tables'].add(table_id)
80
+ elif doc_type == 'image':
81
+ doc_stats[doc_id]['images'] += 1
82
  else:
83
+ doc_stats[doc_id]['text'] += 1
84
 
85
  for doc_id in sorted(doc_stats.keys()):
86
  stats = doc_stats[doc_id]
87
+ parts = []
88
  if stats['tables']:
89
+ parts.append(f"tables={list(stats['tables'])[:5]}")
90
+ if stats['text']:
91
+ parts.append(f"text={stats['text']}")
92
+ if stats['images']:
93
+ parts.append(f"images={stats['images']}")
94
+ log_message(f" {doc_id}: {', '.join(parts)}")
95
+
96
+ doc_ids = [n.metadata.get('document_id', 'unknown') for n in retrieved]
97
+ table_nums = [n.metadata.get('table_number', '') for n in retrieved if n.metadata.get('type') == 'table']
98
+ log_message(f"Retrieved from documents: {set(doc_ids)}")
99
+ if table_nums:
100
+ log_message(f"Retrieved tables: {set(table_nums)}")
101
 
102
+ reranked = rerank_nodes(question, retrieved, reranker, top_k=25)
103
+ log_message(f"Reranked to {len(reranked)} nodes")
 
 
 
 
 
 
 
 
 
 
 
104
 
105
+ doc_ids_reranked = [n.metadata.get('document_id', 'unknown') for n in reranked]
106
+ table_nums_reranked = [n.metadata.get('table_number', '') for n in reranked if n.metadata.get('type') == 'table']
107
+ log_message(f"After reranking - documents: {set(doc_ids_reranked)}")
108
+ if table_nums_reranked:
109
+ log_message(f"After reranking - tables: {set(table_nums_reranked)}")
110
 
 
111
  context_parts = []
112
  for n in reranked:
113
  meta = n.metadata
 
115
  doc_type = meta.get('type', 'text')
116
 
117
  if doc_type == 'table':
118
+ table_num = meta.get('table_number', 'unknown')
119
  title = meta.get('table_title', '')
120
+ source_label = f"[ТАБЛИЦА {table_num} - {doc_id}]"
121
  if title:
122
  source_label += f" {title}"
123
+ elif doc_type == 'image':
124
+ img_num = meta.get('image_number', 'unknown')
125
+ source_label = f"[РИСУНОК {img_num} - {doc_id}]"
126
  else:
127
+ section = meta.get('section_id', '')
128
+ source_label = f"[{doc_id} - {section}]"
129
 
130
+ context_parts.append(f"{source_label}\n{n.text}")
 
 
131
 
132
+ context = "\n\n" + ("="*70 + "\n\n").join(context_parts)
133
+ from config import CUSTOM_PROMPT
134
+ prompt = f"""
135
+ Вы являетесь высокоспециализированным Ассистентом для анализа нормативных документов (AIEXP). Ваша цель - предоставлять точные, корректные и контекстно релевантные ответы исключительно на основе предоставленного контекста из нормативной документации.
136
 
137
+ ПРАВИЛА АНАЛИЗА ЗАПРОСА:
 
138
 
139
+ 1. ПРЯМЫЕ ВОПРОСЫ БЕЗ ДОКУМЕНТАЛЬНОГО КОНТЕКСТА:
140
+ Если пользователь задает вопрос типа "В каких случаях могут быть признаны протоколы испытаний?" без предоставления дополнительных документов, найдите соответствующую информацию в доступном контексте и предоставьте полный ответ с указанием источников.
141
 
142
+ 2. ОПРЕДЕЛЕНИЕ ТИПА ЗАДАЧИ:
 
 
 
143
 
144
+ а) ПОИСК И ОТВЕТ НА ВОПРОС (ключевые слова: "в каких случаях", "когда", "кто", "что", "как", "почему"):
145
+ - Найдите релевантную информацию в контексте
146
+ - Предоставьте развернутый ответ
147
+ - Обязательно укажите конкретные документы и разделы
148
+ - Процитируйте ключевые положения
149
+
150
+ б) КРАТКОЕ САММАРИ (ключевые слова: "кратко", "суммировать", "резюме", "основные моменты"):
151
+ - Предоставьте структурированное резюме
152
+ - Выделите ключевые требования
153
+ - Используйте нумерованный список
154
+
155
+ в) ПОИСК ДОКУМЕНТА И ПУНКТА (ключевые слова: "найти", "где", "какой документ", "в каком разделе"):
156
+ - Укажите конкретный документ и структурное расположение
157
+ - Предоставьте точные номера разделов/пунктов
158
+
159
+ г) ПРОВЕРКА КОРРЕКТНОСТИ (ключевые слова: "правильно ли", "соответствует ли", "проверить"):
160
+ - Четко укажите: "СООТВЕТСТВУЕТ" или "НЕ СООТВЕТСТВУЕТ"
161
+ - Перечислите конкретные требования
162
+
163
+ д) ПЛАН ДЕЙСТВИЙ (ключевые слова: "план", "алгоритм", "пошагово"):
164
+ - Создайте пронумерованный план
165
+ - Укажите ссылки на соответствующие пункты НД
166
+
167
+ ПРАВИЛА ФОРМИРОВАНИЯ ОТВЕТОВ:
168
+
169
+ Работай исключительно с информацией из предоставленного контекста. Запрещено использовать:
170
+ - Общие знания
171
+ - Информацию из интернета
172
+ - Данные из предыдущих диалогов
173
+ - Собственные предположения
174
+
175
+ 1. СТРУКТУРА ОТВЕТА:
176
+ - Начинайте с прямого ответа на вопрос
177
+ - Затем указывайте нормативные основания
178
+ - Завершайте ссылками на конкретные документы и разделы
179
+
180
+ 2. РАБОТА С КОНТЕКСТОМ:
181
+ - Если информация найдена в контексте - предоставьте полный ответ
182
+ - Если информация не найдена: "Информация по вашему запросу не найдена в доступной нормативной документации"
183
+ - Не делайте предположений за пределами контекста
184
+ - Не используйте общие знания
185
+
186
+ 3. ТЕРМИНОЛОГИЯ И ЦИТИРОВАНИЕ:
187
+ - Сохраняйте официальную терминологию НД
188
+ - Цитируйте точные формулировки ключевых требований
189
+ - При множественных источниках - укажите все релевантные
190
+
191
+ 4. ФОРМАТИРОВАНИЕ:
192
+ - Для перечислений: используйте нумерованные списки
193
+ - Выделяйте критически важные требования
194
+ - Структурируйте ответ логически
195
+
196
+ # КАК РАБОТАТЬ С ЗАПРОСОМ
197
+
198
+ **Шаг 1:** Определи, что именно ищет пользователь (термин, требование, процедура, условие)
199
+
200
+ **Шаг 2:** Найди релевантную информацию в контексте
201
+
202
+ **Шаг 3:** Сформируй ответ:
203
+ - Если нашел: укажи документ и пункт, процитируй нужную часть
204
+ - Если не нашел: четко сообщи об отсутствии информации
205
+
206
+ **Шаг 4:** При наличии нескольких источников:
207
+ - Представь их последовательно с указанием источника каждого
208
+ - Если источников много (>4) — сначала дай их список, потом цитаты
209
 
210
  КОНТЕКСТ:
211
  {context}
212
 
213
  ВОПРОС: {question}
214
+ """
 
 
 
 
 
 
 
215
 
216
  response = query_engine.query(prompt)
217
  sources = format_sources(reranked)
 
223
  import traceback
224
  log_message(traceback.format_exc())
225
  return f"Ошибка: {e}", ""
226
+
227
+ def rerank_nodes(query, nodes, reranker, top_k=30, min_score=0.3):
228
+ """Rerank nodes with diversity - MORE LENIENT"""
229
  if not nodes:
230
  return []
231
 
232
+ # Score all nodes
233
  pairs = [[query, n.text] for n in nodes]
234
  scores = reranker.predict(pairs)
235
 
236
+ # Sort by score
237
  scored = sorted(zip(nodes, scores), key=lambda x: x[1], reverse=True)
238
 
239
+ # More lenient threshold
 
 
240
  filtered = [(n, s) for n, s in scored if s >= min_score]
241
 
242
  if not filtered:
243
+ # Fallback: take top 50% if nothing passes threshold
244
+ cutoff = max(scores) * 0.5
245
  filtered = [(n, s) for n, s in scored if s >= cutoff][:top_k]
246
 
247
+ # Log top scores for debugging
248
+ log_message(f"Top 5 reranking scores: {[f'{s:.3f}' for _, s in scored[:5]]}")
 
 
 
 
 
249
 
250
+ # Diversity selection - but prioritize tables if query mentions them
251
  selected = []
252
+ seen_docs = set()
253
+ table_nodes = []
254
+ other_nodes = []
255
+
256
+ for node, score in filtered:
257
+ if node.metadata.get('type') == 'table':
258
+ table_nodes.append((node, score))
259
+ else:
260
+ other_nodes.append((node, score))
261
 
262
+ # If query mentions "таблица", prioritize table nodes
263
+ if 'таблиц' in query.lower():
264
+ combined = table_nodes + other_nodes
265
+ else:
266
+ combined = filtered
267
 
268
+ for node, score in combined[:top_k]:
269
+ if len(selected) >= top_k:
270
+ break
271
+ selected.append(node)
272
+ seen_docs.add(node.metadata.get('document_id', 'unknown'))
273
 
274
+ log_message(f"Reranked: {len(filtered)} → {len(selected)} (from {len(seen_docs)} docs)")
275
 
276
+ return selected