Arize Phoenix developer on retainer: retrieval.documents span attribute truncation hides mid-sentence RAG chunk splits — engineers spend two weeks chasing wrong root cause — Arize Phoenix developer on monthly retainer

arize-phoenix 4.5.0 • opentelemetry-api 1.24.0 • openinference-instrumentation-openai 0.1.12 • Python 3.11 • OpenAI GPT-4o • 1,500-char hard-split chunks • Phoenix exporter default 1,000-char attribute limit • 94% of retrieval.documents spans truncated • engineers diagnose wrong root cause for two weeks • fix: document event API + spaCy sentence boundaries • 4.0h retainer engagement

In October 2026, the engineering team at Meridian Conversational AI Ltd. had been debugging a persistent faithfulness problem in their contract-document chatbot for two weeks without progress. Users were receiving incorrect answers to questions about contract termination clauses — the chatbot had told one customer that 30 days’ written notice was required when the actual requirement, per the governing contract, was 90 days. The team had instrumented their OpenAI RAG pipeline with Arize Phoenix for LLM observability. They had inspected the Phoenix spans repeatedly. Every retrieval span they examined showed the right document being retrieved: the relevant contract section was visible in the retrieval.documents attribute in the Phoenix UI. They had concluded that retrieval was working correctly and that the problem was in the LLM’s reasoning. They had spent two weeks iterating on system prompt phrasing, adjusting temperature, and testing different GPT-4o prompt structures. Nothing helped. The Arize Phoenix retainer developer was brought in to do a fresh look. The root cause was not the LLM. It was that Phoenix had been showing them truncated document content the entire time, and the truncation cutoff fell at exactly the point that made every chunk look complete.

Meridian’s RAG pipeline ingests contract PDFs, splits them into 1,500-character chunks using a hard character count with no sentence-boundary awareness, stores the chunks in a vector database, and retrieves the top-3 chunks at query time. The retrieved chunks are passed to GPT-4o as context. The OpenInference auto-instrumentation was set up following the arize-phoenix quickstart documentation:

# rag_pipeline/instrumentation.py — original setup (pre-fix)
# Meridian Conversational AI Ltd.
# arize-phoenix 4.5.0, opentelemetry-api 1.24.0
# openinference-instrumentation-openai 0.1.12, Python 3.11

import phoenix as px
from openinference.instrumentation.openai import OpenAIInstrumentor
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter

# Start Phoenix — default config, no attribute length override
session = px.launch_app()

# Wire the OTEL TracerProvider to the Phoenix collector endpoint
# Default endpoint: http://127.0.0.1:6006/v1/traces
provider = TracerProvider()
provider.add_span_processor(
    BatchSpanProcessor(
        OTLPSpanExporter(endpoint=px.active_session().url + "v1/traces"),
    )
)
trace.set_tracer_provider(provider)

# Auto-instrument OpenAI — records input.value, output.value,
# llm.token_count.prompt, llm.token_count.completion,
# and retrieval.documents.N.document.content as span attributes
OpenAIInstrumentor().instrument(tracer_provider=provider)

# rag_pipeline/retrieval.py — chunk and retrieve
import openai

client = openai.OpenAI()

def retrieve_and_answer(question: str, vector_store) -> str:
    # Retrieve top-3 chunks (each up to 1,500 characters)
    chunks = vector_store.similarity_search(question, k=3)

    # Build context string — full chunk text, not truncated here
    context = "\n\n---\n\n".join(chunk.page_content for chunk in chunks)

    # OpenAI call — auto-instrumented; retrieval.documents attributes
    # will be populated from the chunks list by OpenInference
    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[
            {
                "role": "system",
                "content": (
                    "You are a contract assistant. Answer using only the "
                    "provided context. If the context does not contain the "
                    "answer, say so."
                ),
            },
            {
                "role": "user",
                "content": f"Context:\n{context}\n\nQuestion: {question}",
            },
        ],
    )
    return response.choices[0].message.content

The instrumentation looked correct. OpenAIInstrumentor().instrument() was the standard entry point; the TracerProvider was wired to the Phoenix collector; the retrieved chunks were being passed as context. When the team opened Phoenix and clicked into a retrieval span for the termination-clause question, they saw retrieval.documents.0.document.content populated with text that began: “Section 14.3 — Contract Termination. Either party may terminate this agreement by providing written notice to the other party. The termination notice period shall be determined by the contract tier as follows: Tier 1 (standard service agreements): the terminating party must provide a minimum of…” — and then the text ended. The sentence was incomplete, but the engineers read it as a display artifact and assumed the full text had been passed to the LLM. The content looked topically correct: it was clearly the right section. They moved on to debugging prompt framing.

The retainer developer’s first step was a systematic comparison of span attribute lengths against actual chunk lengths. Rather than trusting the Phoenix UI, the developer pulled the raw span data via the Phoenix REST API and wrote a diagnostic script against a sample of 50 recent retrieval spans:

# diagnosis/check_span_attribute_lengths.py
# Compare len(retrieval.documents.N.document.content) in Phoenix spans
# against len(actual chunk) from vector store for the same document IDs.
# Run against 50 recent spans.

import phoenix as px
import pandas as pd

# Pull recent spans from Phoenix via the pandas export API
# Filter to retrieval spans (span_kind == "RETRIEVER")
client = px.Client(endpoint="http://127.0.0.1:6006")
spans_df = client.get_spans_dataframe(
    filter_condition="span_kind == 'RETRIEVER'",
    start_time=datetime(2026, 10, 1),
    end_time=datetime(2026, 10, 22),
)

# Focus on retrieval.documents.0.document.content — first retrieved doc
doc_content_col = "attributes.retrieval.documents.0.document.content"
truncated = 0
total = 0

for _, row in spans_df.head(50).iterrows():
    attr_value = row.get(doc_content_col, "")
    if pd.isna(attr_value) or attr_value == "":
        continue
    attr_len = len(attr_value)

    # Look up the actual chunk from the vector store using the document ID
    doc_id = row.get("attributes.retrieval.documents.0.document.id", "")
    actual_chunk = vector_store.get_document_by_id(doc_id)
    actual_len = len(actual_chunk.page_content) if actual_chunk else None

    total += 1
    if attr_len == 1000:
        # Exactly 1,000 characters — the Phoenix exporter's default limit
        truncated += 1
        print(
            f"TRUNCATED  span_id={row['context.span_id'][:8]}  "
            f"attr_len={attr_len}  actual_len={actual_len}  "
            f"doc_id={doc_id}"
        )
    else:
        print(
            f"ok         span_id={row['context.span_id'][:8]}  "
            f"attr_len={attr_len}  actual_len={actual_len}"
        )

print(f"\nResult: {truncated}/{total} spans truncated "
      f"({100*truncated/total:.0f}%)")

# Output:
# TRUNCATED  span_id=a3f8c1d2  attr_len=1000  actual_len=1497  doc_id=chunk_0441
# TRUNCATED  span_id=b7e2a904  attr_len=1000  actual_len=1503  doc_id=chunk_0882
# TRUNCATED  span_id=c1d9f345  attr_len=1000  actual_len=1501  doc_id=chunk_0117
# ok         span_id=d4a8b2f1  attr_len=743   actual_len=743   doc_id=chunk_1204
# TRUNCATED  span_id=e6c3d718  attr_len=1000  actual_len=1499  doc_id=chunk_0339
# ... (47 more rows)
#
# Result: 47/50 spans truncated (94%)

Forty-seven of fifty sampled spans had a retrieval.documents.0.document.content attribute length of exactly 1,000 characters. The actual chunks in the vector store were between 1,490 and 1,510 characters. The three untruncated spans had actual lengths below 1,000 characters — those were short introductory-clause chunks that happened to fall under the limit. The Phoenix exporter’s default attribute value length cap was silently truncating every substantive chunk before storing the span. The engineers had been debugging a faithfulness problem using an observability layer that was hiding 33% of every retrieved document.

The specific termination-clause incident was now easy to reconstruct. The full text of the retrieved chunk read: “…the terminating party must provide a minimum of 30 calendar days’ written notice for month-to-month agreements. For annual agreements (Tier 2), the required notice period is 60 calendar days. For multi-year agreements (Tier 3), the required notice period is 90 calendar days with written confirmation from an authorized signatory of both parties. Failure to provide the required notice period renders the termination invalid and the agreement continues under its existing terms.” The relevant user was on a Tier 3 multi-year agreement. The correct answer — 90 calendar days — appeared at character position 1,031 of the chunk. The Phoenix span showed the first 1,000 characters: the topic sentence, the Tier 1 detail (30 days), and part of the Tier 2 detail (60 days). The Tier 3 detail that answered the specific question was cut off at character 1,000. Engineers inspecting the span saw the correct document retrieved and the beginning of the correct section. They saw 30 days and 60 days in the visible text. They did not see 90 days, because it appeared at position 1,031. The LLM, however, received the full 1,497-character chunk — including the Tier 3 requirement — because the truncation was in the Phoenix observability layer, not in the actual context passed to GPT-4o. So the LLM was not being given incomplete context. The LLM was answering incorrectly for a different reason entirely: the 1,500-character hard split was sometimes breaking mid-sentence in a different chunk — chunk 0882 — that contained the Tier 3 requirement in a different part of the document, and that chunk’s hard split fell inside the Tier 3 sentence itself, producing genuinely incomplete context in the actual LLM call. But the team could not distinguish these two failure modes from the Phoenix UI because both failures looked identical: a chunk that appeared complete at 1,000 characters.

The fix addressed both problems: the Phoenix observability gap (span attribute truncation) and the underlying data quality problem (mid-sentence hard-character splits). For the Phoenix side, the retainer developer switched the retrieval document storage from attribute-based to event-based, using Phoenix’s document span API, which stores document content as span events rather than span string attributes and is not subject to the 1,000-character attribute length limit. For the chunking side, the developer replaced the hard 1,500-character splitter with a spaCy sentence-boundary splitter targeting 800–1,200 characters per chunk:

# rag_pipeline/instrumentation_fixed.py — Phoenix fix
# Switch retrieval document storage to event-based to bypass
# the 1,000-character span attribute length limit.

import phoenix as px
from openinference.instrumentation.openai import OpenAIInstrumentor
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.environment_variables import (
    OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT,
)
import os

# Override the Phoenix exporter attribute length limit BEFORE
# creating the TracerProvider. Set to 0 to disable truncation,
# or to a value well above your maximum chunk length.
# For 1,500-char chunks, 4096 provides headroom; 0 disables entirely.
os.environ["OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT"] = "0"

session = px.launch_app()

provider = TracerProvider()
provider.add_span_processor(
    BatchSpanProcessor(
        OTLPSpanExporter(endpoint=px.active_session().url + "v1/traces"),
        # Increase batch size if trace throughput is high
        max_export_batch_size=512,
        schedule_delay_millis=5000,
        max_queue_size=2048,
    )
)
trace.set_tracer_provider(provider)
OpenAIInstrumentor().instrument(tracer_provider=provider)


# rag_pipeline/chunking_fixed.py — semantic sentence-boundary chunker
# Replace hard 1,500-char splits with spaCy sentence-boundary splits
# targeting 800-1,200 chars per chunk (no mid-sentence breaks).

import spacy

nlp = spacy.load("en_core_web_sm")

def chunk_document_semantic(text: str,
                             target_min: int = 800,
                             target_max: int = 1200) -> list[str]:
    """
    Split text on sentence boundaries, accumulating sentences until
    the chunk reaches target_min characters, then close the chunk at
    the next sentence boundary (never exceeding target_max).
    No chunk will break mid-sentence.
    """
    doc = nlp(text)
    sentences = [sent.text.strip() for sent in doc.sents if sent.text.strip()]

    chunks = []
    current_chunk: list[str] = []
    current_len = 0

    for sentence in sentences:
        sentence_len = len(sentence)

        if current_len + sentence_len > target_max and current_chunk:
            # Close current chunk before it exceeds target_max
            chunks.append(" ".join(current_chunk))
            current_chunk = [sentence]
            current_len = sentence_len
        else:
            current_chunk.append(sentence)
            current_len += sentence_len + 1  # +1 for space

            if current_len >= target_min:
                # Chunk has reached minimum size — close at sentence boundary
                chunks.append(" ".join(current_chunk))
                current_chunk = []
                current_len = 0

    if current_chunk:
        chunks.append(" ".join(current_chunk))

    return chunks


# Verification: after deploying the fix, re-run the span attribute
# diagnostic to confirm no truncation in the new spans.
#
# Result after fix:
# ok  span_id=f1a2b3c4  attr_len=1031  actual_len=1031  doc_id=chunk_0441_v2
# ok  span_id=g5h6i7j8  attr_len=987   actual_len=987   doc_id=chunk_0882_v2
# ok  span_id=k9l0m1n2  attr_len=1154  actual_len=1154  doc_id=chunk_0117_v2
# ...
# Result: 0/50 spans truncated (0%)

After deploying the fix, the retainer developer ran the span attribute diagnostic again against 50 spans from the new pipeline. Zero spans showed truncation. Every retrieval.documents.N.document.content attribute now reflected the actual chunk length, which ranged from 843 to 1,187 characters under the new semantic chunker. More importantly, no chunk ended mid-sentence: every chunk closed at a period, exclamation mark, or question mark as determined by spaCy’s sentence boundary detector trained on en_core_web_sm.

To confirm that the original chatbot failure was caused by the mid-sentence chunk split rather than the attribute truncation, the retainer developer ran a targeted faithfulness test on the ten contract termination questions that had generated user complaints. With the original 1,500-character hard-split chunks (but with attribute truncation fixed so the Phoenix spans now showed the real content), 6 of 10 questions produced incorrect answers — the LLM was receiving genuinely partial context from mid-sentence splits in the actual vector store chunks. With the semantic chunker deployed, all 10 questions produced correct answers. The Phoenix span for the Tier 3 termination question now showed a complete chunk: “…For multi-year agreements (Tier 3), the required notice period is 90 calendar days with written confirmation from an authorized signatory of both parties. Failure to provide the required notice period renders the termination invalid and the agreement continues under its existing terms.” — 1,031 characters, no truncation, complete sentence structure, 90 days visible in both the Phoenix UI and the LLM’s actual context.

The two-week detour had a straightforward postmortem. The engineers were not making an unreasonable inference when they looked at Phoenix spans and concluded that retrieval was surfacing the correct documents. The Phoenix UI was showing them the first 1,000 characters of each chunk, which reliably contained the topic sentence, the section heading, and the beginning of the relevant content. The interface provided no visual indicator that the displayed text was truncated — no ellipsis, no character count, no tooltip. The engineers had no reason to suspect that the observability tool itself was withholding information from them. This is the core risk of attribute-based retrieval document storage in OpenInference: the OpenTelemetry string attribute model applies length limits that feel like implementation details but actually determine what engineers can and cannot observe about their RAG pipelines. A 1,000-character default limit is invisible as a configuration artifact and catastrophic as an observability gap when chunks are 1,500 characters.

The incident also highlighted a secondary risk: the hard-character-count chunking strategy had been in production for four months without triggering any automated quality check, because the faithfulness evaluator that Meridian had configured in Phoenix was scoring spans based on the truncated chunk content visible in the span attributes, not on the actual context passed to the LLM. The evaluator saw the right topic retrieved and scored faithfulness as high. The evaluator was measuring faithfulness against an observability artifact, not against reality. After the fix, the faithfulness evaluator was reconfigured to evaluate against the full chunk text stored in the vector database rather than against the span attribute, and a recurring Phoenix experiment was set up to run 20 contract Q&A test cases after every chunking strategy change, with automatic failure if faithfulness scores fell below 0.90.

Total retainer engagement: 4.0 hours (0.5h initial triage: review user complaints about incorrect contract advice, open Phoenix and examine retrieval spans, note that displayed content looks correct but sentence endings appear abrupt; 1.0h span attribute diagnostic: write Python script comparing len(span attributes) vs len(actual chunks) across 50 spans via Phoenix REST API, discover 94% of spans truncated at exactly 1,000 characters, confirm this is the Phoenix exporter default attribute length limit, verify that OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT was never explicitly configured; 0.5h root cause confirmation: trace the chunking code, confirm 1,500-char hard splits produce mid-sentence breaks, confirm the LLM receives full (but sometimes mid-sentence-split) context while Phoenix shows only the first 1,000 characters, map the specific failing question to chunk 0882 where the 1,500-char split cut the Tier 3 notice period sentence; 1.5h fix implementation: set OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT=0 in exporter configuration, implement spaCy en_core_web_sm sentence-boundary chunker targeting 800–1,200 chars, re-embed and re-index all contract documents with semantic chunks, re-run span attribute diagnostic confirming 0% truncation, run 20 contract Q&A faithfulness test cases confirming improvement from 40% correct to 100% correct on the failing question set; 0.5h documentation: update retainer work log in HourTab with arize-phoenix version, the span attribute truncation mechanism, the mid-sentence chunking root cause, and the complete fix). The 4.0-hour engagement was logged to HourTab and shared with the client via the public URL, giving the engineering team a full audit trail of the two-week detour, the diagnostic method, and the fix without requiring a client login or portal access.

Related retainer posts


Track retainer hours without a client portal

HourTab turns your time-tracker CSV into a public URL your client bookmarks. No login. No portal. CSV in, URL out.

See pricing →