Skip to content

Commit f615929

Browse files
groksrcclaude
andauthored
feat: structure-corpus converter + flat-vs-structured findings (#40)
Adds `bm-bench convert structure-corpus`, which rewrites a flat conversation corpus into Basic Memory's native representation (typed `- [category]` observations and `- relation [[Entity]]` links) via the plan-billed `claude -p` extractor (no API spend), so BM can be measured "at its best" instead of purely as a text search over chat logs. Two modes: `augment` (keep the transcript and append the structured block — how BM really works, since a note keeps its prose while its parsed observations/relations become first-class searchable units) and `replace` (structured block only; lossy). Doc ids and frontmatter are preserved, so retrieval ground truth, recall, QA, and the failure diagnostic stay directly comparable to the flat corpus. Output mirrors the input layout, so both flat (`docs/`) and grouped (`groups/<g>/docs/`) corpora work. 8 tests, no live LLM calls. Findings (benchmarks/results/structured-ingestion-findings.md): structure is a WASH where retrieval saturates (ConvoMem implicit_connection augment ties flat 0.449; replace hurts via lossy distillation) and HELPS retrieval where retrieval has headroom (LoCoMo multi_hop recall@5 0.778->0.833, recall@10 0.841->0.889, MRR 0.679->0.715; QA +1 because 81% of those failures are answerer-bound). The recall gain is from observations being better retrieval targets than facts buried in transcript, NOT query-time graph traversal (which BM does not do today) — which points to relation-traversal-at-search as a real future retrieval lever. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 92e8d3a commit f615929

4 files changed

Lines changed: 451 additions & 0 deletions

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# Structured ingestion: does Basic Memory's native representation help?
2+
3+
**Date:** 2026-06-14 · **Status:** internal finding · **Harness:** `convert structure-corpus`
4+
5+
## Question
6+
7+
The default benchmark ingests each conversation as a flat `## Conversation`
8+
transcript, so Basic Memory is exercised purely as a text-search engine — none
9+
of its native representation (typed `- [category]` observations and
10+
`- relation [[Entity]]` links) is used. Does running BM "at its best", with the
11+
conversations distilled into its knowledge-graph form, change the numbers?
12+
13+
## Method
14+
15+
`convert structure-corpus` rewrites each flat conversation doc into a BM-native
16+
note via the plan-billed `claude -p` extractor (no API spend), in two modes:
17+
18+
- **augment** (faithful): keep the transcript **and** append an extracted
19+
`## Observations` + `## Relations` block. This mirrors how BM actually
20+
works — a note keeps its prose while its parsed observations/relations become
21+
first-class searchable units.
22+
- **replace**: substitute the structured block for the transcript (lossy).
23+
24+
Document ids and frontmatter are preserved, so retrieval ground truth, recall,
25+
QA, and the failure diagnostic stay directly comparable to the flat corpus.
26+
Answerer (`claude-haiku-4-5`) and judge (`claude-sonnet-4-6`) are held constant;
27+
only the corpus representation changes.
28+
29+
## Results
30+
31+
### ConvoMem `implicit_connection` (49 q) — retrieval saturated
32+
33+
| arm | QA acc | correct |
34+
|---|---|---|
35+
| flat (transcripts) | **0.449** | 22/49 |
36+
| structured-replace (lossy) | 0.367 | 18/49 |
37+
| structured-augment (faithful) | **0.449** | 22/49 |
38+
39+
Augment **ties** flat exactly (it flips 10/49 questions, +5/−5 — a wash);
40+
replace **hurts** because distillation drops the specific personal details these
41+
questions hinge on. **Why:** ConvoMem cs10 retrieval is *saturated* (the
42+
diagnostic shows a retrieval ceiling of 1.000 — the answerer already receives the
43+
entire 10-doc haystack), so representation can only reshuffle what is already in
44+
context. **Implication:** flat markdown is a *fair* representation for BM on
45+
ConvoMem — the benchmark was not secretly hobbling BM there.
46+
47+
### LoCoMo `multi_hop` (63 q, full 272-doc haystack) — retrieval has headroom
48+
49+
| metric | flat | structured-augment | Δ |
50+
|---|---|---|---|
51+
| recall@5 | 0.778 | **0.833** | **+0.055** |
52+
| recall@10 | 0.841 | **0.889** | **+0.048** |
53+
| MRR | 0.679 | **0.715** | +0.036 |
54+
| QA accuracy | 0.556 (35/63) | 0.571 (36/63) | +0.015 |
55+
56+
Structure **improves retrieval**: +5 questions newly reach their gold evidence in
57+
the top-10 (−2 lost, net +3). Example — *"Where did Joanna travel in July 2022?"*:
58+
flat never retrieves the evidence (→ "I don't know"); structured does (→
59+
"Woodhaven", correct). QA barely moves (+1) because the diagnostic attributes 81%
60+
of multi_hop failures to the fixed answerer, so retrieval gains don't fully
61+
convert end-to-end.
62+
63+
## Interpretation
64+
65+
Structure is a **wash where retrieval saturates** (ConvoMem) and **helps recall
66+
where retrieval has headroom** (LoCoMo multi_hop) — a coherent two-slice story.
67+
68+
**Mechanism (important for credibility):** Basic Memory does **not** traverse
69+
relations at query time today, so the recall gain is **not** graph multi-hop. It
70+
comes from the concise observations being *better retrieval targets* than the same
71+
facts buried in a long transcript — a better lexical/semantic surface.
72+
73+
**Confound to close before publishing:** augmented docs are longer (transcript +
74+
structure), giving more match surface. A replace-mode retrieval ablation would
75+
isolate "structure" from "more text". The LoCoMo effect is also modest (n=63);
76+
confirm on the full q300 / multi_hop+temporal slice before any external claim.
77+
78+
**Product lever this surfaces:** following `[[Entity]]` relations during search
79+
(query-time relation traversal) would turn the structured representation into
80+
genuine multi-hop retrieval — a concrete future basic-memory retrieval
81+
improvement, distinct from the lexical-surface gain measured here.
82+
83+
## Reproduce
84+
85+
```bash
86+
# Structure a corpus (flat or grouped layout; augment keeps the transcript)
87+
bm-bench convert structure-corpus \
88+
--input-dir benchmarks/generated/locomo-corrected-v2/docs \
89+
--output-dir benchmarks/generated/locomo-corrected-v2-augmented/docs \
90+
--mode augment --extractor claude:claude-haiku-4-5
91+
92+
# Run flat vs structured over the same queries; compare recall + QA + diagnose
93+
bm-bench run retrieval --providers bm-local --corpus-dir <corpus> --queries-path <queries> ...
94+
bm-bench run qa --run-dir <run> --answerer claude:claude-haiku-4-5 --judge claude:claude-sonnet-4-6
95+
bm-bench run diagnose --run-dir <run>
96+
```

src/basic_memory_benchmarks/cli.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,63 @@ def convert_locomo(
102102
console.print(f"Queries: [cyan]{queries_path}[/cyan] ({query_count})")
103103

104104

105+
@convert_app.command("structure-corpus")
106+
def convert_structure_corpus(
107+
input_dir: Path = typer.Option(
108+
..., "--input-dir", help="Source corpus root (a flat docs dir or a grouped …/groups dir)"
109+
),
110+
output_dir: Path = typer.Option(
111+
..., "--output-dir", help="Destination root; the input layout is mirrored beneath it"
112+
),
113+
mode: str = typer.Option(
114+
"augment",
115+
"--mode",
116+
help="augment: keep transcript + append structure (faithful); replace: structure only (lossy)",
117+
),
118+
categories: str = typer.Option(
119+
"",
120+
"--categories",
121+
help="Grouped corpora only: comma-separated category labels to restructure (matches group-id prefix). Empty = all docs.",
122+
),
123+
extractor: str = typer.Option(
124+
"claude:claude-haiku-4-5", "--extractor", help="LLM runner spec for fact extraction"
125+
),
126+
max_workers: int = typer.Option(4, "--max-workers"),
127+
) -> None:
128+
"""Restructure flat conversation docs into Basic Memory observations/relations.
129+
130+
Produces a structured twin of a corpus with doc ids/frontmatter preserved, so
131+
a flat-vs-structured run isolates the representation and recall stays
132+
comparable. Works on both grouped and flat corpora (layout is mirrored).
133+
"""
134+
from basic_memory_benchmarks.converters.structure_corpus import (
135+
group_prefix_filter,
136+
structure_corpus,
137+
)
138+
from basic_memory_benchmarks.llm.runners import create_runner
139+
140+
if mode not in ("augment", "replace"):
141+
raise typer.BadParameter("--mode must be 'augment' or 'replace'")
142+
cats = {c.strip() for c in categories.split(",") if c.strip()}
143+
path_filter = group_prefix_filter(cats) if cats else None
144+
runner = create_runner(extractor)
145+
output_dir.mkdir(parents=True, exist_ok=True)
146+
147+
doc_count = structure_corpus(
148+
input_root=input_dir,
149+
output_root=output_dir,
150+
runner=runner,
151+
mode=mode, # type: ignore[arg-type]
152+
path_filter=path_filter,
153+
max_workers=max_workers,
154+
)
155+
156+
console.print(f"Structured ([green]{mode}[/green]): [cyan]{output_dir}[/cyan] ({doc_count} docs)")
157+
console.print(f"Extractor: [green]{extractor}[/green]")
158+
if cats:
159+
console.print(f"Filtered to categories: {sorted(cats)}")
160+
161+
105162
@convert_app.command("longmemeval")
106163
def convert_longmemeval(
107164
dataset_path: Path = typer.Option(
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
"""Restructure a flat conversation corpus into Basic Memory's native form.
2+
3+
The default benchmark corpus stores each conversation as a flat
4+
``## Conversation`` transcript, so Basic Memory is exercised purely as a
5+
text-search engine over chat logs — none of its actual representation (typed
6+
``- [category]`` observations and ``- relation [[Entity]]`` links) is used. That
7+
can undersell a system whose strength is a structured knowledge graph,
8+
especially where the answer requires joining facts the prose states separately.
9+
10+
This converter rewrites each conversation doc into the note shape a Basic Memory
11+
user/agent would actually write. Two modes:
12+
13+
- ``augment`` (default, the faithful one): keep the original transcript and
14+
*append* an LLM-extracted ``## Observations`` + ``## Relations`` block. This is
15+
how Basic Memory really works — a note keeps its prose AND its parsed
16+
observations/relations become first-class searchable units — so it can only
17+
add signal, never lose the source text.
18+
- ``replace``: swap the transcript for the extracted structure only. Useful to
19+
measure the pure distilled representation, but strictly lossy.
20+
21+
Document ids and frontmatter are always preserved, so retrieval ground truth and
22+
recall stay directly comparable to the flat corpus, and every downstream stage —
23+
provider ingest, retrieval scoring, QA, the failure diagnostic — works unchanged.
24+
The extractor runs through the same plan-billed ``claude -p`` runner used
25+
elsewhere, so there is no API spend. Output mirrors the input directory layout,
26+
so both grouped (``groups/<g>/docs``) and flat (``docs``) corpora are supported.
27+
"""
28+
29+
from __future__ import annotations
30+
31+
import re
32+
from concurrent.futures import ThreadPoolExecutor
33+
from pathlib import Path
34+
from typing import Callable, Literal
35+
36+
from basic_memory_benchmarks.llm.runners import LLMRunner
37+
38+
Mode = Literal["augment", "replace"]
39+
40+
EXTRACTION_PROMPT = """\
41+
You are converting one conversation into a Basic Memory structured note.
42+
43+
Basic Memory represents knowledge as typed observations and relations, NOT prose:
44+
- An observation is a single fact written as: `- [category] the fact #optional-tag`
45+
Categories are short lowercase labels you choose to fit the fact, e.g.
46+
[fact], [event], [preference], [decision], [requirement], [risk], [recommendation].
47+
- A relation links the note's subject to another entity:
48+
`- relation_type [[Entity Name]]`
49+
Use natural, consistent entity names (e.g. [[Email Communication]],
50+
[[Phishing Incident]]) and verb-like relation types (relates_to, caused_by,
51+
mitigated_by, prefers, concerns, depends_on).
52+
53+
Extract EVERY concrete, durable fact the conversation establishes — what was
54+
said, decided, preferred, experienced, or recommended — as observations,
55+
preserving specific names, dates, numbers, and personal details verbatim. Then
56+
capture how the entities involved connect to each other as relations. Make
57+
implicit connections explicit: if the conversation links two things (an incident
58+
to a vulnerability, a preference to a constraint), write that as a relation or an
59+
observation. Do not editorialize, summarize the chit-chat, or invent facts that
60+
are not supported by the text.
61+
62+
Output ONLY markdown in exactly this form, nothing else (no preamble, no fences):
63+
64+
## Observations
65+
- [category] ...
66+
- [category] ...
67+
68+
## Relations
69+
- relation_type [[Entity]]
70+
- relation_type [[Entity]]
71+
72+
Conversation to convert:
73+
{conversation}
74+
"""
75+
76+
_CONVERSATION_RE = re.compile(r"\n## Conversation\b", re.DOTALL)
77+
78+
79+
def _split_doc(flat_text: str) -> tuple[str, str]:
80+
"""Return (header, conversation_body): header is everything up to (excluding)
81+
the ``## Conversation`` heading, body is the transcript after it."""
82+
match = _CONVERSATION_RE.search(flat_text)
83+
if not match:
84+
raise ValueError("Doc has no '## Conversation' section to restructure")
85+
header = flat_text[: match.start()].rstrip() + "\n"
86+
body = flat_text[match.end() :].strip()
87+
return header, body
88+
89+
90+
def _clean_extraction(text: str) -> str:
91+
"""Strip stray code fences / preamble and ensure it starts at Observations."""
92+
cleaned = text.strip()
93+
if cleaned.startswith("```"):
94+
cleaned = cleaned.strip("`")
95+
cleaned = re.sub(r"^[a-zA-Z]+\n", "", cleaned).strip()
96+
idx = cleaned.find("## Observations")
97+
if idx > 0:
98+
cleaned = cleaned[idx:]
99+
return cleaned.strip()
100+
101+
102+
def structure_doc(flat_text: str, runner: LLMRunner, *, mode: Mode = "augment") -> str:
103+
"""Convert one flat conversation doc into a BM-native note (doc id kept).
104+
105+
``augment`` retains the transcript and appends the structured block;
106+
``replace`` substitutes the structured block for the transcript.
107+
"""
108+
header, conversation = _split_doc(flat_text)
109+
result = runner.complete(EXTRACTION_PROMPT.format(conversation=conversation))
110+
structured = _clean_extraction(result.text)
111+
if "## Observations" not in structured:
112+
# Fail loud so a bad slice can't masquerade as a structured corpus.
113+
raise ValueError(f"Extractor returned no observations for a doc:\n{result.text[:400]}")
114+
if mode == "replace":
115+
return f"{header}\n{structured}\n"
116+
return f"{flat_text.rstrip()}\n\n{structured}\n"
117+
118+
119+
def structure_corpus(
120+
*,
121+
input_root: Path,
122+
output_root: Path,
123+
runner: LLMRunner,
124+
mode: Mode = "augment",
125+
path_filter: Callable[[Path], bool] | None = None,
126+
max_workers: int = 4,
127+
) -> int:
128+
"""Restructure every ``*.md`` under ``input_root`` into ``output_root``,
129+
mirroring the relative directory layout. ``path_filter`` (given the source
130+
path) selects which docs to convert. Returns the doc count."""
131+
docs = [
132+
path
133+
for path in sorted(input_root.rglob("*.md"))
134+
if path_filter is None or path_filter(path)
135+
]
136+
137+
def _work(doc_path: Path) -> None:
138+
structured = structure_doc(doc_path.read_text(encoding="utf-8"), runner, mode=mode)
139+
dest = output_root / doc_path.relative_to(input_root)
140+
dest.parent.mkdir(parents=True, exist_ok=True)
141+
dest.write_text(structured, encoding="utf-8")
142+
143+
with ThreadPoolExecutor(max_workers=max_workers) as pool:
144+
list(pool.map(_work, docs))
145+
146+
return len(docs)
147+
148+
149+
def group_prefix_filter(categories: set[str]) -> Callable[[Path], bool]:
150+
"""Path filter matching grouped corpora: keep docs whose group directory name
151+
starts with one of ``categories`` (e.g. ``implicit_connection-cs10-…``)."""
152+
153+
def _filter(path: Path) -> bool:
154+
return any(part.startswith(f"{cat}-") for part in path.parts for cat in categories)
155+
156+
return _filter

0 commit comments

Comments
 (0)