Corbel

Method

The valuable behaviour is the refusal. Everything else is search.

A retrieval layer over a construction record is used by someone who is about to act on the answer: certify a payment, direct a subcontractor, or write a letter. The expensive failure is not a missed answer. It is a confident answer to a question the record never addressed, because that answer will be quoted back later by someone who assumed it came from the documents.

Only stored text is returned

A result is the stored body of one indexed passage, with the matched terms highlighted, alongside the document number, the page, the section, the issue date and the author. Nothing on a result page is written by the system. There is no summarisation step, so there is no step at which two passages can be merged into a sentence that neither of them supports.

Why the obvious approaches fail

Postgres offers websearch_to_tsquery, which ANDs every term. It refuses reliably, and it also refuses plainly answerable questions: ask “what warranty is required for the roof” and the paragraph that states the warranty is skipped, because it never uses the word “required”. Switching to OR fixes the recall and destroys the product, because then every question returns something and a refusal becomes impossible.

What is used instead is explicit term coverage. The question is reduced to lexemes, each passage is scored by how many of those lexemes it actually contains, and a passage is returned only if it covers at least sixty percent of them. Ranking within the survivors is cover density. The threshold is a count of words present in the text, not a similarity number, so a refusal can be explained precisely: the page lists each term of the question and how many passages in the record contain it. Ask about asbestos abatement and it will tell you the record contains the word zero times.

-- The gate. A passage is returned only if it contains at least
-- min_coverage of the question's lexemes. The score is a count of
-- words that are present, which is a fact about the text.
scored as (
  select p.id, ...,
         ts_rank_cd(p.tsv, parsed.tsq, 32) as rank,
         cardinality(array(
           select unnest(parsed.arr)
           intersect
           select unnest(tsvector_to_array(p.tsv))
         )) as matched,
         parsed.total
  from passages p
  join documents d on d.id = p.document_id
  cross join parsed
  where p.tsv @@ parsed.tsq
)
select ...
from scored
where matched::real / total::real >= min_coverage
  and rank >= min_rank
order by (matched::real / total::real) desc, rank desc;

Section titles are part of the passage

The paragraph that sets the curtain wall deflection limit does not contain the words “curtain wall”; they are in the section title three inches up the page. A retrieval layer that indexes the paragraph alone will not find it. Each passage therefore carries the document type, number, title and discipline denormalised onto it, kept in step by a trigger and indexed at weight A alongside the heading.

Refusals are measured, not assumed

Every question asked of this demonstration is logged with its hit count and whether it was answered, so the coverage threshold can be tuned against real questions rather than guesses. To date 58 questions have been asked here, 36 answered from the record and 22 refused.

Built on

Next.js App Router with server components querying Postgres directly, Supabase for the database, Tailwind, deployed on Vercel. Retrieval is two SQL functions and a GIN index on a generated tsvector; there is no vector store, no embedding model and no inference in the request path.