Symphonix Documentation · Core 0.61.0

The collection format

What a knowledge collection is on disk — the contract between whatever builds an index and whatever answers from it.

This is the engineering reference, rendered from the repository

It is the same file the platform's own tests are written against — LGFlow/docs/reference/collection-format.md — rendered here rather than copied, so it cannot fall behind the software it describes. It is written for somebody working on the platform and uses the internal project names: LGFlow is Symphonix Core, LGStudio is Symphonix Studio, and LGMonitor or LGStudioMonitor is the Symphonix Control Panel. Names that begin LGFlow and appear in code — LGFlowValidationError and the rest — are exported identifiers and are spelled exactly as the software spells them.

*The contract between the thing that builds an index and the thing that answers from it — DSL 0.31, rewritten 2026-10-22.*

Two programs have to agree about this and they are written at different times by different people: an indexing agent produces it, a search server reads it. Two implementations that disagree quietly is the failure this whole design has been avoiding since 0.30, which is why the format is specified here rather than left to whoever writes the first one.

This page described a directory until 2026-10-22 — collection.json, passages.jsonl and a bare vectors.bin — and the owner chose SQLite instead. That was the better choice and the change is recorded rather than tidied away, because three of the four refusals this page used to demand existed only to survive a raw float file. See What SQLite removed.


The shape#

One SQLite file, opened with node:sqlite, which is built into Node and already used by the board — so this adds no dependency.

collections.db
  store_meta         what schema this is
  agents_chunks      one row per chunk, for every agent here
  collection_built   what built each collection, and when

One table for every agent, with agent_id on every row, so one installation of the indexer serves many agents and a row always says whose it is.

agents_chunks#

agent_idwho built it. Provenance, and what makes a report to the board believable
collectionwhat it is. The identity the rest of the platform uses: a flow declares a collection by name in requires.knowledge, and the board keys its record by that name
ordinalits place in the collection, so two builds of identical documents do not look like a change
textwhat the model will be shown. not null
sourcewhat an answer cites. "unknown" is allowed and a lie is not — a collection that does not record sources is usable; one that invents them is not. It is a document's name, never a path
labelsJSON, and only for a collection whose paragraphs depend on the reader. See below
vectorits embedding: little-endian 32-bit floats as a blob
dimensions, model, methodwhat produced it, on the row, so a mismatch is caught per row rather than assumed

agent_id and collection are both here because they answer different questions. The owner's spec asked for agent_id; the collection name is what a flow and the board already speak.

collection_built#

One row per collection: model, dimensions, method, built_from, passages, built_at, agent_id, refused.

a listthose documents are in the folder and are not in the index
[]the build read everything it was given. Worth saying out loud: none were refused is the reassuring half of this number
nullthe build did not say. Every build made before this field existed, and every call that was handed its documents instead of reading a folder

Collapsing null into [] turns nobody recorded this into *nothing was refused*, which is the one reading this record must never produce. The names only, never the reason: two of the three reasons a document is skipped are safe, and the third is the underlying read error - which for a missing file is an ENOENT carrying the whole path. This record is read by a board, and the rule that keeps a folder out of the DSL keeps it out of here.

It is not part of STORE_SCHEMA. The column is added when a store is opened and is nullable, so an older store gets it with every row null and an older build reading a newer store ignores a column it does not know. A schema bump would have refused every store in existence until somebody rebuilt - a hard break bought for one field that is readable as not said by construction.


An empty answer, and which kind of empty it is#

A search has five nearest paragraphs to hand back whether or not any of them answers the question, and an agent handed five paragraphs writes an answer out of them. So search carries refused, and it has three values which a reader must keep apart:

trueparagraphs came back, and a model read them against the question and said none of them answers it. The honest nothing here answers this - and the only case where an empty passages is a finding rather than an absence
falseit was asked and said yes - or nothing came back for an ordinary reason: the index is empty, or nothing cleared the floor
nullnobody asked. This collection has refusing off, or this deployment has no judge configured

null must never be read as false. A collection that cannot refuse and one whose judge approved are different facts, and treating them alike reports a guard that is not there - which is worse than having no guard, because somebody then stops checking.

Why it is asked rather than computed#

Two arithmetic answers were measured on 2026-10-27, on two corpora, in every mode a deployment can be set to, and both failed:

So does this passage answer this question is not a property of the score, in any arithmetic over the scores retrieval already produces. Asking a model took refusal from 1 of 8 and 0 of 8 controls to 7-8 of 8 and 8 of 8 over three rounds on each corpus, costing nothing on prose and one answer in eleven on a legal document in two rounds of three.

It costs one model call per question, at query time - the first call in this platform that exists to prevent an answer rather than to produce one. When it refuses, the answering call never happens.


What SQLite removed, and what it did not#

Three of the four things the directory format made a reader refuse simply cannot happen now:

the old refusalwhy it is gone
a vectors.bin whose length is not passages × dimensions × 4there is no length to check; a vector is a column of the row it belongs to
a truncated vector read as a good onea row is there or it is not
a half-written corpuswrites are transactional, which is what docs/indexing-agent-scope.md § 4.1 needs and a JSONL file could only approximate

A build replaces, in one transaction. That is the decisive reason for SQLite rather than a convenience: a rebuild has a window between *deleted the old* and wrote the new, and in that window the collection answers nothing — so an agent asking it would be told, truthfully and uselessly, that nothing there answers the question. Inside a transaction the window does not exist: a reader sees the old collection until the moment it sees the whole new one, and a build that fails half way leaves it exactly as it was, still answering.

Nothing here answers this needs a floor#

This page used to claim that no paragraphs means nothing here answers this, and with a real embedding model that state was unreachable. Cosine over normalised vectors is almost always positive, so score > 0 kept every paragraph: a question the corpus could not answer came back with unrelated paragraphs at a low score, and the llm node below was handed them as though they bore on it.

Measured on 1,355 paragraphs of this project's own docs with text-embedding-3-small:

top score
four questions the docs answer0.476 – 0.651
four they cannot0.111 – 0.213

So a search keeps only paragraphs at or above MIN_SCORE, 0.25 by default and 0 to turn it off. With the floor the four unanswerable questions return nothing at all, which is the sentence this page always said they should.

What a score does and does not tell#

It tells you whether the corpus is about the subject. It does not tell you whether the right paragraph came back. Measured on 565 paragraphs of this project's own reference docs, eight questions in three kinds:

top score
the docs answer it and similarity found it0.4949 - 0.6999
the docs answer it and similarity did not find it0.5390 - 0.5872
the docs do not mention the subject at all0.2611 - 0.2775

The middle band sits inside the first and above part of it. So a floor separates absent from present, which is the job it was put here to do, and nothing about a score separates found from not found.

Do not build a thin-answer warning on it. One would have fired on nothing useful and stayed silent on both questions it was meant to catch. What is worth reporting is the fork that is knowable - nothing above the floor, or paragraphs that came back and did not answer - and src/knowledge.js reports it to the trace, never into the channel: *a question goes in words and paragraphs come back in words* is a rule of the language, and a score in the channel is a score in the prompt.


A hit may arrive with the paragraphs around it#

A floor cannot be reached by depth, and that is not obvious until it costs an answer. Measured on the acceptance corpus — a 23-page Hebrew articles of association, fixed at 350, the multilingual model — one right runs about 2,000 characters and became six consecutive paragraphs. Asked the question that clause answers:

the piece thatrankscore
grants the right, and says whom it bindsbelow the floor, never returned<0.45
names an exception to it1st of 410.6492

So the best paragraph in the collection, for a question asking whether the right exists, was the sentence saying where it does not — and asking for fifty paragraphs still returned only four of the six, because two of them scored under the floor and no depth reaches what the floor has already removed.

AROUND is how many paragraphs either side of a hit come back with it, 1 by default — measured rather than chosen, and AROUND=0 is the behaviour before it existed. Nothing said and zero are different answers: the first is this installation's, the second is somebody deciding that the matching paragraph alone is what they want. The pieces of a clause are adjacent, and ordinal is already in the table, so this is one range over rows already read — not a second search and not a different index:

deptheither sideof the sixprompt characters
5011,729
500414,189
5258,670
1516 — whole12,808

Two things a window must never do, both of them silent:

  1. Reach a paragraph the reader may not read. Widening is done over the rows mayRead has already allowed, never over the table — widening by ordinal against the store hands a reader the paragraph beside one they are allowed to see, which is exactly the disclosure the reader filter exists to prevent. Where a restricted paragraph sits in the middle of a clause the stitched run breaks around it rather than closing over the gap.
  2. Cross from one document into another. Ordinals run across the whole collection, so the paragraph after a document's last is the next document's first. A stitch over that seam invents a sentence spanning two unrelated documents and attributes it to one of them, and nothing about the result looks wrong.

What is left to refuse, because each still produces a worse answer rather than an error:

  1. A schema it does not know. Refused, never guessed: a store one version ahead looks almost right, and almost right is the worst thing an index can be. The refusal closes the file before throwing, so whoever catches it can still move the store aside.
  2. A question embedded by a model of a different width than the rows. Checked per row, because the model can change underneath.
  3. A build that would replace a collection with nothing. Refused in two places — src/building.js and the store — because it is reachable from both, and because the result is a collection that answers from an empty index rather than erroring.

Who may read a paragraph#

A collection whose paragraphs depend on the reader puts labels on them, and a reader is accepted when it holds at least one of a paragraph's labels.

What this file specifies is the shape, not the policy. How a reader's labels are decided — a directory group, a role on the board, a claim in a token — is the installation's, and the search server is where that is resolved. The platform's half is narrower and is the half it can keep: a run with no reader is not allowed to ask a collection that declares one (src/knowledge.js), so the question never leaves.

A paragraph with no labels is readable by anybody. That is a decision, and it is the dangerous direction: it means a collection marked per-reader leaks any paragraph somebody forgot to label. It is this way because the alternative — no labels means nobody — makes a half-built index look empty rather than wrong, and an index that looks empty gets debugged as a bug in the data while the real answer is that nothing has been labelled yet.

Whoever builds a per-reader collection should treat an unlabelled paragraph as a defect, and a build that produces one should say so.


Who is asking, which is not who the answer is for#

A search call carries onBehalfOf beside reader, and the two had to stop being one field.

readerwho the answer is for. It decides what comes back — the labels above — and travels only where a collection declares reader: "required", because that is the only place it changes the answer. A collection that reads the same for everybody has no business being told who asked
onBehalfOfwho is asking, as the board already names it: the agent's own id. It changes nothing about what comes back. It says whose budget the model calls this search makes belong to

A search server that answers from an index with no model behind it can ignore onBehalfOf entirely. One that uses a model — to rewrite the question, to judge what came back, to rerank — spends on behalf of the asking agent, and this is the field that says which one. The reference implementation charges each call to that agent and reports it over the board's own log frame; a cost with nobody to attribute it to is a cost no budget can enforce, and both the daily budget and the runaway rules are per agent. Fourteen agents may ask one server.

**Absent is nobody said, and nothing is charged.** Not charged to the server, not charged to a bucket of its own, not charged to a placeholder named after the missing field. A search with no onBehalfOf works exactly as it did and costs its model call to nobody — which is the right refusal: a figure filed under nothing is a figure no screen can show and no threshold can read.

And it never reaches a paragraph, a trace or a label. What travels back to a board is which call it was and for which collection — judge for handbook — and never the question or the passage. The journal rule, for the journal's reason: a board is read by other people.


How a document becomes paragraphs#

The strategy is named by the flow (index node → method) and never described to it. src/constants.js → CHUNK_STRATEGIES holds the whole vocabulary including what is not built yet, each mapped to the version that introduced it or to null, so a flow naming a planned strategy is a validation error at design time rather than a failure when the trigger fires.

strategywhat it doesphase
fixedsplit every N characters1 — built, 0.31
recursiveparagraphs, then sentences, then words as needed2
sentencewhole sentences grouped to a size2
semanticsplit where the subject changes2
structureheadings, sections, tables3 — built, 0.32
slidingoverlapping chunks3
parent-childindex the small chunk, return the section around it4
agenticask a model where the boundaries are5

Two of the eight are built, and that is what a board offers and what a measurement compares — *if it is in the list it is in the compare, and if it is not in the list it is not in the compare.* Both come from CHUNK_STRATEGIES_AVAILABLE, which is this table's first column with the null rows dropped, so none of the three can drift from the others. The version in this column is the one in CHUNK_STRATEGIES, and a test fails when they disagree — structure shipped in 0.32 and this table went on calling it unbuilt until 2026-10-25, which is the reason that test exists.

fixed is deliberately crude: it is the only one whose behaviour a person can work out from its name, which is what makes it the right first one — when retrieval is poor, the chunking is not the thing to suspect. It breaks at whitespace within the last tenth of a chunk so a word is never cut in half, and that is the only judgement it makes.

Its weakness is real and is why phase 2 exists. A fact spanning a boundary is split across two chunks — Carry-over is capped at five in one and days in the next — and neither chunk then answers the question on its own.

Chunking is written, not generated from a description. It is the exact shape of the failure docs/decisions.md records under *a model writing a parser from prose*: a chunker that is subtly wrong produces a worse index rather than an error, and nothing downstream can tell a bad answer caused by a chunk that stopped mid-sentence from one caused by the model.


Where the documents come from#

KNOWLEDGE_<COLLECTION>_FOLDER, from the deployment's .env. Never from a flow and never from a call, because a path is a fact about one machine: a flow that named a directory would stop working the first time it was installed anywhere else, which is the rule requires.mcp has kept since 0.23.

It is also what keeps a webhook from choosing what gets indexed. The engine sends documents or sends nothing; there is nowhere in the DSL to put an address.

Extraction is extractDocument in src/read.js — the same PDF and Word reading a read node uses, shared rather than copied. It was split out of readDocument for this, and the reason is worth keeping: readDocument takes an address, a local path is not one, and a bare string falls through to the text is the document — so the first version of the folder reader produced paragraphs whose text was the file's own path, silently, straight past the rule that a deployment's paths never travel. The address rules stay in readDocument: a read node still fetches http and https only and still refuses file://.

A document that cannot be read is skipped and counted, not thrown — the behaviour the DSL cannot express, since onError names a node and so a fan-out of read nodes cannot skip one copy. One scanned PDF in five hundred must not index none.


What a build answers with#

A build is reported, and the record outlives the run — so what it answers with is part of this contract rather than a convenience of one implementation.

**The word for one of these is now paragraph; the key is still passages. Everything a person reads — a label on a screen, a hint, a sentence in these pages — says paragraph. The key on the wire, the column in the board's database and the field a collection server answers with did not move, because they are a protocol and a schema: renaming them is a protocol bump, a migration, and every collection server deployed anywhere needing to be rebuilt in the same moment, and an older agent's passages arriving at a newer board would be read as nobody said** rather than as a count. So the two layers are deliberately out of step, and a table of wire names below is the place where the old word is correct.

passageshow many are in the collection now. Refused when absent: a record with no count is worthless, and src/building.js throws rather than reporting one
modelwhich model embedded them. Refused when absent for the same reason, and it is the one the board compares against the previous build — the only thing standing between a swapped embedder and no sign of it at all
dimensionsthe width of the vectors, or null
documentshow many went in
skippedhow many could not be read at all. Absent, not zero, when nothing was skipped — an index built from nine of forty-six documents is not a small problem, and 0 is the claim that it did not happen
method, sizehow it was split. One decision, because the size belongs to the model, and the pair a report needs to explain why does this index answer badly
tookMshow long it took, measured by the build rather than by a caller. Two clocks on one event is two numbers that can disagree, and a log line and a board's record quoting different ones is worse than neither quoting any

The last four are a report and are coerced rather than refused: a server that does not send them is an older one, not a broken one, and null is *did not say* — the third answer this page keeps everywhere. They reach a board through knowledge-built on the agent's own socket, which LGStudioMonitor/docs/protocol.md defines, and are kept per run so that was it always 129 paragraphs and did somebody change the model under it are answerable at all.


What reads it#

collection/collection-server.mjs is the reference implementation of both halves: the search tool a knowledge node asks, and the build tool an index node calls. One server, because the thing that answers search already holds the embedder — it has to embed the query — and the store.

And a third tool, measure, which no node calls. It answers a question about a collection rather than out of one: given questions whose answers are known, it indexes the same documents several ways and says which way found them and at what rank. It is here because the choices this page specifies — how a document is split, how big a paragraph is, the floor under which nothing answers — are not choosable from first principles. Measured on two real corpora with one model, structure found 6 of 8 known answers where fixed found 3; on prose the two swapped places. No default is right for both, so the format ships a way to find out rather than a recommendation.

It is taken unfloored, unless the caller asks for a floor. A floor is applied before anything comes back, so a measurement that inherits one cannot see either of the two bands a floor is set from: an answer scoring below it is absent and reads as not found, and a control scoring below it reports no score at all. Every row carries its own score instead, and the floor is read off the result — the lowest an answer scored against the highest a control reached, with any number between them separating the two.

Every variant reports what it was asked, and two measurements are comparable only when that matches as well as the corpus and the model. Measured: the same folder, digest, model and chunking gave fixed/350 4 of 11 answers at around 0 and 7 of 11 at around 1, because stitching a hit to its neighbours puts the marker inside the returned text. That is a larger swing than the difference between the two chunking strategies, so a reader comparing two tables has to be told both numbers.

It writes nothing into the collection. Each design goes into a scratch store that is thrown away, so comparing four ways of indexing does not leave the collection built the fourth way — which is what the obvious implementation does, and it would destroy what every agent is answering from. A server implementing this format may leave measure out; a board that asks for it and gets no answer says the server may be too old to have one, and nothing else changes.

collection/collection-store.mjs is the schema and the queries; collection/chunk.mjs is the strategies; collection/collection-folder.mjs reads the folder. test/collection-server.test.js holds every refusal on this page, and holds the unlabelled-paragraph decision too.

The embedder is EMBED_URL: point it at a model on this machine and nothing leaves the network, point it at a hosted one and the documents do. One line, which is where § 4.3 put that choice — and the same endpoint embeds the documents and the questions, so they cannot disagree.


What this does not specify#