Coverage for haystack/components/preprocessors/csv_document_splitter.py: 96%
108 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 13:53 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 13:53 +0000
1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2#
3# SPDX-License-Identifier: Apache-2.0
5from copy import deepcopy
6from io import StringIO
7from typing import Any, Literal, get_args
9from haystack import Document, component, logging
10from haystack.lazy_imports import LazyImport
12with LazyImport("Run 'pip install pandas'") as pandas_import:
13 import pandas as pd
15logger = logging.getLogger(__name__)
17SplitMode = Literal["threshold", "row-wise"]
20@component
21class CSVDocumentSplitter:
22 """
23 A component for splitting CSV documents into sub-tables based on split arguments.
25 The splitter supports two modes of operation:
26 - identify consecutive empty rows or columns that exceed a given threshold
27 and uses them as delimiters to segment the document into smaller tables.
28 - split each row into a separate sub-table, represented as a Document.
30 """
32 def __init__(
33 self,
34 row_split_threshold: int | None = 2,
35 column_split_threshold: int | None = 2,
36 read_csv_kwargs: dict[str, Any] | None = None,
37 split_mode: SplitMode = "threshold",
38 ) -> None:
39 """
40 Initializes the CSVDocumentSplitter component.
42 :param row_split_threshold: The minimum number of consecutive empty rows required to trigger a split.
43 :param column_split_threshold: The minimum number of consecutive empty columns required to trigger a split.
44 :param read_csv_kwargs: Additional keyword arguments to pass to `pandas.read_csv`.
45 By default, the component with options:
46 - `header=None`
47 - `skip_blank_lines=False` to preserve blank lines
48 - `dtype=object` to prevent type inference (e.g., converting numbers to floats).
49 See https://pandas.pydata.org/docs/reference/api/pandas.read_csv.html for more information.
50 :param split_mode:
51 If `threshold`, the component will split the document based on the number of
52 consecutive empty rows or columns that exceed the `row_split_threshold` or `column_split_threshold`.
53 If `row-wise`, the component will split each row into a separate sub-table.
54 """
55 pandas_import.check()
56 if split_mode not in get_args(SplitMode):
57 raise ValueError(
58 f"Split mode '{split_mode}' not recognized. Choose one among: {', '.join(get_args(SplitMode))}."
59 )
60 if row_split_threshold is not None and row_split_threshold < 1:
61 raise ValueError("row_split_threshold must be greater than 0")
63 if column_split_threshold is not None and column_split_threshold < 1:
64 raise ValueError("column_split_threshold must be greater than 0")
66 if row_split_threshold is None and column_split_threshold is None:
67 raise ValueError("At least one of row_split_threshold or column_split_threshold must be specified.")
69 self.row_split_threshold = row_split_threshold
70 self.column_split_threshold = column_split_threshold
71 self.read_csv_kwargs = read_csv_kwargs or {}
72 self.split_mode = split_mode
74 @component.output_types(documents=list[Document])
75 def run(self, documents: list[Document]) -> dict[str, list[Document]]:
76 """
77 Processes and splits a list of CSV documents into multiple sub-tables.
79 **Splitting Process:**
80 1. Applies a row-based split if `row_split_threshold` is provided.
81 2. Applies a column-based split if `column_split_threshold` is provided.
82 3. If both thresholds are specified, performs a recursive split by rows first, then columns, ensuring
83 further fragmentation of any sub-tables that still contain empty sections.
84 4. Sorts the resulting sub-tables based on their original positions within the document.
86 :param documents: A list of Documents containing CSV-formatted content.
87 Each document is assumed to contain one or more tables separated by empty rows or columns.
89 :return:
90 A dictionary with a key `"documents"`, mapping to a list of new `Document` objects,
91 each representing an extracted sub-table from the original CSV.
92 The metadata of each document includes:
93 - A field `source_id` to track the original document.
94 - A field `row_idx_start` to indicate the starting row index of the sub-table in the original table.
95 - A field `col_idx_start` to indicate the starting column index of the sub-table in the original table.
96 - A field `split_id` to indicate the order of the split in the original document.
97 - All other metadata copied from the original document.
99 - If a document cannot be processed, it is returned unchanged.
100 - The `meta` field from the original document is preserved in the split documents.
101 """
102 if len(documents) == 0:
103 return {"documents": documents}
105 resolved_read_csv_kwargs = {"header": None, "skip_blank_lines": False, "dtype": object, **self.read_csv_kwargs}
107 split_documents = []
108 split_dfs = []
109 for document in documents:
110 try:
111 df = pd.read_csv(StringIO(document.content), **resolved_read_csv_kwargs)
112 except Exception as e:
113 logger.exception(
114 "Error processing document {document_id}. Keeping it, but skipping splitting. Error: {error}",
115 document_id=document.id,
116 error=e,
117 )
118 split_documents.append(document)
119 continue
121 if self.split_mode == "row-wise":
122 # each row is a separate sub-table
123 split_dfs = self._split_by_row(df=df)
125 elif self.split_mode == "threshold":
126 if self.row_split_threshold is not None and self.column_split_threshold is None:
127 # split by rows
128 split_dfs = self._split_dataframe(df=df, split_threshold=self.row_split_threshold, axis="row")
129 elif self.column_split_threshold is not None and self.row_split_threshold is None:
130 # split by columns
131 split_dfs = self._split_dataframe(df=df, split_threshold=self.column_split_threshold, axis="column")
132 else:
133 # recursive split
134 split_dfs = self._recursive_split(
135 df=df,
136 row_split_threshold=self.row_split_threshold, # type: ignore
137 column_split_threshold=self.column_split_threshold, # type: ignore
138 )
140 # check if no sub-tables were found
141 if len(split_dfs) == 0:
142 logger.warning(
143 "No sub-tables found while splitting CSV Document with id {doc_id}. Skipping document.",
144 doc_id=document.id,
145 )
146 continue
148 # Sort split_dfs first by row index, then by column index
149 split_dfs.sort(key=lambda dataframe: (dataframe.index[0], dataframe.columns[0]))
151 for split_id, split_df in enumerate(split_dfs):
152 split_documents.append(
153 Document(
154 content=split_df.to_csv(index=False, header=False, lineterminator="\n"),
155 meta={
156 **deepcopy(document.meta),
157 "source_id": document.id,
158 "row_idx_start": int(split_df.index[0]),
159 "col_idx_start": int(split_df.columns[0]),
160 "split_id": split_id,
161 },
162 )
163 )
165 return {"documents": split_documents}
167 @staticmethod
168 def _find_split_indices(
169 df: "pd.DataFrame", split_threshold: int, axis: Literal["row", "column"]
170 ) -> list[tuple[int, int]]:
171 """
172 Finds the indices of consecutive empty rows or columns in a DataFrame.
174 :param df: DataFrame to split.
175 :param split_threshold: Minimum number of consecutive empty rows or columns to trigger a split.
176 :param axis: Axis along which to find empty elements. Either "row" or "column".
177 :return: List of zero-based positional indices for consecutive empty rows or columns.
178 """
179 if axis == "row":
180 empty_elements = [i for i, is_empty in enumerate(df.isnull().all(axis=1).tolist()) if is_empty]
181 else:
182 empty_elements = [i for i, is_empty in enumerate(df.isnull().all(axis=0).tolist()) if is_empty]
184 # If no empty elements found, return empty list
185 if len(empty_elements) == 0:
186 return []
188 # Identify groups of consecutive empty elements
189 split_indices = []
190 consecutive_count = 1
191 start_index = empty_elements[0]
193 for i in range(1, len(empty_elements)):
194 if empty_elements[i] == empty_elements[i - 1] + 1:
195 consecutive_count += 1
196 else:
197 if consecutive_count >= split_threshold:
198 split_indices.append((start_index, empty_elements[i - 1]))
199 consecutive_count = 1
200 start_index = empty_elements[i]
202 # Handle the last group of consecutive elements
203 if consecutive_count >= split_threshold:
204 split_indices.append((start_index, empty_elements[-1]))
206 return split_indices
208 def _split_dataframe(
209 self, df: "pd.DataFrame", split_threshold: int, axis: Literal["row", "column"]
210 ) -> list["pd.DataFrame"]:
211 """
212 Splits a DataFrame into sub-tables based on consecutive empty rows or columns exceeding `split_threshold`.
214 :param df: DataFrame to split.
215 :param split_threshold: Minimum number of consecutive empty rows or columns to trigger a split.
216 :param axis: Axis along which to split. Either "row" or "column".
217 :return: List of split DataFrames.
218 """
219 # Find indices of consecutive empty rows or columns
220 split_indices = self._find_split_indices(df=df, split_threshold=split_threshold, axis=axis)
222 # If no split_indices are found, return the original DataFrame
223 if len(split_indices) == 0:
224 return [df]
226 # Split the DataFrame at identified indices
227 sub_tables = []
228 table_start_idx = 0
229 df_length = df.shape[0] if axis == "row" else df.shape[1]
230 for empty_start_idx, empty_end_idx in split_indices + [(df_length, df_length)]:
231 # Avoid empty splits
232 if empty_start_idx - table_start_idx >= 1:
233 if axis == "row":
234 sub_table = df.iloc[table_start_idx:empty_start_idx]
235 else:
236 sub_table = df.iloc[:, table_start_idx:empty_start_idx]
237 if not sub_table.empty:
238 sub_tables.append(sub_table)
239 table_start_idx = empty_end_idx + 1
241 return sub_tables
243 def _recursive_split(
244 self, df: "pd.DataFrame", row_split_threshold: int, column_split_threshold: int
245 ) -> list["pd.DataFrame"]:
246 """
247 Recursively splits a DataFrame.
249 Recursively splits a DataFrame first by empty rows, then by empty columns, and repeats the process
250 until no more splits are possible. Returns a list of DataFrames, each representing a fully separated sub-table.
252 :param df: A Pandas DataFrame representing a table (or multiple tables) extracted from a CSV.
253 :param row_split_threshold: The minimum number of consecutive empty rows required to trigger a split.
254 :param column_split_threshold: The minimum number of consecutive empty columns to trigger a split.
255 """
257 # Step 1: Split by rows
258 new_sub_tables = self._split_dataframe(df=df, split_threshold=row_split_threshold, axis="row")
260 # Step 2: Split by columns
261 final_tables = []
262 for table in new_sub_tables:
263 final_tables.extend(self._split_dataframe(df=table, split_threshold=column_split_threshold, axis="column"))
265 # Step 3: Recursively reapply splitting checked by whether any new empty rows appear after column split
266 result = []
267 for table in final_tables:
268 # Check if there are consecutive rows >= row_split_threshold now present
269 if len(self._find_split_indices(df=table, split_threshold=row_split_threshold, axis="row")) > 0:
270 result.extend(
271 self._recursive_split(
272 df=table, row_split_threshold=row_split_threshold, column_split_threshold=column_split_threshold
273 )
274 )
275 else:
276 result.append(table)
278 return result
280 def _split_by_row(self, df: "pd.DataFrame") -> list["pd.DataFrame"]:
281 """Split each CSV row into a separate subtable"""
282 split_dfs = []
283 for idx, row in enumerate(df.itertuples(index=False)):
284 split_df = pd.DataFrame(row).T
285 split_df.index = [idx] # Set the index of the new DataFrame to idx
286 split_dfs.append(split_df)
287 return split_dfs