rebuild_index.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. #!/usr/bin/env python3
  2. """
  3. Rebuild RAG index with improved chunking:
  4. - Remove file headers and page markers
  5. - Strict chunk size enforcement
  6. - Better paragraph-aware splitting
  7. - Filter out empty/noise chunks
  8. """
  9. import json
  10. import re
  11. import math
  12. from pathlib import Path
  13. from collections import Counter
  14. from config import cfg
  15. PROCESSED_DIR = cfg.processed_dir
  16. INDEX_FILE = cfg.index_file
  17. KEYWORD_FILE = cfg.keyword_file
  18. CHUNK_SIZE = 512
  19. CHUNK_OVERLAP = 64
  20. MIN_CHUNK_LEN = 30 # Discard chunks shorter than this
  21. def clean_text(text, filename):
  22. """Remove noise from extracted PDF text and merge broken lines"""
  23. # Remove file header line
  24. text = re.sub(r'^# 源文件:.*\n*', '', text)
  25. # Remove page markers
  26. text = re.sub(r'--- 第\d+页 ---\n?', '', text)
  27. # Remove leading/trailing whitespace per line
  28. lines = [line.strip() for line in text.split('\n')]
  29. # Merge broken lines from PDF extraction.
  30. # PDF text has line breaks from page layout, not real paragraph breaks.
  31. # Strategy: only treat as paragraph break if previous text ends with
  32. # sentence-ending punctuation AND there's a blank line.
  33. merged = []
  34. buf = ''
  35. blank_count = 0
  36. for line in lines:
  37. if not line:
  38. blank_count += 1
  39. continue
  40. if buf:
  41. last_char = buf[-1] if buf else ''
  42. ends_sentence = last_char in '。!?;:)》」』"'
  43. # Real paragraph break = ended with punctuation + had blank line(s)
  44. if ends_sentence and blank_count > 0:
  45. merged.append(buf)
  46. merged.append('') # preserve paragraph separator
  47. buf = line
  48. else:
  49. # Merge: either no blank line, or previous didn't end sentence
  50. buf += line
  51. else:
  52. buf = line
  53. blank_count = 0
  54. if buf:
  55. merged.append(buf)
  56. text = '\n'.join(merged)
  57. # Collapse multiple blank lines
  58. text = re.sub(r'\n{3,}', '\n\n', text)
  59. return text.strip()
  60. def _smart_split(text, max_len, overlap):
  61. """Split long text at natural break points (punctuation), not mid-word"""
  62. # Priority: sentence-end > clause-end > comma > any punct > force
  63. break_chars = '。!?;\n'
  64. soft_break_chars = ',、:)》」』"'
  65. pieces = []
  66. start = 0
  67. while start < len(text):
  68. if start + max_len >= len(text):
  69. pieces.append(text[start:])
  70. break
  71. end = start + max_len
  72. # Look backwards for best break point
  73. best = -1
  74. # First try sentence-ending punctuation
  75. for i in range(end, max(start + max_len // 2, start), -1):
  76. if i < len(text) and text[i] in break_chars:
  77. best = i + 1
  78. break
  79. # If not found, try soft breaks (comma etc)
  80. if best == -1:
  81. for i in range(end, max(start + max_len // 2, start), -1):
  82. if i < len(text) and text[i] in soft_break_chars:
  83. best = i + 1
  84. break
  85. # Last resort: hard cut
  86. if best == -1:
  87. best = end
  88. pieces.append(text[start:best])
  89. # Overlap: go back a bit
  90. start = max(best - overlap, start + 1)
  91. if start >= len(text):
  92. break
  93. return pieces
  94. def split_chunks(text, source_file):
  95. """Split text into chunks with strict size enforcement.
  96. Tables marked with [TABLE_START]...[TABLE_END] are kept as whole chunks."""
  97. chunks = []
  98. chunk_id = 0
  99. # First: extract tables as separate blocks
  100. # Split text into table blocks and text blocks
  101. parts = re.split(r'(\[TABLE_START\].*?\[TABLE_END\])', text, flags=re.DOTALL)
  102. all_paragraphs = []
  103. for part in parts:
  104. part = part.strip()
  105. if not part:
  106. continue
  107. if part.startswith('[TABLE_START]'):
  108. # Keep table as a single "paragraph" (won't be split further)
  109. table_content = part.replace('[TABLE_START]', '').replace('[TABLE_END]', '').strip()
  110. if table_content:
  111. all_paragraphs.append(('table', table_content))
  112. else:
  113. # Regular text: split into paragraphs
  114. for para in re.split(r'\n\s*\n', part):
  115. para = para.strip()
  116. if para and len(para) >= 5:
  117. all_paragraphs.append(('text', para))
  118. # Now process paragraphs
  119. paragraphs_iter = all_paragraphs
  120. current_chunk = ''
  121. for ptype, para in paragraphs_iter:
  122. # Tables: always emit as their own chunk(s)
  123. if ptype == 'table':
  124. # Flush current text chunk first
  125. if current_chunk and len(current_chunk) >= MIN_CHUNK_LEN:
  126. chunks.append({
  127. 'id': f'{source_file}#chunk{chunk_id}',
  128. 'source': source_file,
  129. 'chunk_id': chunk_id,
  130. 'text': current_chunk,
  131. 'length': len(current_chunk),
  132. })
  133. chunk_id += 1
  134. current_chunk = ''
  135. # If table fits in one chunk, keep whole; otherwise split by rows
  136. if len(para) <= CHUNK_SIZE * 2: # Allow tables up to 2x chunk size
  137. chunks.append({
  138. 'id': f'{source_file}#chunk{chunk_id}',
  139. 'source': source_file,
  140. 'chunk_id': chunk_id,
  141. 'text': para,
  142. 'length': len(para),
  143. })
  144. chunk_id += 1
  145. else:
  146. # Large table: split keeping header + groups of rows
  147. lines = para.split('\n')
  148. header = '\n'.join(lines[:2]) if len(lines) > 2 else '' # header + separator
  149. table_chunk = header
  150. for line in lines[2:]:
  151. if len(table_chunk) + len(line) + 1 > CHUNK_SIZE * 2:
  152. if table_chunk:
  153. chunks.append({
  154. 'id': f'{source_file}#chunk{chunk_id}',
  155. 'source': source_file,
  156. 'chunk_id': chunk_id,
  157. 'text': table_chunk,
  158. 'length': len(table_chunk),
  159. })
  160. chunk_id += 1
  161. table_chunk = header + '\n' + line # restart with header
  162. else:
  163. table_chunk += '\n' + line
  164. if table_chunk and len(table_chunk) >= MIN_CHUNK_LEN:
  165. chunks.append({
  166. 'id': f'{source_file}#chunk{chunk_id}',
  167. 'source': source_file,
  168. 'chunk_id': chunk_id,
  169. 'text': table_chunk,
  170. 'length': len(table_chunk),
  171. })
  172. chunk_id += 1
  173. continue
  174. # Regular text paragraph
  175. # If adding this paragraph fits in chunk_size
  176. if len(current_chunk) + len(para) + 1 <= CHUNK_SIZE:
  177. current_chunk = (current_chunk + '\n' + para).strip() if current_chunk else para
  178. else:
  179. # Save current chunk if it has content
  180. if current_chunk and len(current_chunk) >= MIN_CHUNK_LEN:
  181. chunks.append({
  182. 'id': f'{source_file}#chunk{chunk_id}',
  183. 'source': source_file,
  184. 'chunk_id': chunk_id,
  185. 'text': current_chunk,
  186. 'length': len(current_chunk),
  187. })
  188. chunk_id += 1
  189. # If paragraph itself is too long, split by sentences/characters
  190. if len(para) > CHUNK_SIZE:
  191. # Try sentence splitting first (at major punctuation)
  192. sentences = re.split(r'(?<=[。!?;\n])', para)
  193. # If no split happened (single long sentence), try comma-level
  194. if len(sentences) <= 1 and len(para) > CHUNK_SIZE:
  195. sentences = _smart_split(para, CHUNK_SIZE, CHUNK_OVERLAP)
  196. # Already split into right-sized pieces, emit directly
  197. for piece in sentences:
  198. if len(piece.strip()) >= MIN_CHUNK_LEN:
  199. chunks.append({
  200. 'id': f'{source_file}#chunk{chunk_id}',
  201. 'source': source_file,
  202. 'chunk_id': chunk_id,
  203. 'text': piece.strip(),
  204. 'length': len(piece.strip()),
  205. })
  206. chunk_id += 1
  207. current_chunk = ''
  208. continue
  209. sub_chunk = ''
  210. for sent in sentences:
  211. sent = sent.strip()
  212. if not sent:
  213. continue
  214. if len(sub_chunk) + len(sent) + 1 <= CHUNK_SIZE:
  215. sub_chunk = (sub_chunk + sent).strip() if sub_chunk else sent
  216. else:
  217. if sub_chunk and len(sub_chunk) >= MIN_CHUNK_LEN:
  218. chunks.append({
  219. 'id': f'{source_file}#chunk{chunk_id}',
  220. 'source': source_file,
  221. 'chunk_id': chunk_id,
  222. 'text': sub_chunk,
  223. 'length': len(sub_chunk),
  224. })
  225. chunk_id += 1
  226. # If single sentence > CHUNK_SIZE, smart split at punctuation
  227. if len(sent) > CHUNK_SIZE:
  228. pieces = _smart_split(sent, CHUNK_SIZE, CHUNK_OVERLAP)
  229. for piece in pieces:
  230. if len(piece) >= MIN_CHUNK_LEN:
  231. chunks.append({
  232. 'id': f'{source_file}#chunk{chunk_id}',
  233. 'source': source_file,
  234. 'chunk_id': chunk_id,
  235. 'text': piece,
  236. 'length': len(piece),
  237. })
  238. chunk_id += 1
  239. sub_chunk = ''
  240. else:
  241. sub_chunk = sent
  242. # Don't forget remaining sub_chunk
  243. if sub_chunk and len(sub_chunk) >= MIN_CHUNK_LEN:
  244. current_chunk = sub_chunk
  245. else:
  246. current_chunk = ''
  247. else:
  248. # Start new chunk with overlap from previous
  249. overlap = current_chunk[-CHUNK_OVERLAP:] if len(current_chunk) > CHUNK_OVERLAP else ''
  250. current_chunk = (overlap + '\n' + para).strip() if overlap else para
  251. # Last chunk
  252. if current_chunk and len(current_chunk) >= MIN_CHUNK_LEN:
  253. chunks.append({
  254. 'id': f'{source_file}#chunk{chunk_id}',
  255. 'source': source_file,
  256. 'chunk_id': chunk_id,
  257. 'text': current_chunk,
  258. 'length': len(current_chunk),
  259. })
  260. return chunks
  261. # Build index
  262. all_chunks = []
  263. txt_files = sorted(PROCESSED_DIR.glob('*.txt'))
  264. print(f'Processing {len(txt_files)} text files...')
  265. for i, txt_path in enumerate(txt_files):
  266. text = txt_path.read_text(encoding='utf-8')
  267. text = clean_text(text, txt_path.stem)
  268. if len(text) < 30:
  269. continue
  270. chunks = split_chunks(text, txt_path.stem)
  271. all_chunks.extend(chunks)
  272. if (i + 1) % 200 == 0:
  273. print(f' Progress: {i+1}/{len(txt_files)}, chunks: {len(all_chunks)}')
  274. # Build keyword inverted index
  275. print(f'Building keyword index... ({len(all_chunks)} chunks)')
  276. keyword_index = {}
  277. for chunk in all_chunks:
  278. words = set()
  279. text = chunk['text'].lower()
  280. # Chinese bigram + trigram
  281. chinese_chars = re.findall(r'[\u4e00-\u9fff]+', text)
  282. for cc in chinese_chars:
  283. for j in range(len(cc) - 1):
  284. words.add(cc[j:j+2])
  285. if len(cc) >= 3:
  286. for j in range(len(cc) - 2):
  287. words.add(cc[j:j+3])
  288. # English words
  289. eng_words = re.findall(r'[a-z]+', text)
  290. words.update(eng_words)
  291. for w in words:
  292. if w not in keyword_index:
  293. keyword_index[w] = []
  294. keyword_index[w].append(chunk['id'])
  295. # Save
  296. index_data = {
  297. 'total_chunks': len(all_chunks),
  298. 'total_files': len(txt_files),
  299. 'chunk_size': CHUNK_SIZE,
  300. 'chunks': all_chunks,
  301. 'keyword_index_size': len(keyword_index),
  302. }
  303. with open(INDEX_FILE, 'w', encoding='utf-8') as f:
  304. json.dump(index_data, f, ensure_ascii=False)
  305. with open(KEYWORD_FILE, 'w', encoding='utf-8') as f:
  306. json.dump(keyword_index, f, ensure_ascii=False)
  307. # Stats
  308. lengths = [c['length'] for c in all_chunks]
  309. print(f'\nDone!')
  310. print(f' Total chunks: {len(all_chunks)}')
  311. print(f' Keywords: {len(keyword_index)}')
  312. print(f' Avg length: {sum(lengths)/len(lengths):.0f}')
  313. print(f' Min/Max: {min(lengths)}/{max(lengths)}')
  314. print(f' <100: {sum(1 for l in lengths if l < 100)} ({sum(1 for l in lengths if l < 100)*100/len(lengths):.1f}%)')
  315. print(f' >1000: {sum(1 for l in lengths if l > 1000)} ({sum(1 for l in lengths if l > 1000)*100/len(lengths):.1f}%)')
  316. print(f' Index size: {INDEX_FILE.stat().st_size/1024/1024:.1f} MB')