Files
liqiang b119135836
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
2026-08-20 13:12:50 +00:00

491 lines
18 KiB
Python

"""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()