navid72m commited on
Commit
3ad150f
·
verified ·
1 Parent(s): 947d262

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +78 -126
  2. document_processor.py +271 -154
app.py CHANGED
@@ -1,145 +1,97 @@
1
  import os
2
  import tempfile
3
  import streamlit as st
4
- import numpy as np
5
- from sentence_transformers import SentenceTransformer
6
- from sklearn.metrics.pairwise import cosine_similarity
7
  from document_processor import DocumentProcessor
 
8
 
9
- # Initialize session state variables if they don't exist
10
- if 'documents' not in st.session_state:
11
- st.session_state.documents = []
12
- if 'embeddings' not in st.session_state:
13
- st.session_state.embeddings = []
14
- if 'search_results' not in st.session_state:
15
- st.session_state.search_results = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
- # Function to process uploaded files
18
  def process_uploaded_files(uploaded_files):
19
- """Process each uploaded file and add chunks to documents."""
 
 
 
20
  temp_dir = tempfile.mkdtemp()
21
- processor = DocumentProcessor(embedding_model="sentence-transformers/all-MiniLM-L6-v2",chunk_size=500, chunk_overlap=50)
 
 
 
 
 
 
 
22
 
 
23
  for uploaded_file in uploaded_files:
24
- # Create a temporary file path
25
- file_path = os.path.join(temp_dir, uploaded_file.name)
26
 
27
- # Write the uploaded file to the temp directory
 
28
  with open(file_path, "wb") as f:
29
  f.write(uploaded_file.getbuffer())
30
 
31
- try:
32
- # Process the file
33
- chunks = processor.process_document(file_path)
34
-
35
- # Add chunks to session state
36
- st.session_state.documents.extend(chunks)
37
- st.success(f"Processed {uploaded_file.name} - Added {len(chunks)} chunks")
38
- except Exception as e:
39
- st.error(f"Error processing {uploaded_file.name}: {str(e)}")
 
 
 
 
40
 
41
- # Return the temp directory for later cleanup
42
- return temp_dir
43
-
44
- # Function to create embeddings
45
- @st.cache_resource
46
- def load_embedding_model():
47
- """Load the sentence transformer model."""
48
- return SentenceTransformer('all-MiniLM-L6-v2')
49
-
50
- def create_embeddings():
51
- """Create embeddings for all documents in the session state."""
52
- model = load_embedding_model()
53
-
54
- # Extract texts from documents
55
- texts = [doc["text"] for doc in st.session_state.documents]
56
-
57
- # Create embeddings
58
- with st.spinner('Creating embeddings...'):
59
- embeddings = model.encode(texts, show_progress_bar=True)
60
-
61
- st.session_state.embeddings = embeddings
62
- st.success(f"Created embeddings for {len(embeddings)} document chunks")
63
-
64
- # Search function
65
- def search_documents(query, top_k=5):
66
- """Search documents based on the query and return top k results."""
67
- model = load_embedding_model()
68
-
69
- # Create query embedding
70
- query_embedding = model.encode([query])[0]
71
-
72
- # Calculate similarities
73
- similarities = cosine_similarity(
74
- [query_embedding],
75
- st.session_state.embeddings
76
- )[0]
77
-
78
- # Get top-k indices
79
- top_indices = np.argsort(similarities)[-top_k:][::-1]
80
-
81
- # Create results
82
- results = []
83
- for idx in top_indices:
84
- document = st.session_state.documents[idx]
85
- results.append({
86
- "document": document,
87
- "similarity": similarities[idx],
88
- "index": idx
89
- })
90
-
91
- st.session_state.search_results = results
92
- return results
93
-
94
- # Main App UI
95
- st.title("Document Search with Embeddings")
96
-
97
- # Sidebar for uploading files and controlling the app
98
- with st.sidebar:
99
- st.header("Document Control")
100
-
101
- # File uploader
102
- uploaded_files = st.file_uploader("Upload documents", accept_multiple_files=True,
103
- type=["txt", "md", "html", "pdf"])
104
-
105
- # Process uploaded files when the user clicks the button
106
- if st.button("Process Files") and uploaded_files:
107
- temp_dir = process_uploaded_files(uploaded_files)
108
 
109
- # Create embeddings button
110
- if st.button("Create Embeddings") and st.session_state.documents:
111
- create_embeddings()
112
 
113
- # Display stats
114
- st.subheader("Stats")
115
- st.write(f"Documents: {len(st.session_state.documents)}")
116
- st.write(f"Embeddings: {len(st.session_state.embeddings)}")
117
 
118
- # Instructions and tips
119
- st.subheader("Processing Tips")
120
- st.info("Supported file types: TXT, MD, HTML, PDF")
121
- st.info("1. Upload your documents\n2. Process the files\n3. Create embeddings\n4. Search!")
122
 
123
- # Main area - Search interface
124
- if st.session_state.embeddings is not None and len(st.session_state.embeddings) > 0:
125
- st.subheader("Document Search")
126
-
127
- # Search query input
128
- query = st.text_input("Enter your search query")
129
- num_results = st.slider("Number of results", min_value=1, max_value=10, value=3)
130
-
131
- # Search button
132
- if st.button("Search") and query:
133
- results = search_documents(query, top_k=num_results)
134
-
135
- # Display search results
136
- if st.session_state.search_results:
137
- st.subheader("Search Results")
138
- for i, result in enumerate(st.session_state.search_results):
139
- with st.expander(f"Result {i+1} - {result['document']['source']} (Score: {result['similarity']:.4f})"):
140
- st.markdown(f"**Source:** {result['document']['source']}")
141
- st.markdown(f"**Chunk ID:** {result['document']['chunk_id']}")
142
- st.markdown("**Content:**")
143
- st.markdown(result['document']['text'])
144
- else:
145
- st.info("Please upload documents and create embeddings to enable search.")
 
1
  import os
2
  import tempfile
3
  import streamlit as st
4
+ import PyPDF2
 
 
5
  from document_processor import DocumentProcessor
6
+ import logging
7
 
8
+ # Set up logging
9
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
10
+ logger = logging.getLogger(__name__)
11
+
12
+ def extract_text_from_pdf(file_path):
13
+ """Extract text from PDF with robust error handling"""
14
+ text = ""
15
+ try:
16
+ with open(file_path, "rb") as f:
17
+ pdf_reader = PyPDF2.PdfReader(f)
18
+
19
+ # Get the number of pages
20
+ num_pages = len(pdf_reader.pages)
21
+ logger.info(f"PDF has {num_pages} pages")
22
+
23
+ # Extract text from each page
24
+ for page_num in range(num_pages):
25
+ page = pdf_reader.pages[page_num]
26
+ try:
27
+ page_text = page.extract_text()
28
+ if page_text:
29
+ text += page_text + "\n\n"
30
+ except Exception as e:
31
+ logger.error(f"Error extracting text from page {page_num}: {e}")
32
+ except Exception as e:
33
+ logger.error(f"Error reading PDF file {file_path}: {e}")
34
+
35
+ logger.info(f"Extracted {len(text)} characters from PDF")
36
+
37
+ # If text is very short, it might be a scanned PDF that needs OCR
38
+ if len(text.strip()) < 100:
39
+ logger.warning(f"Very little text extracted from {file_path}. This may be a scanned PDF requiring OCR.")
40
+
41
+ return text
42
 
 
43
  def process_uploaded_files(uploaded_files):
44
+ if not uploaded_files:
45
+ return None
46
+
47
+ # Create a temporary directory to store processed files
48
  temp_dir = tempfile.mkdtemp()
49
+ logger.info(f"Created temporary directory: {temp_dir}")
50
+
51
+ # Initialize document processor with embedding model
52
+ processor = DocumentProcessor(
53
+ embedding_model="sentence-transformers/all-MiniLM-L6-v2",
54
+ chunk_size=500,
55
+ chunk_overlap=50
56
+ )
57
 
58
+ # Process each file
59
  for uploaded_file in uploaded_files:
60
+ file_name = uploaded_file.name
61
+ logger.info(f"Processing file: {file_name}")
62
 
63
+ # Save the uploaded file to disk temporarily
64
+ file_path = os.path.join(temp_dir, file_name)
65
  with open(file_path, "wb") as f:
66
  f.write(uploaded_file.getbuffer())
67
 
68
+ # Extract text based on file type
69
+ if file_name.lower().endswith('.pdf'):
70
+ text = extract_text_from_pdf(file_path)
71
+ elif file_name.lower().endswith(('.txt', '.md')):
72
+ with open(file_path, "r", encoding="utf-8", errors="replace") as f:
73
+ text = f.read()
74
+ else:
75
+ st.warning(f"Unsupported file type: {file_name}")
76
+ continue
77
+
78
+ if not text or len(text.strip()) == 0:
79
+ st.warning(f"No text could be extracted from {file_name}")
80
+ continue
81
 
82
+ # Create metadata
83
+ metadata = {
84
+ "file_name": file_name,
85
+ "file_path": file_path,
86
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
 
88
+ # Process the document
89
+ doc_ids = processor.process_document(text, metadata)
90
+ st.info(f"Processed {file_name} - Added {len(doc_ids)} chunks")
91
 
92
+ # Save the processor state
93
+ processor.save(os.path.join(temp_dir, "processor_state"))
 
 
94
 
95
+ return temp_dir
 
 
 
96
 
97
+ # The rest of your app.py code remains the same...
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
document_processor.py CHANGED
@@ -1,210 +1,327 @@
1
- from typing import List, Dict, Any, Optional, Union, Callable
 
2
  import re
3
  import uuid
 
4
  import numpy as np
5
- from dataclasses import dataclass
6
-
7
- from embedding_model import EmbeddingModel
8
- from memory_db import VectorDatabase
9
-
10
- @dataclass
11
- class ProcessedChunk:
12
- """Represents a processed text chunk with metadata"""
13
- text: str
14
- metadata: Dict[str, Any]
15
- doc_id: str
16
- embedding: Optional[np.ndarray] = None
17
 
 
 
 
18
 
19
  class DocumentProcessor:
20
- """Handles document splitting and processing for vector storage"""
21
 
22
  def __init__(
23
- self,
24
- embedding_model: EmbeddingModel,
25
- chunk_size: int = 500,
26
  chunk_overlap: int = 50,
27
- min_chunk_size: int = 100,
28
- metadata_extractor: Optional[Callable[[str], Dict[str, Any]]] = None
29
  ):
30
  """
31
- Initialize document processor
32
 
33
  Args:
34
- embedding_model: Model used to create embeddings
35
- chunk_size: Target size of chunks in characters
36
  chunk_overlap: Overlap between chunks in characters
37
- min_chunk_size: Minimum size for chunks
38
- metadata_extractor: Optional function to extract metadata from documents
39
  """
40
- self.embedding_model = embedding_model
41
  self.chunk_size = chunk_size
42
  self.chunk_overlap = chunk_overlap
43
- self.min_chunk_size = min_chunk_size
44
- self.metadata_extractor = metadata_extractor or (lambda _: {})
45
-
46
- def process_document(
47
- self,
48
- document: str,
49
- doc_id: Optional[str] = None,
50
- metadata: Optional[Dict[str, Any]] = None
51
- ) -> List[ProcessedChunk]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  """
53
- Process a document into chunks with embeddings
54
 
55
  Args:
56
- document: Document text to process
57
- doc_id: Optional document ID (will be auto-generated if None)
58
- metadata: Optional metadata to associate with all chunks
59
 
60
  Returns:
61
- List of processed chunks
62
  """
63
- # Generate document ID if not provided
64
- if doc_id is None:
65
- doc_id = str(uuid.uuid4())
 
 
 
 
66
 
67
- # Extract metadata if not provided
68
- if metadata is None:
69
- metadata = self.metadata_extractor(document)
70
 
71
- # Split the document into chunks
72
- chunks = self.split_text(document)
 
 
 
 
 
 
73
 
74
- # Process each chunk
75
- processed_chunks = []
76
- for i, chunk_text in enumerate(chunks):
77
- # Skip chunks that are too small
78
- if len(chunk_text) < self.min_chunk_size:
79
- continue
80
-
81
- # Create chunk metadata (including position info)
82
- chunk_metadata = {
83
- **metadata,
84
- "chunk_index": i,
85
- "total_chunks": len(chunks),
86
- "document_id": doc_id
87
- }
88
-
89
- # Generate chunk ID
90
- chunk_id = f"{doc_id}_{i}"
91
-
92
- # Create embedding
93
- embedding = self.embedding_model.embed_text(chunk_text)
94
-
95
- # Create processed chunk
96
- chunk = ProcessedChunk(
97
- text=chunk_text,
98
- metadata=chunk_metadata,
99
- doc_id=chunk_id,
100
- embedding=embedding
101
- )
102
-
103
- processed_chunks.append(chunk)
104
-
105
- return processed_chunks
106
-
107
- def split_text(self, text: str) -> List[str]:
108
  """
109
- Split text into overlapping chunks
110
 
111
  Args:
112
- text: Text to split
113
 
114
  Returns:
115
  List of text chunks
116
  """
117
- # Clean and normalize text
118
- text = self._normalize_text(text)
 
 
 
 
 
 
 
119
 
120
- # Simple character-based chunking
121
  chunks = []
122
  start = 0
 
123
 
124
- while start < len(text):
125
- # Determine end position for this chunk
126
- end = start + self.chunk_size
127
 
128
- # Adjust to end at sentence boundary if possible
129
- if end < len(text):
130
- # Try to find sentence boundary near the end
131
- sentence_end = self._find_sentence_boundary(text, end)
132
- if sentence_end > start + self.min_chunk_size:
133
- end = sentence_end
134
- else:
135
- end = len(text)
 
 
 
 
 
 
 
136
 
137
- # Add the chunk
138
- chunks.append(text[start:end])
 
 
139
 
140
- # Move start position for next chunk
141
- start = end - self.chunk_overlap
142
- if start < 0 or start >= len(text):
143
- break
144
 
 
145
  return chunks
146
-
147
- def _normalize_text(self, text: str) -> str:
148
- """Normalize text by removing extra whitespace"""
149
- # Replace multiple whitespace with single space
150
- text = re.sub(r'\s+', ' ', text)
151
- # Trim whitespace from start and end
152
- return text.strip()
153
-
154
- def _find_sentence_boundary(self, text: str, position: int) -> int:
155
  """
156
- Find the nearest sentence boundary after the given position
157
 
158
  Args:
159
- text: Text to search in
160
- position: Position to start searching from
161
 
162
  Returns:
163
- Index of sentence boundary
164
  """
165
- # Look for typical sentence end patterns after position
166
- for i in range(position, min(position + 100, len(text))):
167
- if i >= len(text):
168
- return len(text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
 
170
- # Check for sentence endings
171
- if text[i] in '.!?' and (i == len(text) - 1 or text[i+1] == ' '):
172
- return i + 1
173
 
174
- # If no sentence boundary found, just return the original position
175
- return position
176
-
177
- def add_to_database(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  self,
179
- document: str,
180
- database: VectorDatabase,
181
- doc_id: Optional[str] = None,
182
- metadata: Optional[Dict[str, Any]] = None
183
- ) -> List[str]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  """
185
- Process a document and add its chunks to the database
186
 
187
  Args:
188
- document: Document text to process
189
- database: Vector database to add chunks to
190
- doc_id: Optional document ID
191
- metadata: Optional metadata to associate with chunks
192
 
193
  Returns:
194
- List of chunk IDs added to the database
195
- """
196
- # Process document
197
- chunks = self.process_document(document, doc_id, metadata)
198
-
199
- # Add each chunk to the database
200
- chunk_ids = []
201
- for chunk in chunks:
202
- database.add_document(
203
- doc_id=chunk.doc_id,
204
- text=chunk.text,
205
- metadata=chunk.metadata,
206
- embedding=chunk.embedding
207
- )
208
- chunk_ids.append(chunk.doc_id)
209
-
210
- return chunk_ids
 
1
+ from typing import List, Dict, Any, Optional, Tuple, Union
2
+ import os
3
  import re
4
  import uuid
5
+ import json
6
  import numpy as np
7
+ from tqdm import tqdm
8
+ from memory_db import VectorDatabase, Document
9
+ from transformers import AutoTokenizer, AutoModel
10
+ import torch
11
+ import logging
 
 
 
 
 
 
 
12
 
13
+ # Set up logging
14
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
15
+ logger = logging.getLogger(__name__)
16
 
17
  class DocumentProcessor:
18
+ """Process documents for semantic search"""
19
 
20
  def __init__(
21
+ self,
22
+ embedding_model: Optional[str] = "sentence-transformers/all-MiniLM-L6-v2",
23
+ chunk_size: int = 500,
24
  chunk_overlap: int = 50,
25
+ device: Optional[str] = None
 
26
  ):
27
  """
28
+ Initialize the document processor
29
 
30
  Args:
31
+ embedding_model: Model name or path for generating embeddings
32
+ chunk_size: Size of text chunks in characters
33
  chunk_overlap: Overlap between chunks in characters
34
+ device: Device to run model on ('cpu', 'cuda', etc.)
 
35
  """
 
36
  self.chunk_size = chunk_size
37
  self.chunk_overlap = chunk_overlap
38
+
39
+ # Initialize embedding model
40
+ if embedding_model is not None:
41
+ logger.info(f"Loading embedding model: {embedding_model}")
42
+ self.tokenizer = AutoTokenizer.from_pretrained(embedding_model)
43
+ self.model = AutoModel.from_pretrained(embedding_model)
44
+
45
+ # Set device (use CUDA if available)
46
+ if device is None:
47
+ self.device = "cuda" if torch.cuda.is_available() else "cpu"
48
+ else:
49
+ self.device = device
50
+
51
+ logger.info(f"Using device: {self.device}")
52
+ self.model.to(self.device)
53
+ self.embedding_dim = self.model.config.hidden_size
54
+ else:
55
+ self.tokenizer = None
56
+ self.model = None
57
+ self.device = "cpu"
58
+ self.embedding_dim = 384 # Default dimension
59
+
60
+ # Initialize database
61
+ self.db = VectorDatabase(dimension=self.embedding_dim)
62
+
63
+ def _generate_embedding(self, text: str) -> np.ndarray:
64
  """
65
+ Generate embedding for a text using the model
66
 
67
  Args:
68
+ text: Input text
 
 
69
 
70
  Returns:
71
+ Embedding vector
72
  """
73
+ if self.model is None:
74
+ raise ValueError("No embedding model provided")
75
+
76
+ if not text or text.isspace():
77
+ logger.warning("Attempted to generate embedding for empty text")
78
+ # Return zero vector as fallback
79
+ return np.zeros((1, self.embedding_dim))
80
 
81
+ # Truncate text if too long for model
82
+ max_length = self.tokenizer.model_max_length
 
83
 
84
+ # Tokenize and prepare for model
85
+ inputs = self.tokenizer(
86
+ text,
87
+ max_length=max_length,
88
+ padding=True,
89
+ truncation=True,
90
+ return_tensors="pt"
91
+ ).to(self.device)
92
 
93
+ # Generate embeddings
94
+ with torch.no_grad():
95
+ outputs = self.model(**inputs)
96
+
97
+ # Get CLS token embedding (first token)
98
+ embedding = outputs.last_hidden_state[:, 0, :].cpu().numpy()
99
+
100
+ return embedding[0] # Return the actual vector, not a batch
101
+
102
+ def chunk_text(self, text: str) -> List[str]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  """
104
+ Split text into chunks of approximately chunk_size with overlap
105
 
106
  Args:
107
+ text: Text to split into chunks
108
 
109
  Returns:
110
  List of text chunks
111
  """
112
+ # Handle empty text
113
+ if not text or len(text.strip()) == 0:
114
+ logger.warning("Empty text provided for chunking")
115
+ return []
116
+
117
+ # Clean text - remove excessive whitespace
118
+ text = re.sub(r'\s+', ' ', text).strip()
119
+
120
+ logger.info(f"Chunking text of length {len(text)} characters")
121
 
 
122
  chunks = []
123
  start = 0
124
+ text_length = len(text)
125
 
126
+ while start < text_length:
127
+ # Get chunk end position
128
+ end = min(start + self.chunk_size, text_length)
129
 
130
+ # If not at the end of text, try to find a good break point
131
+ if end < text_length:
132
+ # Try to break at sentence or punctuation
133
+ break_chars = '.!?。,,;;'
134
+ # Find the last break character in the chunk
135
+ last_break = max([text.rfind(char, start, end) for char in break_chars])
136
+
137
+ # If found a break point, use it
138
+ if last_break > start:
139
+ end = last_break + 1
140
+ else:
141
+ # If no good break, try to break at space
142
+ last_space = text.rfind(' ', start, end)
143
+ if last_space > start:
144
+ end = last_space + 1
145
 
146
+ # Add chunk
147
+ chunk_text = text[start:end].strip()
148
+ if chunk_text: # Only add non-empty chunks
149
+ chunks.append(chunk_text)
150
 
151
+ # Move to next chunk with overlap
152
+ start = end - self.chunk_overlap if end < text_length else text_length
 
 
153
 
154
+ logger.info(f"Created {len(chunks)} chunks")
155
  return chunks
156
+
157
+ def process_document(
158
+ self,
159
+ text: str,
160
+ metadata: Optional[Dict[str, Any]] = None
161
+ ) -> List[str]:
 
 
 
162
  """
163
+ Process a document and add to database
164
 
165
  Args:
166
+ text: Document text
167
+ metadata: Optional metadata for the document
168
 
169
  Returns:
170
+ List of document IDs created
171
  """
172
+ metadata = metadata or {}
173
+ doc_ids = []
174
+
175
+ # Log text details
176
+ logger.info(f"Processing document: {len(text)} chars, metadata: {metadata}")
177
+
178
+ if not text or len(text.strip()) == 0:
179
+ logger.warning("Empty document text provided")
180
+ return doc_ids
181
+
182
+ # Chunk text
183
+ chunks = self.chunk_text(text)
184
+
185
+ if not chunks:
186
+ logger.warning("No chunks were created from the document")
187
+ return doc_ids
188
+
189
+ # Generate embeddings and add to database
190
+ for i, chunk in enumerate(chunks):
191
+ # Skip empty chunks
192
+ if not chunk or chunk.isspace():
193
+ continue
194
 
195
+ try:
196
+ # Generate embedding
197
+ embedding = self._generate_embedding(chunk)
198
 
199
+ # Create chunk metadata
200
+ chunk_metadata = metadata.copy()
201
+ chunk_metadata["chunk_index"] = i
202
+ chunk_metadata["chunk_count"] = len(chunks)
203
+
204
+ # Add to database
205
+ doc_id = self.db.add_document(
206
+ text=chunk,
207
+ embedding=embedding,
208
+ metadata=chunk_metadata
209
+ )
210
+
211
+ doc_ids.append(doc_id)
212
+
213
+ except Exception as e:
214
+ logger.error(f"Error processing chunk {i}: {e}")
215
+ continue
216
+
217
+ logger.info(f"Added {len(doc_ids)} chunks to database")
218
+ return doc_ids
219
+
220
+ def process_documents(
221
  self,
222
+ documents: List[Tuple[str, Dict[str, Any]]]
223
+ ) -> Dict[str, List[str]]:
224
+ """
225
+ Process multiple documents and add to database
226
+
227
+ Args:
228
+ documents: List of (text, metadata) tuples
229
+
230
+ Returns:
231
+ Dictionary mapping metadata keys to lists of document IDs
232
+ """
233
+ result = {}
234
+
235
+ for text, metadata in tqdm(documents, desc="Processing documents"):
236
+ doc_id = str(uuid.uuid4())
237
+ metadata["document_id"] = doc_id
238
+
239
+ chunk_ids = self.process_document(text, metadata)
240
+
241
+ # Store IDs by document ID
242
+ result[doc_id] = chunk_ids
243
+
244
+ return result
245
+
246
+ def search(
247
+ self,
248
+ query: str,
249
+ top_k: int = 5,
250
+ filter_func: Optional[callable] = None
251
+ ) -> List[Tuple[Document, float]]:
252
+ """
253
+ Search for documents similar to query
254
+
255
+ Args:
256
+ query: Search query
257
+ top_k: Number of results to return
258
+ filter_func: Optional function to filter results
259
+
260
+ Returns:
261
+ List of (document, score) tuples
262
+ """
263
+ # Generate embedding for query
264
+ query_embedding = self._generate_embedding(query)
265
+
266
+ # Search in database
267
+ results = self.db.search(
268
+ query_embedding=query_embedding,
269
+ top_k=top_k,
270
+ filter_func=filter_func
271
+ )
272
+
273
+ return results
274
+
275
+ def save(self, directory: str) -> None:
276
+ """
277
+ Save processor state to directory
278
+
279
+ Args:
280
+ directory: Directory to save to
281
+ """
282
+ os.makedirs(directory, exist_ok=True)
283
+
284
+ # Save database
285
+ self.db.save(os.path.join(directory, "database"))
286
+
287
+ # Save config
288
+ config = {
289
+ "chunk_size": self.chunk_size,
290
+ "chunk_overlap": self.chunk_overlap,
291
+ "embedding_dim": self.embedding_dim
292
+ }
293
+
294
+ with open(os.path.join(directory, "config.json"), "w") as f:
295
+ json.dump(config, f)
296
+
297
+ @classmethod
298
+ def load(
299
+ cls,
300
+ directory: str,
301
+ embedding_model: Optional[str] = "sentence-transformers/all-MiniLM-L6-v2"
302
+ ) -> 'DocumentProcessor':
303
  """
304
+ Load processor from directory
305
 
306
  Args:
307
+ directory: Directory to load from
308
+ embedding_model: Model to use for embeddings
 
 
309
 
310
  Returns:
311
+ Loaded DocumentProcessor
312
+ """
313
+ # Load config
314
+ with open(os.path.join(directory, "config.json"), "r") as f:
315
+ config = json.load(f)
316
+
317
+ # Create processor
318
+ processor = cls(
319
+ embedding_model=embedding_model,
320
+ chunk_size=config["chunk_size"],
321
+ chunk_overlap=config["chunk_overlap"]
322
+ )
323
+
324
+ # Load database
325
+ processor.db = VectorDatabase.load(os.path.join(directory, "database"))
326
+
327
+ return processor