rebuild_index.py 13 KB

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