Skip to content
academia.sh

Lesson 16 / 16

Chunking Sources Containing Tables and Images

Counting what the chunking decision does to retrieval recall when the same fictional corpus is presented in table form: because the procedure name sits only in the header, row-level chunking drops recall to 0.0000; block-level chunking gives 0.9500 but carries 88.7 tokens per request; storing each row together with its header gives the same 0.9500 recall with 38.5 tokens per request and raises the tokens stored in the index from 5702 to 8634. When the table arrives as an image, the source without a description gives 0.0000, and the source with a one-line description gives 0.9500 recall but 0.0000 answerability: the source is found, and the value inside it cannot be retrieved. This lesson closes the Embeddings and Retrieval-Augmented Generation course.

Published Updated

In this lesson

  • Measures row-level, block-level and header repetition chunking of a table on the same query set.
  • Counts retrieval recall and answerability separately and shows that the two come apart on an image source with a description.
  • Computes that the tokens stored in the index and the tokens sent per request move in opposite directions.
  • Collects the no-retrieval baseline and retrieval numbers of the course's sixteen lessons in a single table.
Contents

The previous lesson measured retrieval and fine-tuning side by side on three tasks and showed both rest on the same assumption: that everything in the corpus is plain text. The fictional documents were built from sentences, and each chunk said what it was about by itself.

The procedure documents of the field team are not like that. They contain tables, and in a table meaning does not live in the cell but in its relation to the header. When a table is split like a sentence, row and header are separated and the row says nothing by itself. In sources that carry images the loss goes one step further: no text exists at all. Multimodal input itself was measured in the Natural Language Processing and Computer Vision course, under computer vision, and the token cost of the input format was counted in the Building Applications with Model APIs course: the fine grid gave a hit rate of 0.8167, the cropped grid 0.7000, and the tokens per usable record fell from 224.1 to 60.0. Those procedures are not repeated here. This lesson measures only the chunking decision.

  • RP84. The corpus, the concept axis, the embedding, the query set and the table source are fictional and are produced inside the lesson. There is no real institution, product, document, parser or embedding endpoint.
  • RP85. This lesson turns the same two hundred documents into tables. The procedure name stays only in the header; the rows carry the field name, the requirement and the source columns. What the table is about is not written in the cells.
  • RP86. Five chunking modes are measured: row (each row is a chunk, the header is dropped), block (the whole table is one chunk), row + header (each row is stored together with the header), visual (the table arrives as an image, there is no text) and visual + description (the image has a one-line description).
  • RP87. Two measures are counted separately. Retrieval recall: does the name of the target procedure appear in the retrieved chunks. Answerability: do both the procedure name and a row of the table stand together in one of the retrieved chunks. An answer can be produced only if both are present.
  • RP88. The cost is written in three units: the number of chunks in the index, the tokens stored in the index, and the tokens entering the prompt per request. The first two are storage, the third is the cost of the call.
  • RP89. The whole measurement is deterministic: there is no sampling, the language model simulator is not called and the numbers do not change between runs, so the difference between two runs is zero by definition.
  • RP90. Retrieval brings three chunks in every mode; recall and answerability are read over these three.

Five Ways to Chunk a Table

The chunk size and overlap decision was measured on plain text in this course’s chunking lesson. In a table the decision stands on another axis: whether the header is stored together with the row.

# FICTIONAL. The corpus, the concept axis, the embedding, the query set and the table source are
# produced inside the lesson; there is no real institution, product, document or embedding endpoint.
SEED, M32 = 20260218, 0xFFFFFFFF
DIM = 32


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


def pick(u, w):
    t = 0.0
    for i, x in enumerate(w):
        t += x
        if u < t:
            return i
    return len(w) - 1


def tokenize(m):
    b = []
    for s in m.lower().replace(",", " , ").split():
        while len(s) > 5:
            b.append(s[:5])
            s = s[5:]
        b.append(s)
    return b


# ---- FICTIONAL concept axis: each concept has a separate surface form on the document side and on the query side
CLASSES = ["tariff", "fault", "meter", "outage", "maintenance"]
CODE = {"tariff": "TR", "fault": "AR", "meter": "SY", "outage": "KS", "maintenance": "BK"}
CONCEPTS = {                     # concept: (label, document-side form, query-side form)
    "tier":      ("tariff", "tier limit", "which band am i in"),
    "unit":      ("tariff", "unit price", "what a ton of water costs"),
    "account":   ("tariff", "account type", "home or business"),
    "switch":    ("tariff", "plan switch", "moving to another package"),
    "faultcode": ("fault", "fault code", "error id"),
    "leak":      ("fault", "line leak", "water escaping"),
    "pressure":  ("fault", "pressure drop", "weak flow"),
    "crew":      ("fault", "field crew", "technician when does it show up"),
    "index":     ("meter", "index value", "digits on the dial"),
    "read":      ("meter", "reading routine", "how it gets written down"),
    "swap":      ("meter", "meter swap", "changing the hardware"),
    "verify":    ("meter", "reading check", "typed in by mistake"),
    "planned":   ("outage", "planned outage", "stoppage announced ahead"),
    "notice":    ("outage", "notice channel", "where do i find out"),
    "duration":  ("outage", "outage duration", "how long does it last"),
    "restore":   ("outage", "service restoration", "when does it show up"),
    "periodic":  ("maintenance", "periodic maintenance", "regular inspection"),
    "valve":     ("maintenance", "valve check", "tap shutoff order"),
    "filter":    ("maintenance", "filter replacement", "cleaning part"),
    "calib":     ("maintenance", "calibration record", "accuracy tweak"),
}
KEYS = list(CONCEPTS)
COMMON = ["entry", "area", "term", "task", "form", "okay", "note"]

# ---- FICTIONAL corpus
DOCUMENTS = []
for i in range(200):
    r = rng(SEED + 7919 * i)
    s = CLASSES[pick(r(), [0.22, 0.24, 0.20, 0.18, 0.16])]
    pool = [k for k in KEYS if CONCEPTS[k][0] == s]
    main = pool[int(r() * len(pool))]
    n = 2 + int(r() * 4)
    paras, concepts = [], [main]
    for _ in range(n):
        y = pool[int(r() * len(pool))]
        concepts.append(y)
        o = COMMON[int(r() * len(COMMON))]
        paras.append(f"{CONCEPTS[y][1]} for {o} goes with {CONCEPTS[main][1]} and is filed")
    DOCUMENTS.append({"code": f"{CODE[s]}{i + 1:03d}", "label": s, "main": main,
                      "concepts": concepts, "title": f"{CONCEPTS[main][1]} procedure",
                      "paras": paras})


# ---- FICTIONAL embedding: the surface is first reduced to a concept, the concept then to dimensions
def _to_concepts(m, domain=True):
    v = [0.0] * len(KEYS)
    dm = " " + m.lower() + " "
    for i, k in enumerate(KEYS):
        doc, qry = CONCEPTS[k][1], CONCEPTS[k][2]
        if doc in dm:
            v[i] += 1.0
        if qry in dm:
            v[i] += 1.0 if domain else 0.0      # the out-of-domain embedding does not recognize the query language
    if not domain:
        for o in COMMON:
            if " " + o + " " in dm:
                for i in range(len(KEYS)):
                    v[i] += 0.05
    return v


PROJECTION = []
for i in range(len(KEYS)):
    r = rng(SEED ^ (i + 1) * 2654435761)
    PROJECTION.append([r() * 2 - 1 for _ in range(64)])


def embed(m, dim=DIM, domain=True):
    k = _to_concepts(m, domain)
    v = [0.0] * dim
    for i, a in enumerate(k):
        if a:
            for j in range(dim):
                v[j] += a * PROJECTION[i][j % 64]
    n = sum(x * x for x in v) ** 0.5
    return [x / n for x in v] if n else v


def cosine(a, b):
    return sum(x * y for x, y in zip(a, b))


# ---- FICTIONAL query set: the query language is separate from the document language
QUERIES = []
for i in range(40):
    r = rng(SEED + 104729 * i)
    d = DOCUMENTS[int(r() * len(DOCUMENTS))]
    QUERIES.append({"text": f"{CONCEPTS[d['main']][2]} {COMMON[int(r() * len(COMMON))]}",
                    "label": d["label"], "concept": d["main"]})

# FICTIONAL table source: each procedure document is turned into a table.
# The procedure name is ONLY in the header; the rows carry the field name, the requirement and the source.
TABLES = []
for i, d in enumerate(DOCUMENTS):
    r = rng(SEED + 4093 * (i + 1))
    TABLES.append({"code": d["code"], "main": d["main"],
                   "head": f"{CONCEPTS[d['main']][1]} procedure table",
                   "rows": [f"{p.split()[3]} | {'yes' if r() < 0.5 else 'no'}"
                            f" | {'field' if r() < 0.5 else 'central'}" for p in d["paras"]]})


def build_index(mode):
    chunks = []
    for t in TABLES:
        if mode == "row":
            chunks += [{"code": t["code"], "text": s} for s in t["rows"]]
        elif mode == "block":
            chunks.append({"code": t["code"], "text": t["head"] + " ; " + " ; ".join(t["rows"])})
        elif mode == "row + header":
            chunks += [{"code": t["code"], "text": t["head"] + " ; " + s} for s in t["rows"]]
        elif mode == "visual":
            chunks.append({"code": t["code"], "text": ""})
        else:
            chunks.append({"code": t["code"], "text": t["head"]})
    return chunks


def measure(mode, k=3):
    chunks = build_index(mode)
    vecs = [embed(p["text"]) for p in chunks]
    recall = answerable = tokens = 0
    for s in QUERIES:
        v = embed(s["text"])
        top = sorted(range(len(chunks)), key=lambda i: (-cosine(v, vecs[i]), i))[:k]
        name = CONCEPTS[s["concept"]][1]
        recall += any(name in chunks[i]["text"] for i in top)
        answerable += any(name in chunks[i]["text"] and " | " in chunks[i]["text"] for i in top)
        tokens += sum(len(tokenize(chunks[i]["text"])) for i in top)
    n = len(QUERIES)
    return recall / n, answerable / n, len(chunks), sum(len(tokenize(p["text"])) for p in chunks), tokens / n


print("mode                  recall answerable index chunks stored tokens tokens per request")
for mode in ("row", "block", "row + header", "visual", "visual + description"):
    a, c, np_, sb, ib = measure(mode)
    print(f"{mode:<21} {a:>6.4f} {c:>10.4f} {np_:>12} {sb:>13} {ib:>18.1f}")
mode                  recall answerable index chunks stored tokens tokens per request
row                   0.0000     0.0000          693          3815               17.0
block                 0.9500     0.9500          200          5702               88.7
row + header          0.9500     0.9500          693          8634               38.5
visual                0.0000     0.0000          200             0                0.0
visual + description  0.9500     0.0000          200          1194               18.7

The Source Found, the Value Not Retrieved

Row-level chunking is the cheapest mode: 17.0 tokens per request and 3815 tokens in the index. And its recall is 0.0000. The query asks about a procedure, the rows do not contain its name, and the embedding finds no row close to the query. The loss is not a ranking fault; the surface searched for never entered the index. The same chunking worked on plain text, because there every sentence repeated its own subject.

Block-level chunking brings recall to 0.9500 and answerability with it: the header and the rows are in the same chunk. Its cost is 88.7 tokens per request, over five times the row-level mode. Stored tokens go from 3815 to 5702, since the headers are stored too.

The row + header mode lies between the two. Recall and answerability are identical to the block’s (0.9500), but the tokens per request fall to 38.5: the three chunks retrieved are now three rows, not a whole table. The price is in storage. With the header repeated in every row, stored tokens go from 5702 to 8634, against the block mode. The two costs move in opposite directions: the tokens per request fall while the stored tokens rise. The choice depends on the number of requests; it is a question of numbers, not of style.

The last two rows show the multimodal source’s own loss. When the table arrives as an image with no description, there is no text in the index: recall is 0.0000, stored tokens are 0. The source sits in the index, answering no query: dead weight in the retrieval space.

The real warning is in the fifth row. With a one-line description, recall jumps to 0.9500 while answerability stays at 0.0000. The description says what the table is about, it does not say what is written in it. Read with the four-cell contingency of retrieval and generation from the evaluation lesson, this is the box where the right chunk is retrieved but the answer cannot be produced; whoever reads a single recall number will think this mode works.

Summary

  • In a table, meaning is not in the cell but in its relation to the header; row-level chunking drops the header, so recall falls to 0.0000, and the loss is in the index, not the ranking.
  • Block-level chunking gives 0.9500 recall at 88.7 tokens per request; row + header gives the same 0.9500 at 38.5.
  • Repeating the header costs storage: stored tokens go from 5702 to 8634, against the block mode; the two costs move in opposite directions.
  • An image source without a description carries no text in the index, its recall is 0.0000 and it is dead weight in the retrieval space.
  • A one-line description brings recall to 0.9500 but leaves answerability at 0.0000: the source is found, and the value inside it cannot be retrieved.

Course Wrap-Up

Lesson No-retrieval baseline With retrieval Cost
embeddings/01 — first result’s concept hit rate keyword search 0.0250 embedding similarity 0.5000 (+0.4750) 8000 comparisons, 240 embedding calls, 6400 stored values
embeddings/02 — concept hit rate, 32 dimensions out-of-domain embedding 0.0500, barely above the 0.0250 baseline in-domain embedding 0.5000 (+0.4500) stored values 6400 in both setups; band between two models 0.0250
embeddings/03 — first result’s concept hit rate dot product 0.4250 cosine similarity 0.5000 (+0.0750) first three diverge in thirty of forty queries; average of the retrieved set 66.2 → 57.7 tokens
embeddings/04 — recall against exhaustive search exhaustive search 1.0000, 200 comparisons bucket-based index with 2 probes 0.9500 (−0.0500) 47.2 comparisons; with 1 probe recall 0.7200 and concept precision 0.4700 → 0.3800
embeddings/05 — filtered recall unfiltered index 0.9500 in-index filtering, width 1: 0.4750 (−0.4750) candidates 31.2 → 2.8; exhaustive search of the filtered set 1.0000 with 18 comparisons
embeddings/06 — first rank in the document language class embedding alone 0.5000 rank fusion 0.4000 (−0.1000) in the code class 1.0000 → 0.0500; recall up to the twentieth rank 0.7122 → 0.7214
retrieval-pipeline/01 — generation hit rate no-retrieval baseline 0.2000 pipeline with retrieval 0.9250 (+0.7250) 3033 → 4848 tokens, 120 chunks; run with wrong chunks 0.0500
retrieval-pipeline/02 — generation hit rate single block without preparation 0.0000, its recall 1.0000 structure-aware preparation 0.9250 (+0.9250) stored chunks 1805 → 693; the filter deletes 96 of 568 concept links
retrieval-pipeline/03 — share of chunks carrying a relation occurrence whole size 8 chunking 0.0765 size 32 chunking 0.8846 (+0.8081) stored chunks 1217 → 369; at size 80 generation 0.5500, 0.15 of requests exceed the context limit
retrieval-pipeline/04 — up-to-date answer share refresh once in twelve periods 0.6125 refresh every period 0.9250 (+0.3125) embedded chunks 519 → 881; stale chunk rate 0.2562 → 0.0000, wrong answer rate 0.3875 → 0.0750
retrieval-pipeline/05 — retrieval recall on the short phrasing without transformation 0.3250 expansion 0.7500 (+0.4250) one call per query, 4609 → 5804 tokens; on the long phrasing recall 0.5500 → 0.5000
retrieval-pipeline/06 — precision of the top three single-stage retrieval 0.4167 (generation 0.7000) reranking 24 candidates 0.9000 (+0.4833, generation 1.0000) calls 40 → 1000, tokens 3654 → 38003; at 48 candidates recall 1.0000 → 0.9500
retrieval-pipeline/07 — generation hit rate / recall one chunk 0.7750 / 0.7750 ten chunks 0.5750 (−0.2000) / 0.9750 13.4 chunks fit the context window; at k=13 fifteen of forty queries break the limit
retrieval-pipeline/08 — end-to-end accuracy rate no-retrieval baseline 0.3750, all of it chance three chunks 0.9500 (+0.5750) 120 chunks, 1872 tokens; same 0.9500 with five chunks, the fault carries into generation
retrieval-pipeline/09 — knowledge freshness no retrieval 0.0000 with retrieval 0.9500 (+0.9500) 2232 tokens, 40 retrieval steps; on format compliance 0.0000 → 0.0000
retrieval-pipeline/10 — answerability row-level chunking 0.0000 row + header 0.9500 (+0.9500) 17.0 → 38.5 tokens per request; stored in the index 3815 → 8634

The course’s rule is written in the table row by row: retrieval and generation are measured separately. No number in the no-retrieval baseline column comes from a bad model; all of them come from the answer being not inside the model but in the corpus. Reducing the two columns to a single ratio hides the most instructive rows: in retrieval-pipeline/02, the unprepared source is the best of the retrieval column (recall 1.0000) and the worst of the generation column (0.0000).

The second claim can also be read from the same table: retrieval spoils too. In embeddings/02, the out-of-domain embedding falls below the keyword baseline on the retrieved set. In embeddings/04, with 1 probe recall falls to 0.7200 and the concept precision of the first five from 0.4700 to 0.3800. In embeddings/05, under a narrow filter the index gives 0.4750 with 18.8 comparisons; exhaustive search of the filtered set gives 1.0000 with 18. In embeddings/06, fusion is worse than both in the document language class and takes 1.0000 to 0.0500 in the code class. In retrieval-pipeline/01, the run with wrong chunks drops generation below the no-retrieval baseline. In retrieval-pipeline/03, raising the chunk size to 80 carries relation occurrence to 0.9971 and drops generation to 0.5500, because 0.15 of the requests exceed the context limit. In retrieval-pipeline/04, a longer refresh interval does not move the retrieval column; all the degradation stays in the generation column: the up-to-date answer share falls from 0.9250 to 0.6125. In retrieval-pipeline/05, rewriting takes the baseline recall from 0.5500 to 0.2750 on the long phrasing. In retrieval-pipeline/06, going from 24 to 48 candidates adds an unmeasurable 0.0083 to precision, takes recall from 1.0000 to 0.9500 and almost doubles the tokens. In retrieval-pipeline/07, ten chunks raise recall to 0.9750 and drop generation to 0.5750: what is retrieved is right, what is used is wrong. In retrieval-pipeline/08, going up to five chunks makes recall 1.0000, does not change the end-to-end number and raises the tokens from 1872 to 2932. In retrieval-pipeline/09, retrieval adds nothing on format compliance, from 0.0000 to 0.0000. And in this lesson, the visual source with a description takes recall to 0.9500 and leaves answerability at 0.0000: a number that improved, a job that did not.

Over sixteen lessons, which information enters the model was measured. But we always made this choice: how many chunks to retrieve, which criterion to rank by, whether to transform the query and how to build the context were written into the pipeline by hand. The pipeline was also single-pass: the query went in, the chunks came, the answer went out. Which tool the model would call by its own decision, how it would judge the result insufficient and retry, and where this loop would stop were never asked even once. The next course builds this loop and turns the stopping condition into a measurement object.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close