A support reply can deserialize into the right PHP class and still quote the wrong refund policy. The body is a string, the disposition is a valid enum, and the source list looks reasonable. The customer still receives a promise your billing team cannot honor.
This happens when a knowledge base contains several versions of a policy or exceptions for different products. Searching for similar text helps find candidates. Your application must decide which documents are eligible before it asks a model to draft an answer.
InstructorPHP's Retrieval package gives you the indexing, search, and evidence assembly steps. Polyglot supplies embeddings. Instructor turns the generated draft into a typed PHP object. Together, they let you keep the evidence and the acceptance checks visible in ordinary application code.
One customer, several plausible policies
Consider a customer using the Standard annual plan of a fictional SaaS product:
My annual subscription renewed five days ago. I no longer need it. Can I get a refund, and how do I stop the next renewal?
The account record supplies the plan and renewal date. The knowledge base supplies the policy. Those are separate inputs: a customer's message does not choose their plan or decide which policy version applies.
Our small corpus contains 6 documents:
| Document | Why it matters |
|---|---|
| Current Standard refund policy | Requests within 7 calendar days go to billing review; approval depends on usage and billing checks. |
| Cancellation instructions | Cancel the next renewal in billing settings; access continues through the paid period. |
| Archived refund policy | An older 30-day automatic-refund rule that must stay out of current replies. |
| Enterprise exception | A negotiated 90-day window for a different plan. |
| Payment troubleshooting | Current, but does not answer this request. |
| Account security | Current, but does not answer this request. |
These are fictional policies. The interesting problem is selecting and using the applicable evidence, rather than deciding a real customer's entitlement.
The archived document contains nearly every word in the refund question. In our live run, it ranked first when we searched without a policy filter. Asking the model to reconcile all these documents would move a business decision into the prompt, where the application cannot enforce it reliably.
Select the knowledge set in PHP
The runnable support reply example
uses an in-memory store and short policy documents. It can run without API keys,
then use real embeddings and generation through an explicit --live mode.
The following excerpts use the corpus and response classes from that example.
KnowledgeBase
is a fixture class written for this example. Its documents() method yields
6 TextDocument records containing the policy text and metadata listed above.
Here is an abbreviated fragment with one record:
use Cognesy\Retrieval\Indexing\Data\TextDocument;
final readonly class KnowledgeBase
{
public const string APPROVED = 'workspace-standard:approved:2026-10';
/** @return Generator<int, TextDocument> */
public function documents(): Generator
{
// Other fixture documents omitted.
yield new TextDocument(
id: 'standard-refund',
content: 'STANDARD REFUND REVIEW: For Workspace Standard annual renewals, customers may submit a refund request within 7 calendar days of renewal. '
. 'Approval requires a billing specialist to review usage and billing records. '
. 'Support must not promise approval or claim that a refund has been issued.',
metadata: [
'title' => 'Standard annual refund review',
'knowledge_set' => self::APPROVED,
],
sourceVersion: '2026-10',
);
}
}
TextDocument is the Retrieval package's indexing input: an application-owned
ID, text to embed, filterable metadata, and a source version. The archived and
Enterprise records have different knowledge_set values. PHP uses APPROVED
to select the current Standard records.
The full fixture also defines QUESTION for the customer message and ACCOUNT
for trusted facts such as plan and renewal date. In an application, those inputs
come from the ticket and account record; policy documents can come from a CMS or
database. This helper keeps the runnable example's inputs together. The indexer
receives its documents below:
use Cognesy\Events\Dispatchers\EventDispatcher;
use Cognesy\Polyglot\Embeddings\Embeddings;
use Cognesy\Retrieval\Drivers\InMemory\InMemoryStore;
use Cognesy\Retrieval\Indexing\DocumentIndexer;
use Cognesy\Retrieval\Indexing\DocumentProcessor;
use Cognesy\Retrieval\Vectorization\Vectorizer;
use Examples\Retrieval\SupportReplyWithSources\KnowledgeBase;
$space = 'openai:text-embedding-3-small:v1';
$store = new InMemoryStore();
$vectorizer = new Vectorizer(
Embeddings::using('openai'),
embeddingSpace: $space,
model: 'text-embedding-3-small',
);
$indexer = new DocumentIndexer(new DocumentProcessor(
store: $store,
vectorizer: $vectorizer,
events: new EventDispatcher('support.index'),
));
$indexer->index((new KnowledgeBase())->documents());
The same vectorizer prepares the query. PHP selects the approved knowledge set from trusted account configuration and applies it to retrieval:
use Cognesy\Retrieval\Filter\MetadataEquals;
use Cognesy\Retrieval\Retrieval;
use Cognesy\Retrieval\SemanticRetriever;
use Cognesy\Retrieval\Vectorization\SemanticQueryVectorizer;
$retrieval = Retrieval::fromStore(
$store,
queryPreparer: new SemanticQueryVectorizer($vectorizer),
);
$retriever = new SemanticRetriever(
$retrieval,
embeddingSpace: $space,
filter: new MetadataEquals('knowledge_set', KnowledgeBase::APPROVED),
);
$hits = $retriever->retrieve(KnowledgeBase::QUESTION, 3)->get();
This is a single metadata equality check over an application-maintained scope. Retrieval does not discover the current policy, authorize the customer, or calculate eligibility. Those decisions remain in PHP.
In the verified live run, the eligible results were the cancellation guide, the current refund policy, and payment troubleshooting. Neither ineligible refund policy reached the generated reply's evidence.
Give the model a bounded evidence packet
Passing every retrieved document into a prompt makes the prompt harder to inspect and its size harder to control. Assemble a small evidence packet first:
use Cognesy\Retrieval\Context\ContextAssembler;
use Cognesy\Retrieval\Context\ContextBudget;
use Cognesy\Utils\Tokenization\Drivers\Gpt3TokenizerDriver;
$context = (new ContextAssembler(
tokenizer: new Gpt3TokenizerDriver(),
metadataKeys: ['title', '_source_version', 'knowledge_set'],
))->assemble($hits, new ContextBudget(
maxBytes: 2_500,
maxTokens: 650,
maxEvidence: 2,
maxExcerptBytes: 1_200,
));
The example explicitly uses the bundled tokenizer so the proof works offline.
Its counts are reproducible local counts, rather than exact gpt-4o-mini
counts. In production, choose a tokenizer that matches the answering model.
The evidence budget also leaves you responsible for budgeting instructions,
account facts, the customer message, response schema, and output.
The assembled text prefixes excerpts with labels such as [S1]. Its citation
map retains each included excerpt, document ID, score, and selected metadata.
That means a reviewer can inspect the actual text the model received, including
any truncation. Labels belong to this assembled context; S1 may identify a
different document on another request.
Our normal packet contained 2 sources, 558 bytes, and 121 local tokens. The third hit was omitted. Repeated long documents exercised the tighter limits: 220 bytes, 60 local tokens, and excerpts of at most 120 bytes. Separate checks exercised the byte and token limits independently.
Generate a draft, then check its references
ReplyDraft is the example's PHP response model. It has a body, a
disposition, and a citations list:
use Cognesy\Schema\Attributes\Description;
use Symfony\Component\Validator\Constraints as Assert;
enum ReplyDisposition: string
{
case ReadyForReview = 'ready_for_review';
case InsufficientEvidence = 'insufficient_evidence';
}
final readonly class ReplyDraft
{
/** @param list<string> $citations */
public function __construct(
#[Assert\NotBlank]
#[Description(
'Customer-facing reply addressed directly to the customer. '
. 'Include inline labels [S1], [S2] beside policy statements. '
. 'Do not summarize the customer request in third person.'
)]
public string $body,
public ReplyDisposition $disposition,
#[Description(
'Source labels used inline in the body, without brackets, '
. 'for example ["S1", "S2"].'
)]
public array $citations,
) {}
}
NotBlank validates the body. The enum limits the disposition to the 2 states,
and list<string> supplies the citation item type for the generated schema.
The Description attributes tell the model to address the customer and place
inline labels beside policy statements. PHP checks those labels against the
evidence after generation.
SupportReplyDrafting
is an application service written for this example. Its constructor receives
a configured StructuredOutput instance and a model name, defaulting to
gpt-4o-mini. The service connects the support inputs to Instructor's API.
Its draft() method takes the customer message, trusted account facts, and
assembled context. Empty context produces a handoff directly in PHP. Otherwise
the method combines those inputs into a prompt and asks Instructor for a
ReplyDraft. Here is the core method, with the system instructions omitted:
public function draft(
string $message,
string $accountFacts,
AssembledContext $context,
): ReplyDraft {
if ($context->count() === 0) {
return new ReplyDraft(
'A support specialist must review this request because approved evidence is missing.',
ReplyDisposition::InsufficientEvidence,
[],
);
}
$draft = $this->structuredOutput->with(
// System instructions omitted from this fragment.
messages: "ACCOUNT FACTS:\n{$accountFacts}\n\nCUSTOMER MESSAGE:\n{$message}\n\nEVIDENCE:\n{$context->text}",
responseModel: ReplyDraft::class,
model: $this->model,
)->get();
if (!$draft instanceof ReplyDraft) {
throw new RuntimeException('Expected a typed ReplyDraft.');
}
return $draft;
}
The full method's system instructions treat the message and evidence as untrusted data and forbid claims that a refund or cancellation has already happened. They guide generation; PHP still checks the result.
The separate rejection() method checks the draft's disposition and source
labels against the context. It returns an explanation when a check fails, or
null when the checks pass. The calling code makes both operations explicit:
use Cognesy\Instructor\Enums\OutputMode;
use Cognesy\Instructor\StructuredOutput;
use Cognesy\Instructor\StructuredOutputRuntime;
use Cognesy\Polyglot\Inference\LLMProvider;
use Examples\Retrieval\SupportReplyWithSources\SupportReplyDrafting;
$runtime = StructuredOutputRuntime::fromProvider(LLMProvider::using('openai'))
->withOutputMode(OutputMode::Json)
->withMaxRetries(0);
$drafting = new SupportReplyDrafting(new StructuredOutput($runtime));
$draft = $drafting->draft(
KnowledgeBase::QUESTION,
KnowledgeBase::ACCOUNT,
$context,
);
$rejection = $drafting->rejection($draft, $context);
rejection() requires a reviewable disposition, at least one citation,
and source labels present in the context map. It also requires the inline
labels to match the declared list. A typed DTO containing S99 fails even
though its fields have valid PHP types.
The first live attempt exposed another failure: its DTO listed S1 and S2,
but the body contained no inline labels. PHP rejected it. We added schema
descriptions and used an explicit JSON runtime matching the deterministic
proof. The next live attempt passed the citation checks. Its cancellation
instructions cited the cancellation guide; its conditional refund request
cited the current refund policy. Both attempts are documented with the example.
The captured live output makes the selected sources visible:
Live unfiltered top 3: archived-refund, cancel-renewal, standard-refund
Live approved top 3: cancel-renewal, standard-refund, payment-errors
Live context: bytes=558; tokens=121; sources=2; omitted=1
S1 -> cancel-renewal (2026-10)
S2 -> standard-refund (2026-10)
The accepted draft still goes to a support reviewer. The application owns sending, cancellation, and refund execution.
What the proof establishes
Run the default example from the InstructorPHP repository root:
php examples/B08_Retrieval/SupportReplyWithSources/run.php
Its fixture vectors deliberately rank the ineligible policies first. Fixture model responses force valid and invalid drafts through the real structured output path. These cases prove application mechanics; they do not measure semantic search quality or model accuracy.
| Boundary | Verified result |
|---|---|
| Policy selection | The selected knowledge set excludes archived and wrong-plan content from the prompt. |
| Evidence size | Byte, local token, source-count, and excerpt limits hold. |
| Missing approved corpus | PHP returns insufficient_evidence with zero additional generation calls. |
| Invented citation | The typed S99 draft is rejected. |
| Inconsistent labels | Inline labels that differ from the citation list are rejected. |
| Factual support | An unsupported statement citing a real label passes these citation checks. |
That last row matters. A valid reference establishes which excerpt was cited. It does not establish that the excerpt supports the statement. Likewise, nearest-neighbor retrieval can return irrelevant documents; an unrelated question does not necessarily produce an empty result. The empty-evidence case here uses a knowledge set absent from the corpus.
The separate live run checks the same corpus with OpenAI embeddings and generation. A recorded replay lets you inspect the captured provider behavior without making new calls. A fresh run can produce different ranking or text, and the example fails if its source or acceptance checks stop holding.
Keep the operating decision visible
This gives support engineers a draft whose policy sources can be inspected before sending. When a reply fails a check, they can see whether the problem was the selected knowledge set, the admitted excerpts, or the generated labels. The billing team retains control of approval and execution.
Use structured output validation to enforce the response shape and domain constraints. Use retrieval to supply eligible evidence. Keep the final acceptance rule explicit:
Draft from approved evidence, expose the included sources, and route failed checks or uncertain factual support to review.