Repository navigation
Expand file tree
/
Copy pathindex_local_laws.py
More file actions
490 lines (400 loc) · 18.3 KB
/
Copy pathindex_local_laws.py
File metadata and controls
490 lines (400 loc) · 18.3 KB
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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
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
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
"""Script to chunk and index local legal documents from laws directory
This script:
1. Cleans up existing indexes
2. Reads legal documents from local laws directory
3. Chunks them with paragraph-aware boundaries (soft limit 1024, hard limit 2048)
4. Indexes them in the retrieval pipeline
"""
import os
import json
import logging
import hashlib
from typing import List, Dict, Any, Optional
from pathlib import Path
from dataclasses import dataclass
import time
import requests
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# Configuration
RETRIEVAL_PIPELINE_URL = "http://localhost:4242" # Default retrieval pipeline URL
LAWS_DIR = Path("laws") # Local laws directory
# Chunking configuration
SOFT_LIMIT = 1024 # Soft character limit
HARD_LIMIT = 2048 # Hard character limit
MIN_CHUNK_SIZE = 500 # Minimum chunk size
@dataclass
class LegalChunk:
"""Represents a chunk of legal document"""
chunk_id: str
doc_id: str
doc_title: str
category: str # e.g., "宪法", "民法典"
text: str
chunk_index: int
char_count: int
metadata: Dict[str, Any]
class LocalLegalIndexer:
"""Handles chunking and indexing of local legal documents"""
def __init__(self, laws_dir: Path = LAWS_DIR, pipeline_url: str = RETRIEVAL_PIPELINE_URL):
self.laws_dir = laws_dir
self.pipeline_url = pipeline_url
self.stats = {
"documents_processed": 0,
"chunks_created": 0,
"chunks_indexed": 0,
"errors": 0,
"categories_processed": set()
}
# Document store for tracking
self.doc_store_path = Path("document_store.json")
logger.info(f"Initialized indexer for local laws in {laws_dir}")
logger.info(f"Pipeline URL: {pipeline_url}")
def cleanup_existing_index(self):
"""Clean up existing indexes and document store"""
logger.info("Cleaning up existing indexes...")
# Clean local document store
if self.doc_store_path.exists():
try:
# Load existing store to get document IDs
with open(self.doc_store_path, 'r', encoding='utf-8') as f:
existing_docs = json.load(f)
logger.info(f"Found {len(existing_docs)} existing documents in store")
# Clear the store
self.doc_store_path.unlink()
logger.info("Cleared local document store")
except Exception as e:
logger.error(f"Error cleaning document store: {e}")
# Try to clear the retrieval pipeline
try:
response = requests.delete(f"{self.pipeline_url}/clear", timeout=30)
if response.status_code == 200:
logger.info("Cleared retrieval pipeline index")
else:
logger.warning(f"Failed to clear pipeline: {response.status_code}")
except Exception as e:
logger.warning(f"Could not clear retrieval pipeline: {e}")
logger.info("Cleanup complete")
def get_all_legal_documents(self) -> List[Dict[str, Any]]:
"""Get all legal documents from local laws directory"""
documents = []
if not self.laws_dir.exists():
logger.error(f"Laws directory not found: {self.laws_dir}")
return documents
# Iterate through category directories
for category_dir in sorted(self.laws_dir.iterdir()):
if not category_dir.is_dir():
continue
category_name = category_dir.name
logger.info(f"Processing category: {category_name}")
# Find all .md files in this category
for md_file in category_dir.glob("*.md"):
doc_info = {
"path": md_file,
"name": md_file.stem, # filename without extension
"category": category_name,
"full_name": md_file.name
}
documents.append(doc_info)
logger.info(f"Found {len(documents)} legal documents across {len(self.stats['categories_processed'])} categories")
return documents
def read_document(self, doc_info: Dict[str, Any]) -> Optional[str]:
"""Read a legal document from disk"""
try:
doc_path = doc_info["path"]
content = doc_path.read_text(encoding='utf-8')
logger.debug(f"Read {doc_info['name']} ({len(content)} chars)")
return content
except Exception as e:
logger.error(f"Error reading {doc_info['name']}: {e}")
self.stats["errors"] += 1
return None
def chunk_document_smart(self, text: str, doc_id: str, doc_title: str, category: str) -> List[LegalChunk]:
"""
Smart chunking that respects paragraph boundaries with soft and hard limits.
Strategy:
- Accumulate paragraphs until soft limit is exceeded
- Keep adding if next paragraph fits within hard limit
- Cut at paragraph boundary when possible
- Force split at hard limit if necessary
"""
paragraphs = text.split('\n\n')
chunks = []
current_chunk = []
current_size = 0
for para in paragraphs:
para = para.strip()
if not para:
continue
para_size = len(para)
# Handle oversized paragraphs
if para_size > HARD_LIMIT:
# Save current chunk if exists
if current_chunk:
chunk_text = '\n\n'.join(current_chunk)
if len(chunk_text) >= MIN_CHUNK_SIZE:
chunks.append(self._create_chunk(
chunk_text, doc_id, doc_title, category, len(chunks)
))
current_chunk = []
current_size = 0
# Force split the oversized paragraph
for i in range(0, para_size, SOFT_LIMIT):
sub_text = para[i:i + SOFT_LIMIT]
if len(sub_text) >= MIN_CHUNK_SIZE:
chunks.append(self._create_chunk(
sub_text, doc_id, doc_title, category, len(chunks)
))
continue
# Check if adding this paragraph would exceed limits
new_size = current_size + para_size + (4 if current_chunk else 0) # Account for \n\n
if new_size > SOFT_LIMIT and current_chunk:
# Check if we can still fit it under hard limit
if new_size <= HARD_LIMIT:
# Add it anyway (between soft and hard limit)
current_chunk.append(para)
current_size = new_size
else:
# Save current chunk and start new one
chunk_text = '\n\n'.join(current_chunk)
if len(chunk_text) >= MIN_CHUNK_SIZE:
chunks.append(self._create_chunk(
chunk_text, doc_id, doc_title, category, len(chunks)
))
# Start new chunk
current_chunk = [para]
current_size = para_size
else:
# Add to current chunk
current_chunk.append(para)
current_size = new_size
# Save final chunk
if current_chunk:
chunk_text = '\n\n'.join(current_chunk)
if len(chunk_text) >= MIN_CHUNK_SIZE:
chunks.append(self._create_chunk(
chunk_text, doc_id, doc_title, category, len(chunks)
))
logger.info(f"Created {len(chunks)} chunks for {doc_title}")
return chunks
def _create_chunk(self, text: str, doc_id: str, doc_title: str, category: str, chunk_index: int) -> LegalChunk:
"""Create a LegalChunk object"""
chunk_id = f"{doc_id}_chunk_{chunk_index}"
# Extract section info if available
section_info = self._extract_section_info(text)
return LegalChunk(
chunk_id=chunk_id,
doc_id=doc_id,
doc_title=doc_title,
category=category,
text=text,
chunk_index=chunk_index,
char_count=len(text),
metadata={
"source": "local_laws",
"document_type": "legal",
"language": "zh-CN",
"category": category,
"section": section_info
}
)
def _extract_section_info(self, text: str) -> Optional[str]:
"""Extract section/chapter/article information from legal text"""
import re
# Common patterns in Chinese legal documents
patterns = [
r'第[一二三四五六七八九十百千\d]+[章节条款篇编]',
r'第[一二三四五六七八九十百千\d]+部分',
r'[【\[]第[一二三四五六七八九十百千\d]+[章节条款篇编][】\]]'
]
for pattern in patterns:
match = re.search(pattern, text[:200]) # Check first 200 chars
if match:
return match.group()
return None
def index_chunk(self, chunk: LegalChunk) -> bool:
"""Index a chunk in the retrieval pipeline"""
try:
# Prepare the indexing request
index_data = {
"text": chunk.text,
"doc_id": chunk.chunk_id,
"metadata": {
**chunk.metadata,
"doc_title": chunk.doc_title,
"category": chunk.category,
"chunk_index": chunk.chunk_index,
"char_count": chunk.char_count
}
}
# Send to retrieval pipeline
response = requests.post(
f"{self.pipeline_url}/index",
json=index_data,
headers={"Content-Type": "application/json"}, timeout=30
)
if response.status_code == 200:
self.stats["chunks_indexed"] += 1
return True
else:
logger.warning(f"Failed to index chunk {chunk.chunk_id}: {response.status_code}")
return False
except Exception as e:
logger.error(f"Error indexing chunk {chunk.chunk_id}: {e}")
self.stats["errors"] += 1
return False
def save_document_info(self, doc_info: Dict[str, Any], chunks: List[LegalChunk]):
"""Save document information to local store"""
# Load existing store or create new
if self.doc_store_path.exists():
with open(self.doc_store_path, 'r', encoding='utf-8') as f:
doc_store = json.load(f)
else:
doc_store = {}
# Add document info
doc_id = hashlib.md5(doc_info["full_name"].encode()).hexdigest()[:12]
doc_store[doc_id] = {
"title": doc_info["name"],
"category": doc_info["category"],
"file": str(doc_info["path"]),
"chunks": len(chunks),
"total_chars": sum(c.char_count for c in chunks),
"indexed_at": time.strftime("%Y-%m-%d %H:%M:%S")
}
# Save store
with open(self.doc_store_path, 'w', encoding='utf-8') as f:
json.dump(doc_store, f, ensure_ascii=False, indent=2)
def process_all_documents(self, max_docs: Optional[int] = None, categories: Optional[List[str]] = None):
"""Process all legal documents"""
start_time = time.time()
# Clean up first
self.cleanup_existing_index()
# Get all documents
all_documents = self.get_all_legal_documents()
# Filter by categories if specified
if categories:
all_documents = [d for d in all_documents if any(cat in d["category"] for cat in categories)]
# Limit documents if specified
if max_docs:
all_documents = all_documents[:max_docs]
logger.info(f"Processing {len(all_documents)} documents...")
for i, doc_info in enumerate(all_documents):
logger.info(f"\n[{i+1}/{len(all_documents)}] Processing: {doc_info['name']}")
# Track category
self.stats["categories_processed"].add(doc_info["category"])
# Read document
content = self.read_document(doc_info)
if not content:
continue
# Generate document ID
doc_id = hashlib.md5(doc_info["full_name"].encode()).hexdigest()[:12]
# Chunk the document
chunks = self.chunk_document_smart(
content,
doc_id,
doc_info["name"],
doc_info["category"]
)
self.stats["chunks_created"] += len(chunks)
# Index each chunk
indexed_count = 0
for j, chunk in enumerate(chunks):
if self.index_chunk(chunk):
indexed_count += 1
# Progress update
if (j + 1) % 10 == 0:
logger.debug(f" Indexed {j + 1}/{len(chunks)} chunks")
logger.info(f" ✓ Indexed {indexed_count}/{len(chunks)} chunks successfully")
# Save document info
self.save_document_info(doc_info, chunks)
self.stats["documents_processed"] += 1
elapsed = time.time() - start_time
# Print statistics
self._print_statistics(elapsed)
def _print_statistics(self, elapsed_time: float):
"""Print processing statistics"""
print("\n" + "="*60)
print("INDEXING COMPLETE")
print("="*60)
print(f"Time elapsed: {elapsed_time:.2f} seconds")
print(f"Categories processed: {len(self.stats['categories_processed'])}")
print(f" - {', '.join(sorted(self.stats['categories_processed']))}")
print(f"Documents processed: {self.stats['documents_processed']}")
print(f"Chunks created: {self.stats['chunks_created']}")
print(f"Chunks indexed: {self.stats['chunks_indexed']}")
print(f"Errors: {self.stats['errors']}")
if self.stats['chunks_created'] > 0:
avg_chunks = self.stats['chunks_created'] / max(1, self.stats['documents_processed'])
print(f"Average chunks per document: {avg_chunks:.1f}")
if elapsed_time > 0 and self.stats['documents_processed'] > 0:
docs_per_sec = self.stats['documents_processed'] / elapsed_time
print(f"Processing speed: {docs_per_sec:.2f} docs/second")
print("="*60 + "\n")
def verify_indexing(self, test_queries: Optional[List[str]] = None):
"""Verify that indexing worked by performing test searches"""
if not test_queries:
test_queries = [
"民法典",
"合同法",
"劳动法",
"刑法",
"宪法"
]
print("\n" + "="*60)
print("VERIFICATION TESTS")
print("="*60)
for query in test_queries:
try:
response = requests.post(
f"{self.pipeline_url}/search",
json={
"query": query,
"mode": "hybrid",
"top_k": 5,
"rerank_top_k": 3
}, timeout=30
)
if response.status_code == 200:
results = response.json()
print(f"\n✓ Test search for '{query}':")
if "results" in results:
print(f" Found {len(results['results'])} results")
for i, result in enumerate(results['results'][:2]):
score = result.get('score', result.get('rerank_score', 'N/A'))
metadata = result.get('metadata', {})
print(f" {i+1}. Score: {score}")
print(f" Category: {metadata.get('category', 'Unknown')}")
print(f" Doc: {metadata.get('doc_title', 'Unknown')}")
print(f" Preview: {result.get('text', '')[:100]}...")
else:
print(f" No results found")
else:
print(f"✗ Test search for '{query}' failed: {response.status_code}")
except Exception as e:
print(f"✗ Error testing '{query}': {e}")
print("="*60 + "\n")
def main():
"""Main function"""
import argparse
parser = argparse.ArgumentParser(description="Index local legal documents into retrieval pipeline")
parser.add_argument("--pipeline-url", default=RETRIEVAL_PIPELINE_URL, help="Retrieval pipeline URL")
parser.add_argument("--max-docs", type=int, help="Maximum number of documents to process")
parser.add_argument("--categories", nargs="+", help="Specific categories to process (e.g., '宪法' '民法典')")
parser.add_argument("--no-cleanup", action="store_true", help="Don't clean existing indexes")
parser.add_argument("--verify", action="store_true", help="Run verification tests after indexing")
args = parser.parse_args()
# Create indexer
indexer = LocalLegalIndexer(pipeline_url=args.pipeline_url)
# Skip cleanup if requested
if args.no_cleanup:
indexer.cleanup_existing_index = lambda: logger.info("Skipping cleanup (--no-cleanup flag)")
# Process documents
indexer.process_all_documents(
max_docs=args.max_docs,
categories=args.categories
)
# Run verification if requested
if args.verify:
indexer.verify_indexing()
if __name__ == "__main__":
main()