# Part 2: Facts and entities | Team0

> Turn stored text into people, companies and work things, with facts that point back to where they were said.

Agent-readable version of https://team0.ai/learn/facts-and-entities. The page for people is at that address; this file carries the same content without layout.

---
[Building an agentic brain](https://team0.ai/learn)

Part 2

# Facts and entities

Turn stored text into people, companies and work things, with facts that point back to where they were said.

Yogev Ben-Tov · 6 min read

In part 1 you stored what a source says, as it says it. Your agents found more, and searched more. That's what raw text gives you: everything is in there somewhere, and the agent has to piece it together on every question.

This part turns that text into pieces that connect. By the end you'll have entities (people, companies and the things you work on), facts linked to them, and every fact pointing back to where it was said. Then you'll score again.

## Why facts, not passages

Take one line from an email: "Dana set a meeting with Jordan tomorrow at 14:00 about the proposal she sent him."

As a passage, it's one blob. An agent asking "what's going on with Jordan?" has to find it, read it, and work out who Jordan is and what "tomorrow" meant.

As facts, it's:

- **Dana** and **Jordan** are each an entity.
- "Dana sent Jordan a proposal" is a fact linked to both.
- "Dana meets Jordan on Oct 23 at 14:00" is another fact, which becomes past once the meeting happens.

Every later email, meeting or agent chat about Jordan attaches to the same entity. The picture of Jordan gets fuller over time, and an agent asking about Jordan reads that picture, not a pile of search results.

## Step 1: Pick your entity types

Start small, with what your agents ask about most. For a brain about your work, that's usually:

- **People**
- **Companies**
- **Work things:** a project, a feature, a deal, a policy

Don't design a full ontology up front. Add a type when your test set needs one.

## Step 2: Entities, with keys and names

```
create table entities (
  id    integer primary key,
  type  text not null,      -- 'person', 'company', 'project'
  name  text not null       -- the name you'd show
);

create table entity_keys (     -- things only this entity has
  entity_id integer not null references entities(id),
  kind      text not null,     -- 'email', 'domain', 'crm_id'
  value     text not null,
  unique (kind, value)
);

create table entity_names (    -- every other name it goes by
  entity_id integer not null references entities(id),
  name      text not null
);
```

The difference between `entity_keys` and `entity_names` is the most important decision in this part.

**Keys decide who is who.** An email address, a company domain, an id from your CRM. If two mentions share a key, they're the same entity. Nobody else has Jordan's email address. Three exceptions: a shared inbox like sales@ or an assistant writing for their boss is a contact, not a person; gmail.com and other free-mail domains are not a company; and phone numbers get passed on, so don't use them as keys.

**Names never decide on their own.** Two people are called Dana. "Dana" and "Dana at Northpeak" may or may not be the same person. A fuzzy match that merges two people is a silent error: every fact about one now shows up for the other, and nothing tells you. When a name is all you have, keep a separate entity and link it later when a key shows up, or ask.

Work things have no email address, so names matter more for them. Keep every other name, including short forms and names in other languages: "SSO" and "single sign-on" are one thing, so a decision about "SSO" is found by an agent asking about "single sign-on". "SSO for Enterprise" is a narrower thing, not the same one. When you can't tell, keep them apart and ask.

## Step 3: Facts that point back to the source

```
create table facts (
  id             integer primary key,
  subject_id     integer not null references entities(id),
  predicate      text not null,      -- 'start_date', 'meets_with', 'owns', 'decided'
  object_id      integer references entities(id),  -- when the value is another entity
  value          text,                             -- otherwise
  text           text not null,      -- the fact as a sentence an agent can read
  happened_at    text not null,      -- when it was said
  said_by        integer references entities(id),
  observation_id integer not null references observations(id)
);
```

SQLite only checks those `references` if you turn it on, on every connection: `pragma foreign_keys = on;`.

Two columns here are what make the rest of the series possible:

- **`observation_id`:** every fact points to the row it came from. Anyone, human or agent, can check what was actually said. Without it, a wrong fact can't be traced or fixed.
- **`said_by`:** who said it. "Dana says she's moving to Fairline" and "someone mentioned Dana might move" are not the same fact. Part 3 uses this to decide what to trust.

Notice what isn't here yet: nothing says which fact replaces which. Both "the pilot starts November 1" and "the pilot starts November 15" will be in this table. That's part 3.

## Step 4: Extract

Use a model to read each new observation and propose: the entities it mentions (with any keys it can see, like email addresses in the headers), and the facts, each as subject, predicate, value and a short sentence.

Three rules that keep this from going wrong:

- **The model proposes, your code decides.** The model suggests "this is Jordan". Your code checks the keys and decides whether that's an existing entity, a new one, or a question.
- **Keys come from structure, not from the model's guess.** The sender's address is in the email headers; attendees are in the calendar event. Take them from there.
- **Resolve relative dates against `happened_at`.** "Tomorrow" in an email from Oct 22 is Oct 23, not tomorrow from today.

Run it over everything from part 1, including the facts you moved out of your instruction files.

## Step 5: Read by entity

Change the reading line in your instruction file. Instead of searching raw text:

> Before answering about a person, company or project, find the entity by email, domain or name, then read its facts, most recent first. Search the `observations` table only if the facts don't answer it.

## Step 6: Score again

Ask the test set again in fresh sessions, save as `part2.jsonl`, and compare with part 1:

```
python3 score.py questions.jsonl part1.jsonl part2.jsonl
```

What you'll usually see on held-out questions:

- **Tool calls go down.** One read about Jordan replaces several searches.
- **"One person, seen in different places" questions get better,** wherever a shared key connected them.
- **"A value that changed" questions may still be wrong.** Both start dates are facts now, with no rule for which one is current.
- **Junk is now facts too.** The newsletter that mentioned your customer became a fact about your customer.

The last two are the next three parts: which fact replaces which (part 3), what never becomes a fact (part 4), and keeping it true as things change (part 5).

These are starter schemas for this exercise, not Team0's production data model. MD files with stable identifiers and source links can serve the same learning goal at a small scale; evaluate the behavior before migrating storage.

Next, part 3: the write path.

---

*Build and measure your own setup with the companion skill. [See the series](https://team0.ai/learn). For a maintained company install, [talk to Team0](https://calendar.app.google/uM9ptyv83Ubgnq226).*
