skip to content>_lesfer
11
Modules

of 14 · Oct 21, 2026 · 10 min read

When the Answer Spans Documents

Sometimes one document names a thing and another says what it applies to, and nothing in the question leads to the second. Link documents through the names they share, resolve spellings first, and follow those links under the same access rules.

Builds on the router from Part 9.

Add this module only if your answers depend on documents that refer to each other.

A new dealer bulletin arrives from Atlas Bikes' battery supplier:

Battery packs BP-36-2 with serial numbers 2509-0001 to 2509-0800 may swell after a deep discharge. Stop charging an affected pack, check the serial number on the pack label, and replace affected packs free of charge.

A technician asks the obvious question: "Which products are affected by the battery recall?"

The bulletin doesn't say. It names a part, because that's what the supplier knows. Which Atlas products use part BP-36-2 is written in two other places: the C-3 specification ("The C-3 uses the 36 V battery pack BP-36-2") and the S-2 scooter's warranty ("The warranty covers the scooter's removable 36 V battery pack, part BP36-2").

Here's what search supplies, with Part 10's code and this part's new documents (the recall bulletin and the battery part numbers arrive with the part-11 checkpoint):

Retrieved:  0.750  BULLETIN-2026-09-1-p0  0.500  BULLETIN-2026-02-2-p0  0.500  BULLETIN-2026-09-2-p0

The battery recall, rightly first. Then section 2 of a different bulletin, the charger recall from February, titled "Affected models": "The C-3 and the K-1 shipped with CH-25A chargers between March and May 2025." It matched "affected" and "recall".

So the model receives a recall that names no products, and next to it a list of affected models from another recall. "The C-3 and the K-1" is the answer it's most likely to give. The K-1 is wrong: it uses a different battery pack. The S-2 is missing. The citations would look fine.

Search did what it does: it found the passages that best match the question. The C-3 specification and the S-2 warranty don't match the question at all. They match the bulletin, through a part number. This part teaches the system to follow that link.

There's a catch: the question never mentions BP-36-2. The technician doesn't know the part number, and doesn't need to. The system learns it from the first piece of evidence it finds, the recall itself, and uses it to look for the rest. So the work happens at two moments:

  • At ingestion, the system records which blocks mention which part numbers, so it can later answer "where else is BP-36-2 mentioned?" instantly.
  • At question time, search finds the recall, the system reads the part number out of the recall, and then asks that question.

The bulletin and the C-3 specification have nothing in common except one name, BP-36-2, and one shared name is enough to connect them. If, at ingestion, every block records the part numbers it mentions, any two blocks that mention the same part are connected through it.

This is a graph: things (here, blocks of evidence and parts) connected by relationships (here, "mentions"). For Atlas, after ingestion, it looks like this:

flowchart LR
  B9["BULLETIN-2026-09 §1<br/>battery recall"] -- mentions --> P1((BP-36-2))
  S3["SPEC-C3-R5 §8<br/>C-3"] -- mentions --> P1
  W2["WARR-S2-R1 §2<br/>S-2"] -- mentions --> P1
  PL["PRICE-PARTS-2026 §1<br/>sales only"] -- mentions --> P1
  PL -- mentions --> P2((BP-48-1))
  PL -- mentions --> P3((BP-24-1))
  T7["SPEC-T7-R2 §10<br/>T-7"] -- mentions --> P2
  K1["SPEC-K1-R1 §10<br/>K-1"] -- mentions --> P3

Building it takes a few lines. Each block is scanned when it's ingested, and each part number it contains is recorded:

class LinkGraph:    def __init__(self) -> None:        self.mentions: dict[str, list[Chunk]] = {}  # part -> blocks that mention it
    def add(self, block: Chunk) -> None:        for part in part_numbers(block.text):            self.mentions.setdefault(part, []).append(block)

Part numbers are a good kind of name to link through: they have a fixed shape, so a regular expression finds them reliably, and they mean the same thing in every document. Product codes, contract numbers, and customer IDs share that quality. Names written in free text ("the removable battery", "our Rabat dealer") are harder; more on that below.

One part, two spellings

The S-2 warranty writes BP36-2, without the first dash. To a program comparing strings, BP36-2 and BP-36-2 are two different parts, and the S-2 would never be linked to the recall.

Recognizing that two names refer to the same thing is called entity resolution. Here it takes its simplest form: every part number is rewritten in the catalog's spelling before it's recorded.

PART_NUMBER = re.compile(r"\b([A-Z]{2})-?(\d{2})-(\d)\b")

def part_numbers(text: str) -> set[str]:    """Part numbers in the catalog's form, however they were typed: BP36-2 and BP-36-2 are one part."""    return {f"{family}-{size}-{version}" for family, size, version in PART_NUMBER.findall(text)}

The -? makes the first dash optional, and the result always has it. It's the same fix as Part 8's error codes typed without a dash, applied to ingestion instead of to the question. With the dash required, the links from the recall reach only one product:

links   start=['BULLETIN-2026-09#1'], linked=['BP-36-2 -> SPEC-C3-R5#8']

With it optional, both:

links   start=['BULLETIN-2026-09#1'], linked=['BP-36-2 -> SPEC-C3-R5#8', 'BP-36-2 -> WARR-S2-R1#2']

A fourth route

Following links on every question would drag in loosely related blocks. So the router from Part 9 gets a fourth path, follow, for questions that ask which things a piece of evidence applies to: "Which products…", "Which bikes…", "Which models…". Everything else keeps its path. "Which chargers are affected by the recall?", a case since Part 7, still goes to search and still passes.

The follow path, for "Which products are affected by the battery recall?":

  1. search, exactly as in Part 6. The question's words find the battery recall first. So far, the system knows nothing about BP-36-2.
  2. read the part numbers in that evidence. The same part_numbers function that ran at ingestion now runs on the recall's text. This is the step where the linking entity enters: it comes from the evidence, not from the question.
    part_numbers("Battery packs BP-36-2 with serial numbers 2509-0001 to 2509-0800 may swell ...")-> {'BP-36-2'}
  3. look it up in the graph. The graph built at ingestion knows every block that mentions BP-36-2:
    BP-36-2 -> BULLETIN-2026-09#1, PRICE-PARTS-2026#1, SPEC-C3-R5#8, WARR-S2-R1#2
    The recall is already in hand. The price list is dropped because service staff can't read it (more on that below). The same access and situation filters apply as in search. Two new blocks remain, and the trace records each one with the part that led to it:
    links   start=['BULLETIN-2026-09#1'], linked=['BP-36-2 -> SPEC-C3-R5#8', 'BP-36-2 -> WARR-S2-R1#2']
    In code, steps 2 and 3 are one short loop:
    for block in start:                                   # the best evidence from search    for part in sorted(part_numbers(block.text)):     # step 2: the part numbers it mentions        for other in self.mentions.get(part, []):     # step 3: every block that mentions the same part            if other.id not in seen and readable(other, groups) and where(other):                seen.add(other.id)                links.append((part, other))
  4. assemble: the starting evidence and the linked blocks, each labeled for citation. The prompt also states the links that were followed, so the model can explain why each block is there:
    Known facts: links followed from BULLETIN-2026-09#1: BP-36-2 also appears in SPEC-C3-R5#8 (C-3); BP-36-2 also appears in WARR-S2-R1#2 (S-2).
    [S1] BULLETIN-2026-09, section 1...Battery packs BP-36-2 with serial numbers 2509-0001 to 2509-0800 may swell after a deep discharge. ...
    [S2] SPEC-C3-R5, section 8...The C-3 uses the 36 V battery pack BP-36-2, mounted in the frame's battery tube. ...
    [S3] WARR-S2-R1, section 2...The warranty covers the scooter's removable 36 V battery pack, part BP36-2.
  5. generate and citations, as before.

The charger recall is gone, because the follow path supplies only the best evidence and what it links to. The prompt is smaller too: three blocks, about 270 tokens. And each product in the answer can cite the document that ties it to the recall, which is what a technician needs if a customer asks "are you sure my scooter is affected?"

The new test case checks both halves: the right evidence arrives, and the wrong list doesn't.

  PASS                   Q21-linked-products      all present; none supplied

The price list mentions BP-36-2 too ("C-3 36 V battery (BP-36-2): dealer price 410 EUR"), and only the sales desk may read it. A graph is a shortcut between documents, so it's also a shortcut around access control if you let it be one.

The rule is the same as for search: the filter applies at every step, not only at the start. For service staff, the price list is never a link target; for a manager who belongs to both groups, it is:

service staff:    linked=['BP-36-2 -> SPEC-C3-R5#8', 'BP-36-2 -> WARR-S2-R1#2']service manager:  linked=['BP-36-2 -> PRICE-PARTS-2026#1', 'BP-36-2 -> SPEC-C3-R5#8', 'BP-36-2 -> WARR-S2-R1#2']

The same goes for anything you derive from the graph. A summary of "everything about BP-36-2", built once at ingestion from all documents, would contain the dealer price, and every user who reads the summary would read the price. Derived content inherits the most restrictive access of its sources, or it's built per group.

What this can't do yet

The follow path makes two fixed choices: it starts from the best search result, and it follows one hop.

If the best result is wrong, the chain is wrong. Had the charger recall ranked first, the links would have started from it. One hop is also a limit. "Is the replacement pack for the recalled battery in stock at the Casablanca warehouse?" would need recall → part → replacement part → stock record, and which hop comes next depends on what the previous one found. A fixed path can't know that in advance. Letting the model decide its next step from what it found is Part 12.

What changed

PASS Q21-linked-products all present; none supplied

The held-out set got a linked question too, "Which bikes use the BP-48-1 battery pack?", answered from the T-7 specification. Development recall stays at 1.00, and every earlier case keeps its path and its result.

The system now

flowchart LR
  Q[Question] --> R{Router}
  R -- "search" --> S[Search, top k]
  R -- "list" --> L[Every matching record]
  R -- "calculate" --> C[Metric computed in code]
  R -- "follow" --> F[Search, best evidence]:::new
  F --> G["Follow part numbers, one hop (access + situation filters)"]:::new
  D[(Blocks)] --> X["Part numbers, normalized"]:::new
  X --> GR[(Link graph)]:::new
  GR --> G
  S --> M[Model]
  L --> M
  C --> M
  G --> M
  classDef new stroke-width:3px
links: BP-36-2 (4 blocks), BP-48-1 (2), BP-24-1 (2)routes: search, list, calculate, follow

Stop here if

The links your questions need run through identifiers you can find reliably (part numbers, product codes, contract or customer IDs), and one hop from the best evidence is enough. Each new kind of link is a small, deliberate addition: a pattern or an alias list, a normalization rule, and test cases that check both the evidence that should arrive and the evidence that shouldn't.

If the path depends on what each step finds, a fixed route won't get there. That's Part 12.