Skip to content

Commit 5998966

Browse files
committed
test(embed): exercise loss guards through public paths
Replace redundant private-helper checks with public runtime, embedder, and writer coverage. Clarify the dense-only and None-conversion behavior in the shipped documentation.
1 parent 698de56 commit 5998966

7 files changed

Lines changed: 120 additions & 203 deletions

File tree

docs/docs/extraction/troubleshoot.md

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -49,28 +49,33 @@ and embedding. It does not automatically raise for:
4949
an embedding failure to `None`, which can still be dropped silently as described
5050
below.
5151

52-
Row-level embedding failures are unaffected and still populate the error
53-
column. A plain CUDA out-of-memory is not treated as an engine failure, because
54-
on the HuggingFace embedding backend a smaller next batch can succeed. That is
55-
about the embed stage, not the run: if the retry succeeds nothing changes. If
56-
the batch is lost, whether the ingest then fails depends on the shape those
57-
rows carry. The writer refuses `[]` but not `None`, as described below. If you
58-
see `Refusing to build an incomplete index` with no engine-startup message in
59-
the embed actor logs, look for an out-of-memory instead.
60-
61-
`LanceDB(on_bad_vectors=...)` does not suppress that error. A row that arrives
62-
with an empty list or tuple embedding used to be counted as a wrong-length
63-
vector and silently excluded; it now fails the run, and no policy value
64-
restores the old behavior. Fix the embed stage.
52+
Alternate handlers that convert row-level failures to `None` still populate
53+
the error column. The default local runtime is stricter: if its embedder
54+
returns no vector for any submitted input, the embedder raises and aborts the
55+
ingest. A plain CUDA out-of-memory is not classified as an engine failure,
56+
because on the HuggingFace embedding backend a smaller next batch can succeed.
57+
That is about the embed stage, not the run: if the retry succeeds nothing
58+
changes. If the batch is lost, whether the ingest then fails depends on the
59+
shape those rows carry. The dense writer refuses `[]` but not `None`, as
60+
described below. If you see `Refusing to build an incomplete index` with no
61+
engine-startup message in the embed actor logs, look for an out-of-memory
62+
instead.
63+
64+
`LanceDB(on_bad_vectors=...)` does not suppress that error. On a dense write, a
65+
row that arrives with an empty list or tuple embedding used to be counted as a
66+
wrong-length vector and silently excluded; it now fails the run, and no policy
67+
value restores the old behavior. Fix the embed stage.
6568

6669
This covers empty list and tuple values only. A failed embedding that arrives
67-
as `None` keeps its pre-existing silent drop, counted as
68-
`dropped_no_embedding`. Some embed
69-
operators produce `None` for a lost batch as well as for a row they chose not
70-
to embed. The two carry different `error` payloads upstream, but the writer
71-
does not see that key, so it drops both alike. If recall is low and the run
72-
reported success, check `dropped_no_embedding` in the ingest logs, not just
73-
the guard.
70+
as `None` keeps its pre-existing handling. For direct nested LanceDB records,
71+
the writer silently drops the row and counts it as `dropped_no_embedding`.
72+
Graph rows are converted before the writer: a mixed batch filters out `None`
73+
rows without incrementing that writer counter, while a batch with no
74+
uploadable rows raises `VdbUploadError`. Some embed operators produce `None`
75+
for a lost batch as well as for a row they chose not to embed. The two carry
76+
different `error` payloads upstream, but the writer does not see that key. If
77+
recall is low and the run reported success, inspect the embed-stage row errors
78+
and writer logs; do not rely on the empty-vector guard alone.
7479

7580
- Caption or remote VLM stages. Missing credentials fail at actor setup;
7681
inference failures can abort the entire ingest.

nemo_retriever/src/nemo_retriever/common/vdb/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,9 @@ When `vdb_op="lancedb"` (or `vdb=LanceDB(...)` is passed explicitly), `_construc
9393
1. **`create_index`** — connects with `lancedb.connect(self.uri)`, transforms ingestion batches into Arrow rows (`vector`, `text`, `metadata`, `source`), and **`db.create_table(...)`** with schema and `on_bad_vectors` policy.
9494
2. **`write_to_index`** — builds the **vector index** (e.g. IVF/HNSW) and optionally an **FTS/BM25** index over the ingested `text` column when `hybrid=True`.
9595

96-
During step 1, a row that arrives with an **empty list or tuple** embedding raises `RuntimeError`, and no table rows are written. The embed failure path writes `[]` when it produces no vector for a row. Such a row used to be counted as a wrong-length vector and silently excluded, letting a run publish a short index and still report `success: true`.
96+
During a dense-vector step 1, a row that arrives with an **empty list or tuple** embedding raises `RuntimeError`, and no table rows are written. The embed failure path writes `[]` when it produces no vector for a row. Such a row used to be counted as a wrong-length vector and silently excluded, letting a run publish a short index and still report `success: true`.
9797

98-
The new guard treats only an empty list or tuple as fatal. A row whose embedding is absent or `None` keeps its pre-existing silent drop. For this policy, `None` has two relevant meanings: a deliberate skip and a genuine embedding failure. For example, `operators/embed/text_embed.py` writes `error: None` alongside a blank-text skip, while its failure handler writes a populated `error` dict with stage, type, message, and traceback. Other embedding paths can produce the same two meanings. `common/vdb/records.py` already reads the payload one layer above this writer, so the discriminator is available. Threading it down is follow-up work, and making `None` fatal without it would fail ingests that work today. Until then, a batch lost through that path is still dropped silently. `on_bad_vectors` is unaffected: it governs malformed vectors, and an empty embedding is the absence of a vector rather than a short one. The returned `counts` dict gains an `empty_embedding` key.
98+
The new dense-writer guard treats only an empty list or tuple as fatal. A row whose embedding is absent or `None` keeps its pre-existing handling. For direct nested LanceDB records, the writer silently drops that row and counts it as `dropped_no_embedding`. Graph rows are converted first: a mixed batch can omit failed `None` rows while indexing healthy rows, but an all-rejected batch raises `VdbUploadError`. For this policy, `None` has two relevant meanings: a deliberate skip and a genuine embedding failure. For example, `operators/embed/text_embed.py` writes `error: None` alongside a blank-text skip, while its failure handler writes a populated `error` dict with stage, type, message, and traceback. Other embedding paths can produce the same two meanings. `common/vdb/records.py` reads that payload during conversion, so the discriminator is available there, but it is not forwarded to the writer. Making every `None` fatal would fail deliberate skips; source-aware handling is follow-up work. `on_bad_vectors` is unaffected: it governs malformed vectors, and an empty embedding is the absence of a vector rather than a short one. The returned `counts` dict gains an `empty_embedding` key.
9999

100100
The other half of the guard is upstream: a local embedder that fails to embed some rows raises `LocalEmbedderRowsLostError` from `_finalize_vectors` instead of zero-padding them, which this writer could not otherwise detect.
101101

nemo_retriever/tests/test_embed_engine_failure_propagation.py

Lines changed: 35 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@
88

99
from typing import Any
1010

11+
import httpx
1112
import pandas as pd
1213
import pytest
1314

1415
from nemo_retriever.models.embed_errors import (
1516
LocalEmbedderReturnedNothingError,
1617
LocalEmbedderRowsLostError,
1718
)
18-
from nemo_retriever.models.inference import main_text_embed, runtime
19+
from nemo_retriever.models.inference import runtime
1920
from nemo_retriever.models.nim.error_reporter import drain_errors
2021

2122

@@ -62,11 +63,16 @@ def _batch(rows: int = 4) -> pd.DataFrame:
6263
)
6364

6465

65-
def _raise(exc: BaseException):
66-
def _fail(*_args: Any, **_kwargs: Any) -> pd.DataFrame:
67-
raise exc
66+
class _TextModel:
67+
"""Model-boundary double for the local runtime."""
6868

69-
return _fail
69+
def __init__(self, result: Any) -> None:
70+
self.result = result
71+
72+
def embed(self, _texts: Any, *, batch_size: int) -> Any:
73+
if isinstance(self.result, BaseException):
74+
raise self.result
75+
return self.result
7076

7177

7278
def _wrapped(outer: BaseException, cause: BaseException) -> BaseException:
@@ -103,60 +109,56 @@ def _wrapped(outer: BaseException, cause: BaseException) -> BaseException:
103109
),
104110
],
105111
)
106-
def test_local_engine_failure_propagates(monkeypatch: pytest.MonkeyPatch, exc: BaseException) -> None:
107-
monkeypatch.setattr(runtime, "_embed_group", _raise(exc))
108-
112+
def test_local_engine_failure_propagates(exc: BaseException) -> None:
109113
with pytest.raises(type(exc)):
110-
runtime.embed_text_main_text_embed(_batch(), model=object(), inference_batch_size=2)
114+
runtime.embed_text_main_text_embed(_batch(), model=_TextModel(exc), inference_batch_size=2)
111115

112116

113117
def test_local_embedder_returning_nothing_is_raised_as_a_classified_failure() -> None:
114118
with pytest.raises(LocalEmbedderReturnedNothingError):
115-
main_text_embed._callable_runner([["page one", "page two"]], embedder=lambda _texts: [], batch_size=2)
119+
runtime.embed_text_main_text_embed(_batch(2), model=_TextModel([]), inference_batch_size=2)
116120

117-
assert runtime._is_engine_lifecycle_failure(LocalEmbedderReturnedNothingError("no vectors"))
118121

122+
def test_partial_local_result_keeps_the_pre_existing_fallback() -> None:
123+
out_df = runtime.embed_text_main_text_embed(_batch(2), model=_TextModel([[1.0, 2.0]]), inference_batch_size=2)
119124

120-
def test_partial_local_result_is_a_plain_value_error() -> None:
121-
with pytest.raises(ValueError) as excinfo:
122-
main_text_embed._callable_runner([["page one", "page two"]], embedder=lambda _texts: [[1.0, 2.0]], batch_size=2)
123-
124-
assert not isinstance(excinfo.value, LocalEmbedderReturnedNothingError)
125-
assert not runtime._is_engine_lifecycle_failure(excinfo.value)
125+
assert list(out_df["text_embeddings_1b_v2_has_embedding"]) == [False, False]
126+
assert [payload["embedding"] for payload in out_df["text_embeddings_1b_v2"]] == [[], []]
126127

127128

128129
@pytest.mark.parametrize(
129130
"model",
130131
[pytest.param(None, id="model-nulled-by-the-operator"), pytest.param(object(), id="model-also-set")],
131132
)
132133
def test_endpoint_mode_absorbs_engine_lifecycle_failure(monkeypatch: pytest.MonkeyPatch, model: object) -> None:
133-
monkeypatch.setattr(runtime, "_embed_group", _raise(RuntimeError(ENGINE_INIT_FAILED)))
134+
original_client = httpx.Client
135+
136+
def client_factory(*_args: Any, **_kwargs: Any) -> httpx.Client:
137+
transport = httpx.MockTransport(lambda _request: httpx.Response(400, text=ENGINE_INIT_FAILED))
138+
return original_client(transport=transport)
139+
140+
monkeypatch.setattr(httpx, "Client", client_factory)
134141

135142
out_df = runtime.embed_text_main_text_embed(
136143
_batch(),
137144
model=model,
138145
embedding_endpoint="http://embed.example/v1",
139-
inference_batch_size=2,
146+
inference_batch_size=4,
140147
)
141148

142149
assert list(out_df["text_embeddings_1b_v2_has_embedding"]) == [False] * 4
143150
assert [payload["embedding"] for payload in out_df["text_embeddings_1b_v2"]] == [[]] * 4
144151

145152

146-
def test_an_oom_alone_is_not_classified_as_an_engine_failure(monkeypatch: pytest.MonkeyPatch) -> None:
147-
monkeypatch.setattr(runtime, "_embed_group", _raise(OutOfMemoryError(OOM_IN_GELU)))
148-
149-
out_df = runtime.embed_text_main_text_embed(_batch(), model=object(), inference_batch_size=2)
153+
@pytest.mark.parametrize(
154+
"exc",
155+
[
156+
pytest.param(OutOfMemoryError(OOM_IN_GELU), id="bare-oom"),
157+
pytest.param(EngineGenerateError("generate() failed"), id="recoverable-generate-error"),
158+
],
159+
)
160+
def test_recoverable_local_failure_keeps_the_pre_existing_fallback(exc: BaseException) -> None:
161+
out_df = runtime.embed_text_main_text_embed(_batch(), model=_TextModel(exc), inference_batch_size=2)
150162

151163
assert list(out_df["text_embeddings_1b_v2_has_embedding"]) == [False] * 4
152164
assert [payload["embedding"] for payload in out_df["text_embeddings_1b_v2"]] == [[]] * 4
153-
154-
155-
def test_engine_lifecycle_classifier_rejects_unrelated_failures() -> None:
156-
assert not runtime._is_engine_lifecycle_failure(ValueError("could not decode image payload"))
157-
assert not runtime._is_engine_lifecycle_failure(TimeoutError("read timed out"))
158-
# Recoverable on the HuggingFace backend; see the module docstring.
159-
assert not runtime._is_engine_lifecycle_failure(OutOfMemoryError(OOM_IN_GELU))
160-
# vLLM documents this one as recoverable in its own source.
161-
assert not runtime._is_engine_lifecycle_failure(EngineGenerateError("generate() failed"))
162-
assert runtime._is_engine_lifecycle_failure(RuntimeError(ENGINE_INIT_FAILED))

nemo_retriever/tests/test_lancedb_collections.py

Lines changed: 10 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1130,19 +1130,6 @@ def delete_target():
11301130
assert delete_errors == []
11311131

11321132

1133-
def _collection_context() -> CollectionWriteContext:
1134-
return CollectionWriteContext(
1135-
scope="workspace-a",
1136-
collection_name="collection-a",
1137-
document_id="document-a",
1138-
document_version="v1",
1139-
content_sha256="sha-v1",
1140-
filename="source.pdf",
1141-
job_id="job-a",
1142-
operation="append",
1143-
)
1144-
1145-
11461133
def _collection_record(embedding: Any, *, text: str = "first chunk") -> dict:
11471134
return {
11481135
"document_type": "text",
@@ -1156,56 +1143,25 @@ def _collection_record(embedding: Any, *, text: str = "first chunk") -> dict:
11561143

11571144

11581145
@pytest.mark.parametrize("empty_embedding", [pytest.param([], id="list"), pytest.param((), id="tuple")])
1159-
def test_an_empty_embedding_on_the_collection_path_fails_the_write(empty_embedding: Any) -> None:
1146+
def test_an_empty_embedding_on_the_collection_path_fails_the_write(tmp_path, empty_embedding: Any) -> None:
1147+
backend = _backend_with_collection(tmp_path)
11601148
records = [[_collection_record(empty_embedding), _collection_record([1.0, 0.0])]]
11611149

11621150
with pytest.raises(RuntimeError) as excinfo:
1163-
_collection_rows(records, context=_collection_context())
1151+
backend.write_collection(records, context=_context())
11641152

11651153
message = str(excinfo.value)
11661154
assert "incomplete document" in message
11671155
assert "empty_embedding=1" in message
1156+
with pytest.raises(VDBResourceNotFound):
1157+
backend.get_document(scope="workspace-a", collection_name="collection-a", document_id="document-a")
11681158

11691159

1170-
@pytest.mark.parametrize(
1171-
"skipped_record",
1172-
[
1173-
pytest.param(None, id="non-dict-record"),
1174-
pytest.param({"metadata": None}, id="non-dict-metadata"),
1175-
pytest.param(_collection_record(None), id="malformed-embedding"),
1176-
pytest.param(_collection_record([1.0, 0.0], text=" "), id="text-free-non-image"),
1177-
],
1178-
)
1179-
def test_collection_failure_counts_each_pre_existing_silent_skip(skipped_record: Any) -> None:
1180-
records = [
1181-
[
1182-
skipped_record,
1183-
_collection_record([]),
1184-
_collection_record([1.0, 0.0], text="good chunk"),
1185-
]
1186-
]
1187-
1188-
with pytest.raises(RuntimeError) as excinfo:
1189-
_collection_rows(records, context=_collection_context())
1190-
1191-
message = str(excinfo.value)
1192-
assert "empty_embedding=1" in message
1193-
assert "skipped_other=1" in message
1194-
assert "accepted=1" in message
1195-
1196-
1197-
def test_a_numpy_collection_embedding_does_not_raise_on_truthiness() -> None:
1160+
def test_collection_write_does_not_apply_sequence_truthiness_to_numpy(tmp_path) -> None:
11981161
numpy = pytest.importorskip("numpy")
1162+
backend = _backend_with_collection(tmp_path)
1163+
records = [[_collection_record(numpy.array([1.0, 0.0])), _collection_record([1.0, 0.0])]]
11991164

1200-
records = [
1201-
[
1202-
_collection_record(numpy.array([1.0, 0.0])),
1203-
_collection_record(numpy.array([])),
1204-
_collection_record([1.0, 0.0], text="good chunk"),
1205-
]
1206-
]
1207-
1208-
rows = _collection_rows(records, context=_collection_context())
1165+
result = backend.write_collection(records, context=_context())
12091166

1210-
assert len(rows) == 1
1211-
assert rows[0]["text"] == "good chunk"
1167+
assert result.written == 1

nemo_retriever/tests/test_lancedb_write_policy.py

Lines changed: 17 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
lancedb = pytest.importorskip("lancedb")
1515

16-
from nemo_retriever.common.vdb.lancedb import LanceDB, _create_lancedb_results
16+
from nemo_retriever.common.vdb.lancedb import LanceDB
1717

1818

1919
def _records(text: str = "hello", vector: list[float] | None = None) -> list[list[dict]]:
@@ -298,7 +298,10 @@ def test_dense_write_fails_on_image_only_row_with_an_empty_embedding(tmp_path: P
298298

299299

300300
def test_dense_write_drops_a_none_embedding_end_to_end(tmp_path: Path) -> None:
301-
records = [[_record(None), _record([1.0, 2.0], text="good row")]]
301+
records = _records(text="good row", vector=[1.0, 2.0])
302+
missing = _records(text="lost row", vector=[1.0, 2.0])[0][0]
303+
missing["metadata"]["embedding"] = None
304+
records[0].insert(0, missing)
302305

303306
table_rows = _write_rows(tmp_path, records)
304307

@@ -336,6 +339,18 @@ def test_dense_write_keeps_on_bad_vectors_fill_reachable(tmp_path: Path) -> None
336339
assert len(table_rows[0]["vector"]) == 2
337340

338341

342+
def test_dense_write_does_not_apply_sequence_truthiness_to_numpy(tmp_path: Path) -> None:
343+
numpy = pytest.importorskip("numpy")
344+
records = _records(text="good row", vector=[1.0, 2.0])
345+
unsupported = _records(text="unsupported row", vector=[1.0, 2.0])[0][0]
346+
unsupported["metadata"]["embedding"] = numpy.array([1.0, 2.0])
347+
records[0].insert(0, unsupported)
348+
349+
table_rows = _write_rows(tmp_path, records)
350+
351+
assert [row["text"] for row in table_rows] == ["good row"]
352+
353+
339354
def test_sparse_write_drops_image_only_row_without_text(tmp_path: Path) -> None:
340355
table_rows = _write_rows(tmp_path, _image_only_records([1.0, 0.0]), sparse=True)
341356

@@ -346,55 +361,3 @@ def test_sparse_write_drops_whitespace_only_text(tmp_path: Path) -> None:
346361
table_rows = _write_rows(tmp_path, _records(text=" \n\t "), sparse=True)
347362

348363
assert table_rows == []
349-
350-
351-
def _record(embedding: Any, *, text: str = "page text") -> dict:
352-
return {
353-
"document_type": "text",
354-
"metadata": {
355-
"embedding": embedding,
356-
"content": text,
357-
"content_metadata": {"page_number": 1, "id": "row-1"},
358-
"source_metadata": {"source_name": "doc.pdf"},
359-
},
360-
}
361-
362-
363-
def test_create_lancedb_results_rejects_empty_embeddings_without_length_check() -> None:
364-
with pytest.raises(RuntimeError, match="empty_embedding=1"):
365-
_create_lancedb_results([[_record([])]], expected_dim=None)
366-
367-
368-
def test_wrong_length_rows_are_forwarded_when_the_wrapper_check_is_disabled() -> None:
369-
records = [[_record([1.0]), _record([1.0, 2.0])]]
370-
371-
rows, counts = _create_lancedb_results(records, expected_dim=None)
372-
373-
assert len(rows) == 2
374-
assert counts["dropped_bad_length"] == 0
375-
assert counts["dropped_no_embedding"] == 0
376-
assert counts["empty_embedding"] == 0
377-
378-
379-
@pytest.mark.parametrize(
380-
"embedding",
381-
[
382-
pytest.param([0.0, 0.0], id="all-zero-but-present"),
383-
pytest.param((1.0, 2.0), id="tuple"),
384-
],
385-
)
386-
def test_present_vectors_are_accepted_whatever_their_values(embedding) -> None:
387-
rows, counts = _create_lancedb_results([[_record(embedding)]], expected_dim=2)
388-
389-
assert len(rows) == 1
390-
assert counts["accepted"] == 1
391-
392-
393-
def test_an_empty_numpy_array_remains_a_nonfatal_bad_length_row() -> None:
394-
numpy = pytest.importorskip("numpy")
395-
396-
rows, counts = _create_lancedb_results([[_record(numpy.array([]))]], expected_dim=2)
397-
398-
assert rows == []
399-
assert counts["empty_embedding"] == 0
400-
assert counts["dropped_bad_length"] == 1

0 commit comments

Comments
 (0)