Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 0.27.17

### Fixes

- **Serializing a chunk copied its `orig_elements` twice and threw both copies away.** `ElementMetadata.to_dict()` deep-copied every metadata field before replacing `coordinates`, `data_source`, `orig_elements` and `key_value_pairs` with their serialized form, and `_fix_metadata_field_precision()` copied each element again to round coordinates and `detection_class_prob` that most elements do not carry. On a chunk, `orig_elements` holds every source element of that chunk, so both copies duplicated the document. Serializing chunks is now roughly 4x to 6x faster through `to_dict()` and 6x to 10x faster through `elements_to_json()`; `elements_to_json()` on elements with no `orig_elements` is about 3x faster. **Behavior change:** `Element.id` mints a uuid on first access and caches it on that element, so the discarded copies used to take the new ids with them and consecutive serializations of one chunk reported different `element_id`s for the same source elements. Ids are now minted on the caller's element and are stable across calls, in `elements_to_json()` and `elements_to_ndjson()` as well as inside `orig_elements`. Elements given an explicit or hash-derived id were never affected.

## 0.27.16

### Fixes
Expand Down
94 changes: 94 additions & 0 deletions test_unstructured/documents/test_elements.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import json
import pathlib
from functools import partial
from unittest.mock import patch

import pytest

Expand Down Expand Up @@ -426,6 +427,99 @@ def and_it_serializes_an_orig_elements_sub_object_to_base64_when_it_is_present(s
assert restored[0].text == "Lorem"
assert restored[1].text == "Lorem Ipsum"

def and_it_does_not_deep_copy_the_sub_objects_it_reserializes(self):
"""`to_dict()` replaces `orig_elements` with its base64 form, so copying it first is waste.

On a chunk, `orig_elements` holds every source element of that chunk, so the discarded copy
dominated the cost of serializing it.
"""
meta = ElementMetadata(
orig_elements=assign_hash_ids([Title("Lorem"), Text("Lorem Ipsum")]),
page_number=2,
)
copied_dicts = []
real_deepcopy = copy.deepcopy

def recording_deepcopy(x, memo=None):
if isinstance(x, dict):
copied_dicts.append(x)
return real_deepcopy(x, memo)

with patch.object(copy, "deepcopy", recording_deepcopy):
meta.to_dict()

assert copied_dicts, "expected `to_dict()` to copy its remaining fields"
assert all(
separately_serialized_field not in copied
for copied in copied_dicts
for separately_serialized_field in ElementMetadata.SEPARATELY_SERIALIZED_FIELD_NAMES
)

def and_it_emits_the_separately_serialized_fields_in_their_declared_position(self):
"""A field's key position must not depend on whether it gets a separate serialized form.

`Element.to_dict()` output is written verbatim by consumers that do not sort keys, so
moving `coordinates`, `data_source`, `orig_elements` or `key_value_pairs` to the end of the
dict changes the bytes they write.
"""
meta = ElementMetadata(
coordinates=CoordinatesMetadata(
points=((2, 2), (1, 4), (3, 4), (3, 2)), system=RelativeCoordinateSystem()
),
data_source=DataSourceMetadata(url="https://example.com"),
filetype="text/plain",
languages=["eng"],
orig_elements=assign_hash_ids([Title("Lorem"), Text("Lorem Ipsum")]),
page_number=2,
)

assert list(meta.to_dict()) == [
"coordinates",
"data_source",
"filetype",
"languages",
"orig_elements",
"page_number",
]

def and_it_serializes_orig_elements_the_same_way_on_every_call(self):
"""Two `to_dict()` calls on one metadata must agree on the ids of its `orig_elements`.

`Element.id` mints a uuid on first access and caches it on that instance. Copying the
elements meant each call materialized ids on throwaway copies, so consecutive
serializations of one chunk reported different ids for the same source elements.
"""
meta = ElementMetadata(orig_elements=[Title("Lorem"), Text("Lorem Ipsum")])

assert meta.to_dict()["orig_elements"] == meta.to_dict()["orig_elements"]

@pytest.mark.parametrize(
"orig_element",
[
Text(
"Lorem",
metadata=ElementMetadata(
coordinates=CoordinatesMetadata(
points=((1.234, 2.345), (1.234, 4.567), (3.456, 4.567), (3.456, 2.345)),
system=RelativeCoordinateSystem(),
)
),
),
Text("Lorem", metadata=ElementMetadata(detection_class_prob=0.123456)),
],
ids=["coordinates", "detection_class_prob"],
)
def and_that_holds_for_elements_whose_precision_still_has_to_be_rounded(self, orig_element):
"""Rounding an element's precision requires a copy, and the copy must not take the id.

`Element.id` caches on first access, so an element that still goes through the copy would
otherwise mint a new uuid on the throwaway each time. Every hi_res-partitioned element
carries coordinates, so this is the common case rather than the corner one.
"""
meta = ElementMetadata(orig_elements=[orig_element])

assert meta.to_dict()["orig_elements"] == meta.to_dict()["orig_elements"]

def but_unlike_in_ElementMetadata_unknown_fields_in_sub_objects_are_ignored(self):
"""Metadata sub-objects ignore fields they do not explicitly define.

Expand Down
69 changes: 69 additions & 0 deletions test_unstructured/staging/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1011,3 +1011,72 @@ def test_element_to_md_with_none_mime_type():
result = base.element_to_md(image_element)
assert "![Test Image](data:image/*" in result
assert "base64," in result


def test_fix_metadata_field_precision_leaves_elements_with_nothing_to_round_alone():
"""Copying an element to round fields it does not have is waste.

Both `elements_to_json()` and `orig_elements` serialization run this over every element, so the
copy was paid on every element of every document, not only the ones carrying coordinates.
"""
element = Text("Lorem Ipsum")
assert element.metadata.coordinates is None
assert element.metadata.detection_class_prob is None

(result,) = base._fix_metadata_field_precision([element])

assert result is element


def test_fix_metadata_field_precision_rounds_a_copy_and_leaves_the_callers_element_unrounded():
element = Text(
"Lorem",
metadata=ElementMetadata(
coordinates=CoordinatesMetadata(
points=((1.23456, 2.34567), (3.45678, 4.56789)),
system=CoordinateSystem(width=10, height=10),
),
detection_class_prob=0.123456789,
),
)

(result,) = base._fix_metadata_field_precision([element])

assert result.metadata.coordinates.points == ((1.23, 2.35), (3.46, 4.57))
assert result.metadata.detection_class_prob == 0.12346
assert element.metadata.coordinates.points == ((1.23456, 2.34567), (3.45678, 4.56789))
assert element.metadata.detection_class_prob == 0.123456789


@pytest.mark.parametrize(
"rounded_field",
[
{
"coordinates": CoordinatesMetadata(
points=((1.23456, 2.34567), (3.45678, 4.56789)),
system=CoordinateSystem(width=10, height=10),
)
},
{"detection_class_prob": 0.123456789},
],
ids=["coordinates", "detection_class_prob"],
)
def test_elements_to_json_keeps_orig_elements_ids_stable_for_an_element_it_rounds(rounded_field):
"""Rounding a parent's precision must not re-mint the ids of the elements it carries.

`Element.id` caches on first access, so copying the parent's `orig_elements` subtree gave the
nested elements a fresh uuid on every call.
"""
child = Text("Ipsum")
parent = Text("Lorem", metadata=ElementMetadata(orig_elements=[child], **rounded_field))

first = json.loads(base.elements_to_json([parent]))
second = json.loads(base.elements_to_json([parent]))

first_ids = [
e.id for e in base.elements_from_base64_gzipped_json(first[0]["metadata"]["orig_elements"])
]
second_ids = [
e.id for e in base.elements_from_base64_gzipped_json(second[0]["metadata"]["orig_elements"])
]
assert first_ids == second_ids == [child.id]
2 changes: 1 addition & 1 deletion unstructured/__version__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.27.16" # pragma: no cover
__version__ = "0.27.17" # pragma: no cover
18 changes: 17 additions & 1 deletion unstructured/documents/elements.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ class ElementMetadata:
# to consolidate this new metadata field from each pre-chunk element during chunking.
# - Add field-name to DEBUG_FIELD_NAMES if it shouldn't appear in dict/JSON or participate in
# equality comparison.
# - Add field-name to SEPARATELY_SERIALIZED_FIELD_NAMES if `.to_dict()` replaces its value
# with a serialized form, so it is not deep-copied first.

attached_to_filename: Optional[str]
category_depth: Optional[int]
Expand Down Expand Up @@ -235,6 +237,13 @@ class ElementMetadata:
# -- `.fields` dict used by other parts of the library like chunking and weaviate.
DEBUG_FIELD_NAMES = frozenset(["detection_origin"])

#: Fields that `.to_dict()` replaces with their serialized form. Deep-copying them there
#: would only build a copy that is thrown away on the next few lines, and `orig_elements` on
#: a chunk holds every source element of that chunk.
SEPARATELY_SERIALIZED_FIELD_NAMES = frozenset(
["coordinates", "data_source", "orig_elements", "key_value_pairs"]
)

def __init__(
self,
attached_to_filename: Optional[str] = None,
Expand Down Expand Up @@ -429,7 +438,14 @@ def to_dict(self) -> dict[str, Any]:
"""
from unstructured.staging.base import elements_to_base64_gzipped_json

meta_dict = copy.deepcopy(dict(self.fields))
# -- copy the fields a caller could mutate to reach back into this metadata, but not the
# -- ones replaced by their serialized form below. Those stay in place as placeholders so
# -- the reassignments keep their original key position --
fields = self.fields
copied = copy.deepcopy(
{k: v for k, v in fields.items() if k not in self.SEPARATELY_SERIALIZED_FIELD_NAMES}
)
meta_dict = {k: copied.get(k, v) for k, v in fields.items()}

# -- remove fields that should not be serialized --
for field_name in self.DEBUG_FIELD_NAMES:
Expand Down
31 changes: 26 additions & 5 deletions unstructured/staging/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@
import json
import re
import zlib
from copy import deepcopy
from copy import copy
from datetime import datetime
from typing import Any, Iterable, Optional, Sequence, cast

from unstructured.documents.coordinates import PixelSpace
from unstructured.documents.elements import (
TYPE_TO_TEXT_ELEMENT_MAP,
CheckBox,
CoordinatesMetadata,
Element,
ElementMetadata,
Formula,
Expand Down Expand Up @@ -485,19 +486,39 @@ def elements_to_ndjson(


def _fix_metadata_field_precision(elements: Iterable[Element]) -> list[Element]:
"""Round `coordinates` and `detection_class_prob` for serialization.

An element with neither field is returned as-is, the caller's own object. Any other element is
returned as a shallow copy with its own metadata copy; the caller's element is not modified.
"""
out_elements: list[Element] = []
for element in elements:
el = deepcopy(element)
# -- the copy exists only so the two fields below can be rounded without touching the
# -- caller's element; most elements carry neither, and this runs over every element of
# -- every serialized document --
if not element.metadata.coordinates and not element.metadata.detection_class_prob:
out_elements.append(element)
continue

# -- mint the id on the caller's element first. `Element.id` caches on first access, so
# -- without this the copy below takes a fresh uuid with it and every call reports a
# -- different `element_id` for the same element --
_ = element.id
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
el = copy(element)
el.metadata = copy(element.metadata)
if el.metadata.coordinates:
precision = 1 if isinstance(el.metadata.coordinates.system, PixelSpace) else 2
points = el.metadata.coordinates.points
coordinates = el.metadata.coordinates
precision = 1 if isinstance(coordinates.system, PixelSpace) else 2
points = coordinates.points
assert points is not None
rounded_points: list[Point] = []
for point in points:
x, y = point
rounded_point = (round(x, precision), round(y, precision))
rounded_points.append(rounded_point)
el.metadata.coordinates.points = tuple(rounded_points)
el.metadata.coordinates = CoordinatesMetadata(
points=tuple(rounded_points), system=coordinates.system
)

if el.metadata.detection_class_prob:
el.metadata.detection_class_prob = round(el.metadata.detection_class_prob, 5)
Expand Down
Loading