Skip to content

Q&A Pair

A q_a_pair is a curated, corpus-level record of a question-and-answer pair: self-contained, versioned, governed, and ready for direct retrieval by AI via the MCP q_a_search tool. It is NOT a workspace-partitioned artefact — the Q&A corpus is shared across all six baseline application types (procurement, intelligence, sales_proposal, product_guide, competitor_research, training_onboarding). Workspace relevance is computed at query time via scope_tag overlap, not via a static FK. Deduplication of near-duplicate q_a_pairs (ID-120) is INTRA-tenant — across the client’s workspaces and forms within one database — never cross-tenant.

The two-tier model owns the full Q&A lifecycle:

  • q_a_pairs — the curated golden record. The corpus. Promoted from an extraction, authored directly by a SME, or promoted from a prior bid response (UC5). Published pairs are the retrieval substrate for the q_a_search MCP tool.
  • q_a_extractions — the derived cache. LLM or parser output from running an extractor over a source_documents row (chunked via content_chunks) or markdown sidecar. Once promoted, promoted_to_pair_id captures the lineage back to the corpus record. Invalidated rows are retained for audit — NOT deleted. As of id-370 (S511), the pipeline gates the declare on answer_text: a blank form’s questions still extract to a NULL answer_text (a sanctioned result), but no q_a_extractions row is minted for an unanswered question — q_a_extractions feeds the answered-pairs-only promotion funnel. The q_a_extractions_promotion_candidates() RPC mirrors the gate (branch 1 carries the same answered predicate branch 3 already carried). See specs/id-370-unanswered-question-routing/.
  • q_a_pair_history — the version table. Trigger-written on every q_a_pairs UPDATE. Provides the version-on-cite substrate per 0.9-edit-flow-investigation.md §6.0.3 — shipped bid responses resolve to the version snapshot at ship time.

This is the Wikipedia Principle applied to Q&A: one record about “ISO 27001 certification status” is one q_a_pair row cited by procurement, sales-proposal, and competitor-research workspaces alike — not three duplicates.

ColumnTypeNullableNotes
iduuidNOPK, gen_random_uuid()
question_texttextNOPrimary retrieval substrate (embedded + FTS)
alternate_question_phrasingstext[]NODefault '{}'; covers Shape D context variants; included in FTS index
answer_standardtextNOMain answer body (NOT NULL post-T6 WP1)
answer_advancedtextYESExtended answer for tiered clients (Phew-style audit-6col shape)
scope_tagtext[]NODefault '{}'; workspace-relevance substrate (CV 21)
anti_scope_tagtext[]NODefault '{}'; explicit exclusion from matching workspaces
source_workspace_iduuidYESFK → workspaces(id) ON DELETE SET NULL; provenance audit only (NOT a scoping signal per Q-OQR1-07)
origin_kindtextNOCV 22; CHECK ('extracted_from_corpus','curated_explicit','derived_from_bid_response','imported_legacy'); default 'curated_explicit'
question_embeddingvector(1024)YEStext-embedding-3-large; NULL until embedding job runs
publication_statustextNODefault 'draft'; CHECK ('draft','in_review','published','archived')
superseded_byuuidYESSelf-referencing FK → q_a_pairs(id) ON DELETE SET NULL; UC8 merge lineage
valid_fromtimestamptzYESTemporal validity start
valid_totimestamptzYESNULL = currently valid; version-on-cite at ship time per §6.0.3
created_attimestamptzNOnow()
updated_attimestamptzNOnow()
IndexColumnPurpose
idx_q_a_pairs_scope_tagscope_tag GIN&& overlap operator for workspace-relevance filter
idx_q_a_pairs_anti_scope_taganti_scope_tag GINExclusion filter NOT (anti_scope_tag && workspace.scope_tag)

See §6 for the anti-pattern rule on idx_q_a_pairs_workspace.

2.3 source_workspace_id is nullable provenance audit

Section titled “2.3 source_workspace_id is nullable provenance audit”

Per Q-OQR1-07: source_workspace_id is NULLABLE and records the originating workspace for origin_kind='derived_from_bid_response' pairs only. It is NOT a workspace-scoping signal. For origin_kind='extracted_from_corpus' or 'curated_explicit', source_workspace_id is NULL — the pair’s provenance is carried in origin_kind + source_content_item_id on the linked q_a_extractions row.

publication_status drives the governance lifecycle. State transitions:

FromToTrigger
draftin_reviewSME or automated quality gate submits for review
in_reviewpublishedReviewer approves
in_reviewdraftReviewer rejects / returns for rework
publishedin_reviewAny edit to a published pair (UC6 §8.3 — revision moves to in_review automatically)
published / in_review / draftarchivedAdministrative retire

Supersession: a pair may be superseded by a newer pair (superseded_by UUID FK). The valid_to column records when the pair was superseded. Superseded pairs are accessible for version-on-cite lineage — q_a_get_verbatim(uuid) has no publication_status filter so superseded/archived pairs are retrievable for lineage resolution.

Note: publication_status='superseded' was the T2 value; it was replaced in T6 WP1 by 'in_review' because superseded_by UUID carries the lineage — the status enum was redundant for this purpose (see 05-qa-flow.md §8.3).

origin_kind (CV 22) records how a Q&A pair entered the corpus:

ValueMeaning
extracted_from_corpusExtraction from a source_documents row or markdown sidecar via cocoindex
curated_explicitDirectly authored by a SME (no extraction source); default for manually created pairs
derived_from_bid_responsePromoted from a bid response via UC5; source_workspace_id records the originating procurement workspace
imported_legacyOne-shot migration from legacy data (e.g. Phew’s 395 content_items.content_type='q_a_pair' rows via T7, pre-ID-131)

source_workspace_id is populated only for derived_from_bid_response pairs. For all other origin kinds, source_workspace_id is NULL — the pair’s corpus-level identity is expressed by origin_kind + the q_a_extractions.source_content_item_id lineage.

Two-step retrieval pattern per S16 §6.1 and 05-qa-flow.md §7.2-§7.3:

  1. q_a_search(p_query text, p_query_embedding vector(1024), p_limit integer DEFAULT 20) — Step 1. Returns a ranked preview list. Filters publication_status='published' AND question_embedding IS NOT NULL. Returns embedding_score NUMERIC(5,4) + fulltext_score NUMERIC(5,4) as separate columns per N9 RESOLVED-S236 — NOT a blended single score. Scope filtering is caller-side (scope_tag && caller_scope_tags). Internal ORDER BY uses weighted blend embedding * 0.6 + fulltext * 0.4 (not exposed as a return column).

  2. q_a_get_verbatim(p_pair_id uuid) — Step 2. Returns the full q_a_pair row for a single pair. Excludes question_embedding (payload-size discipline per S16 §6.1 “AI-consumer-first”). No publication_status filter — caller may fetch superseded/archived pairs for lineage resolution.

The separate-score design surfaces per-method tunability signal to the UI without schema change. Per-method weight configuration and blend policy are feature-spec scope per N9 RESOLVED-S236 (“operational verification deferred to feature spec time”).

The following are explicitly REJECTED per Phase 0.9 ratifications and the T6 migration:

Anti-patternStatusRationale
q_a_pairs.workspace_id NOT NULL FKRATIFIED-RETIRE (Q-OQR1-06)Superseded by corpus-level + scope_tag-driven relevance; 0 of 395 prod rows were workspace-assigned empirically
q_a_pair_workspaces M:N junctionRATIFIED-DO-NOT-BUILD (Q-OQR1-06)scope_tag overlap subsumes; heavier than needed
idx_q_a_pairs_workspace (workspace-partition index)RATIFIED-DO-NOT-BUILDWorkspace relevance computed at read time via GIN; a workspace-partition index encodes the superseded schema framing. Future maintainers MUST NOT add this index.
q_a_extractions as corpus recordsRATIFIED-DO-NOT-BUILDExtractions are derived cache, NOT corpus records. Only promoted q_a_pairs rows are corpus records. The two-tier model is load-bearing — conflating them destroys the promotion audit trail and the version-on-cite substrate.
  • q_a_extractions.promoted_to_pair_id FK → q_a_pairs(id) — lineage from extraction to promoted corpus record.
  • q_a_extractions.source_document_id (renamed from source_content_item_id at ID-131 M2, 20260628200000_id131_extract_reparent.sql) FK → source_documents(id) — provenance back to the extracted source document.
  • q_a_pair_history.q_a_pair_id FK → q_a_pairs(id) ON DELETE CASCADE — version snapshots triggered on every UPDATE.
  • q_a_pairs.superseded_by FK → q_a_pairs(id) — self-referencing UC8 merge lineage.
  • q_a_pairs.source_workspace_id FK → workspaces(id) — provenance audit for derived_from_bid_response pairs only.
  • Adjacent to CV 22 origin_kindq_a_pairs.origin_kind uses this vocabulary.
  • Adjacent to CV 23 extractor_kindq_a_extractions.extractor_kind uses this vocabulary.
  • Adjacent to CV 21 scope_tagq_a_pairs.scope_tag + anti_scope_tag use scope_tag vocabulary.
  • Layer 5 (Knowledge Graph): q_a_pair is the canonical Layer-5 “citable fact” entity per phase-b-prerequisite-1-onthology-pipeline.md §3.5.
  • MCP q_a_search tool (planned lib/mcp/tools/qa.ts) — primary consumer; uses the two-step retrieval pattern (q_a_search → q_a_get_verbatim).
  • UC5 promotion flow — bid response → Q&A pair promotion writes q_a_pairs rows with origin_kind='derived_from_bid_response'.
  • UC6 revision flow — user or AI-suggest edits to published pairs via q_a_pairs UPDATE (triggers history row).
  • question_matches table — links application-specific form questions to corpus-level q_a_pairs via question_kind discriminator.
  • Cocoindex extraction pipeline — UPSERTs into q_a_extractions; human review promotes to q_a_pairs.
  • Status: APPLIED-S249 — full schema (T6 WP1 + WP2) applied to staging branch turayklvaunphgbgscat. Production apply pending Liam ratification.
  • T2 combined-PR migration (S246/S247) created the minimal q_a_pairs table sketch. T6 WP1 extended it to the full spec-compliant shape.
  • T7 (Phew Q&A first-ingest) will use origin_kind='imported_legacy' + cocoindex extraction → promotion for the legacy 395 content_items.content_type='q_a_pair' rows (pre-ID-131; content_items itself was DROPPED at ID-131 M6). Those rows soft-archive post-migration; q_a_pairs becomes the sole retrieval surface.
  • question_embedding is NULL until the embedding job runs (post-T6 WP1 apply). The q_a_search RPC filters WHERE question_embedding IS NOT NULL so unembedded pairs are silently excluded from semantic retrieval until the embedding run completes.
  • content_items.content_text_hash was a GENERATED ALWAYS column (CLAUDE.md gotcha, pre-ID-131) — unrelated to q_a_pairs; the column (and content_items itself) is GONE, DROPPED at ID-131 M6. Kept here only as historical trivia.