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
observationstable 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. For a maintained company install, talk to Team0.