update the codebase poc ver1

This commit is contained in:
DatTT127
2026-07-07 15:54:17 +07:00
parent fed5f277f4
commit 1622dc8fc5
452 changed files with 83999 additions and 66328 deletions

View File

@@ -0,0 +1,263 @@
# Corpus Profiles — Book-Specific Ingestion Rules
## Purpose
Defines per-textbook metadata parsing, logical-unit assembly keys, and boundary sources for the semantic chunking pipeline.
## Profile Summary
| Book | `book_id` | Constraint tier | Physical unit | Logical unit | Assembly |
|------|-----------|-----------------|---------------|--------------|----------|
| ADO | `ado` | Loose | 5 pages, unconstrained | Section (via TOC/headers) | Stitch all batches → detect boundaries |
| TNY | `tny` | Loose | 5 pages, unconstrained | Section (via TOC/headers) | Stitch all batches → detect boundaries |
| OHO | `oho` | Section | 5 pages within section | Section | Group by `section_id`, stitch batches |
| MOR | `mor` | Subsection | Whole subsection (var. pages) | Subsection | One row = one unit (no stitch) |
---
## ADO
### Physical split
- Pattern: same as TNY — fixed 5-page batches across entire bounded PDF
- Output: `corpus/pdf/ado/batches/`
- Filename: `batch-{NN}_chunk-{PPP}-{PPP}.pdf`
- Example: `batch-02_chunk-006-010.pdf`
### Filename regex
```regex
^batch-(?P<batch_index>\d+)_chunk-(?P<page_start>\d+)-(?P<page_end>\d+)\.pdf$
```
### Logical unit assembly
1. Load all checkpoint rows; sort by `page_start`
2. Concatenate into `book_stream`
3. Subdivide using TOC map (when added) or Docling `#` / `##` headers
4. `logical_unit_id` = `{section_number}` or `{header_slug}`
### Boundary sources
- TOC map: TBD (`corpus/pdf/ado/index.txt` or equivalent)
- Fallback: markdown headers in Docling output
---
## TNY
### Physical split
- Script: `knowledge/tests/tny_split_page.py`
- Master PDF: `corpus/pdf/tny/tny_bounded.pdf`
- Output: `corpus/pdf/tny/batches/`
- Batch size: 5 pages
- Filename: `batch-{NN}_chunk-{PPP}-{PPP}.pdf`
- Example: `batch-02_chunk-006-010.pdf`
### Filename regex
```regex
^batch-(?P<batch_index>\d+)_chunk-(?P<page_start>\d+)-(?P<page_end>\d+)\.pdf$
```
### Parsed metadata example
```json
{
"book_id": "tny",
"batch_index": 2,
"page_start": 6,
"page_end": 10,
"section_id": null,
"subsection_id": null
}
```
### Logical unit assembly
Same as ADO (loose tier):
1. Stitch all batches in page order
2. Detect section boundaries via TOC or headers
3. `logical_unit_id` = `sec_{N}` or `h2_{slug}`
### Boundary sources
- TOC: TBD
- NER glossary (separate concern): `corpus/ner_glossary/TNY_*.txt`
### Checkpoint DB
`tny_ingestion_corpus.db` (see `knowledge/tests/ingestion_corpus_db.py`)
---
## OHO
### Physical split
- Script: `knowledge/tests/oho_split_page.py`
- Master PDF: `corpus/pdf/oho/oho_bounded.pdf`
- Section index: `corpus/pdf/oho/index.txt`
- Output: `corpus/pdf/oho/sections/`
- Batch size: 5 pages **within each section** (does not cross section boundaries)
- Filename: `sec_{N}_batch-{NN}_chunk-{PPP}-{PPP}.pdf`
- Example: `sec_3_batch-01_chunk-104-108.pdf`
### Section index format (`index.txt`)
```text
section 1: 32-50
section 2: 51-103
section 3: 104-243
...
```
### Filename regex
```regex
^sec_(?P<section_id>\d+)_batch-(?P<batch_index>\d+)_chunk-(?P<page_start>\d+)-(?P<page_end>\d+)\.pdf$
```
### Parsed metadata example
```json
{
"book_id": "oho",
"section_id": 3,
"batch_index": 1,
"page_start": 104,
"page_end": 108,
"subsection_id": null
}
```
### Logical unit assembly
1. Group checkpoint rows by `section_id`
2. Sort by `batch_index` (then `page_start`)
3. Concatenate within group
4. `logical_unit_id` = `sec_{section_id}`
**Never** merge rows across different `section_id` values.
### Boundary sources
- Hard: `corpus/pdf/oho/index.txt` (section page ranges)
- Soft: `##` / `###` headers within assembled section text
- Index markdown (separate artifact): `corpus/indexs/oho_index_clean.md`
### Checkpoint DB
`oho_ingestion_corpus.db`
---
## MOR
### Physical split
- Script: `knowledge/tests/mor_split_page.py`
- Master PDF: `corpus/pdf/mor/mor_bounded.pdf`
- TOC: `corpus/pdf/mor/toc_structure.json`
- Output: `corpus/pdf/mor/sections/`
- Unit: one PDF per **subsection** (page count varies)
- Filename: `sub_{subsection_id}_chunk-{PPP}-{PPP}.pdf`
- Example: `sub_1.1_chunk-028-032.pdf`
### Filename regex
```regex
^sub_(?P<subsection_id>\d+\.\d+)_chunk-(?P<page_start>\d+)-(?P<page_end>\d+)\.pdf$
```
### Parsed metadata example
```json
{
"book_id": "mor",
"subsection_id": "1.1",
"section_id": 1,
"page_start": 28,
"page_end": 32,
"batch_index": null
}
```
Derive `section_id` as integer part of `subsection_id` (`1.1` → section `1`).
### Logical unit assembly
**No cross-row assembly.** Each `processed_chunks` row maps 1:1 to a logical unit.
- `logical_unit_id` = `sub_{subsection_id}`
- `parent_title` from `toc_structure.json` → matching subsection `title`
### TOC lookup
```json
{
"sections": [
{
"section_number": 1,
"section_title": "SECTION I: GENERAL",
"subsections": [
{
"title": "1.1 History Taking for Patients with Rheumatic Complaints",
"page_range": "28-32"
}
]
}
]
}
```
Match `subsection_id` prefix to subsection `title` (e.g. `1.1` → title starting with `1.1`).
### Checkpoint DB
`mor_ingestion_corpus.db`
---
## Metadata parser interface
Future module: `knowledge/implementation/ingestion/metadata_parser.py`
```python
@dataclass
class SourceMetadata:
book_id: str
page_start: int
page_end: int
section_id: int | None
subsection_id: str | None
batch_index: int | None
source_filename: str
def parse_source_path(source_path: str) -> SourceMetadata: ...
def logical_unit_key(meta: SourceMetadata) -> str:
"""Stable assembly key for grouping checkpoint rows."""
if meta.book_id == "mor":
return f"sub_{meta.subsection_id}"
if meta.book_id == "oho":
return f"sec_{meta.section_id}"
# ado, tny — assigned after book_stream section detection
raise NotImplementedError("loose tier uses post-assembly logical_unit_id")
```
## Rollout validation matrix
| Book | Validate assembly | Validate chunk count | Validate citations |
|------|-------------------|----------------------|--------------------|
| MOR | 1 row → 1 unit | ~1 chunk per short subsection | page_range matches TOC |
| OHO | N batches → 1 unit per section | multiple chunks per long section | section_id matches index.txt |
| TNY/ADO | all rows → section units | headers align with visual TOC | page ranges contiguous |
## References
- `ingestion_pipeline_spec.md`
- `semantic_chunking_spec.md`
- `schema.md`

View File

@@ -0,0 +1,155 @@
# Knowledge Ingestion Pipeline Specification
## Purpose
Defines the end-to-end pipeline that turns clinical textbook PDFs into retrieval-ready semantic chunks with traceable citations. This spec covers Stages 04; Stage 3 (semantic chunking) is detailed in `semantic_chunking_spec.md`.
## Owner
Knowledge Engineering Team
## Scope
| In scope | Out of scope |
|----------|--------------|
| PDF physical splitting for Docling | Qdrant collection tuning |
| Docling markdown extraction checkpoint (SQLite) | LLM grounding / RAG answer generation |
| Book-aware logical-unit assembly | ladybugDB ontology ingestion |
| Header + semantic chunking | Production embedding server deployment |
| Chunk metadata schema | Frontend citation UI |
## Pipeline Stages
```mermaid
flowchart LR
S0[Stage 0: PDF split] --> S1[Stage 1: Docling extract]
S1 --> S2[(Stage 2: SQLite checkpoint)]
S2 --> S3[Stage 3: Semantic chunk]
S3 --> S4[(Stage 4: Embed + Qdrant)]
```
### Stage 0 — Physical PDF split
Splits master bounded PDFs into Docling-sized slices. **Physical splits are for extraction parallelism only; they are not RAG chunk boundaries.**
| Book | Constraint tier | Split rule | Output dir | Reference script |
|------|-----------------|------------|------------|------------------|
| **ADO** | Loose | Fixed 5-page batches, no section boundary | `corpus/pdf/ado/batches/` | (same pattern as TNY) |
| **TNY** | Loose | Fixed 5-page batches, no section boundary | `corpus/pdf/tny/batches/` | `knowledge/tests/tny_split_page.py` |
| **OHO** | Section | 5-page batches **within** each section | `corpus/pdf/oho/sections/` | `knowledge/tests/oho_split_page.py` |
| **MOR** | Subsection | One PDF per TOC subsection (variable page count) | `corpus/pdf/mor/sections/` | `knowledge/tests/mor_split_page.py` |
Section/subsection page maps:
- OHO: `corpus/pdf/oho/index.txt`
- MOR: `corpus/pdf/mor/toc_structure.json`
### Stage 1 — Docling extraction
Convert each physical PDF slice to markdown.
- **Engine:** Docling `DocumentConverter` (pre-loaded singleton for batch runs)
- **Output:** Markdown string per source file
- **Reference:** `knowledge/tests/ingestion_corpus_db.py`
Constraints:
- One SQLite row per physical PDF file (never merge at this stage)
- Preserve full `source_path` for downstream metadata parsing
- Idempotent: skip files already present in `processed_chunks` unless `force_reextract=true`
### Stage 2 — Extraction checkpoint (SQLite)
Raw markdown checkpoint. **Do not semantic-chunk or embed directly from this table.**
See `schema.md``processed_chunks`.
Per-book databases (convention):
| Book | DB file |
|------|---------|
| ADO | `ado_ingestion_corpus.db` |
| TNY | `tny_ingestion_corpus.db` |
| OHO | `oho_ingestion_corpus.db` |
| MOR | `mor_ingestion_corpus.db` |
A unified `{book}_ingestion_corpus.db` per textbook keeps checkpoint isolation and allows independent re-chunking.
### Stage 3 — Semantic chunking
Transform checkpoint rows into retrieval-sized chunks using book-aware assembly + hybrid splitting.
**Detailed rules:** `semantic_chunking_spec.md`
**Book profiles:** `corpus_profiles.md`
Input: `processed_chunks` rows for one book
Output: `semantic_chunks` rows (same DB or sibling DB)
Properties:
- Versioned (`chunker_version`) — re-chunk without re-extracting
- Idempotent — skip logical units already chunked at current version
- Traceable — every chunk links back to one or more `processed_chunks.id`
### Stage 4 — Embedding and vector store
Embed `semantic_chunks.content` and upsert to Qdrant (768-d, EmbeddingGemma per `knowledge_spec.md`).
Payload fields (minimum):
```json
{
"chunk_id": "uuid",
"book_id": "mor",
"logical_unit_id": "1.1",
"parent_title": "1.1 History Taking for Patients with Rheumatic Complaints",
"page_start": 28,
"page_end": 32,
"chunk_index": 0,
"chunker_version": "header+semantic_v1",
"source_extraction_ids": [12]
}
```
Update `semantic_chunks.embedding_status` and `qdrant_point_id` on success.
## Planned Implementation Layout
```
knowledge/
├─ spec/ingestion/ # This spec package
├─ implementation/ingestion/ # Stage 14 modules (future)
│ ├─ extract.py # Docling → processed_chunks
│ ├─ assemble.py # Book-aware logical-unit assembly
│ ├─ chunk.py # Header + semantic chunking
│ ├─ embed.py # EmbeddingGemma → Qdrant
│ └─ metadata_parser.py # source_path → structured metadata
└─ tests/
├─ ingestion_corpus_db.py # Stage 12 (existing)
├─ *_split_page.py # Stage 0 (existing)
└─ markdown_chunking_oho.py # Prototype header split (superseded by chunk.py)
```
## Rollout Order
1. **MOR** — subsection rows ≈ logical units; validate chunk → embed → retrieve loop
2. **OHO** — section-scoped batch assembly from filenames
3. **TNY / ADO** — full-book stream assembly + header/TOC boundary detection
## Breaking-change Policy
| Change | Version bump |
|--------|--------------|
| New book profile | MINOR |
| `chunker_version` algorithm change | New version string; re-chunk required |
| `semantic_chunks` schema additive column | MINOR |
| Embedding model dimension change | MAJOR (per `knowledge_spec.md`) |
| Removal of citation metadata field | MAJOR |
## References
- `semantic_chunking_spec.md` — Stage 3 algorithm
- `corpus_profiles.md` — per-book assembly and filename parsing
- `schema.md` — SQLite table definitions
- `../knowledge_spec.md` — RAG stack (Qdrant, EmbeddingGemma)
- `knowledge/tests/ingestion_corpus_db.py` — current Stage 12 prototype

View File

@@ -0,0 +1,190 @@
# Ingestion SQLite Schema
## Purpose
Defines checkpoint and semantic-chunk tables for the knowledge ingestion pipeline. One database file per textbook.
## Database files
| Book | Filename |
|------|----------|
| ADO | `ado_ingestion_corpus.db` |
| TNY | `tny_ingestion_corpus.db` |
| OHO | `oho_ingestion_corpus.db` |
| MOR | `mor_ingestion_corpus.db` |
Location: `knowledge/corpus/db/` (recommended) or co-located with ingestion scripts during development.
---
## Table: `processed_chunks` (Stage 2 — extraction checkpoint)
Raw Docling markdown per physical PDF slice. **Read-only input for semantic chunking.**
```sql
CREATE TABLE IF NOT EXISTS processed_chunks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_path TEXT NOT NULL UNIQUE,
markdown_content TEXT NOT NULL,
execution_time REAL,
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_processed_chunks_source_path
ON processed_chunks (source_path);
```
| Column | Type | Description |
|--------|------|-------------|
| `id` | INTEGER | Surrogate key; referenced by `semantic_chunks.source_extraction_ids` |
| `source_path` | TEXT | Absolute or repo-relative path to source PDF |
| `markdown_content` | TEXT | Full Docling markdown for that PDF slice |
| `execution_time` | REAL | Docling wall time (seconds) |
| `processed_at` | TIMESTAMP | Insert timestamp |
**Migration from current prototype:** `knowledge/tests/ingestion_corpus_db.py` already creates this table; add `UNIQUE` on `source_path` and index when promoting to implementation.
---
## Table: `semantic_chunks` (Stage 3 — retrieval chunks)
Hybrid header + semantic chunks ready for embedding.
```sql
CREATE TABLE IF NOT EXISTS semantic_chunks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chunk_uuid TEXT NOT NULL UNIQUE,
logical_unit_id TEXT NOT NULL,
chunk_index INTEGER NOT NULL,
content TEXT NOT NULL,
token_count INTEGER NOT NULL,
book_id TEXT NOT NULL,
section_id INTEGER,
subsection_id TEXT,
parent_title TEXT,
page_start INTEGER,
page_end INTEGER,
source_extraction_ids TEXT NOT NULL, -- JSON array of processed_chunks.id
chunker_version TEXT NOT NULL,
embedding_status TEXT NOT NULL DEFAULT 'pending',
qdrant_point_id TEXT,
content_hash TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE (logical_unit_id, chunk_index, chunker_version, book_id)
);
CREATE INDEX IF NOT EXISTS idx_semantic_chunks_book_version
ON semantic_chunks (book_id, chunker_version);
CREATE INDEX IF NOT EXISTS idx_semantic_chunks_embedding_status
ON semantic_chunks (embedding_status);
CREATE INDEX IF NOT EXISTS idx_semantic_chunks_logical_unit
ON semantic_chunks (logical_unit_id, chunker_version);
```
| Column | Type | Description |
|--------|------|-------------|
| `chunk_uuid` | TEXT | UUID v4 for Qdrant payload / API citations |
| `logical_unit_id` | TEXT | Assembly key, e.g. `sec_3`, `sub_1.1`, `tny_sec_12` |
| `chunk_index` | INTEGER | Order within logical unit (0-based) |
| `content` | TEXT | Final chunk markdown |
| `token_count` | INTEGER | Tokens per embedding model tokenizer |
| `book_id` | TEXT | `ado`, `tny`, `oho`, `mor` |
| `section_id` | INTEGER | Nullable; from filename or TOC |
| `subsection_id` | TEXT | Nullable; e.g. `1.1` (MOR) |
| `parent_title` | TEXT | Human-readable section/subsection title |
| `page_start`, `page_end` | INTEGER | Inclusive citation page range |
| `source_extraction_ids` | TEXT | JSON `[12, 13, 14]` — provenance |
| `chunker_version` | TEXT | e.g. `header+semantic_v1` |
| `embedding_status` | TEXT | `pending` \| `done` \| `failed` |
| `qdrant_point_id` | TEXT | Set after Stage 4 upsert |
| `content_hash` | TEXT | SHA-256 of `content` for dedup |
### `embedding_status` values
| Value | Meaning |
|-------|---------|
| `pending` | Chunk written; not yet embedded |
| `done` | Embedded and upserted to Qdrant |
| `failed` | Embed/upsert error; retry eligible |
---
## Table: `chunking_runs` (optional — audit log)
Track chunk pipeline executions for idempotency and debugging.
```sql
CREATE TABLE IF NOT EXISTS chunking_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
book_id TEXT NOT NULL,
chunker_version TEXT NOT NULL,
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
finished_at TIMESTAMP,
status TEXT NOT NULL DEFAULT 'running',
units_processed INTEGER DEFAULT 0,
chunks_written INTEGER DEFAULT 0,
error_message TEXT,
UNIQUE (book_id, chunker_version, started_at)
);
```
---
## PostgreSQL target (Supabase)
Stage 4 upserts into the **`knowledge`** schema on Supabase (pgvector HNSW). See:
- `../pg_semantic_vector_db/er_diagram.md` — entity model
- `../pg_semantic_vector_db/supabase_schema.md` — migration reference
- `../../supabase/migrations/` — apply with `supabase db push`
| SQLite column | Supabase table.column |
|---------------|----------------------|
| `chunk_uuid` | `knowledge.semantic_chunk.chunk_id` |
| `embedding_status` | `knowledge.semantic_chunk_embedding.embedding_status` |
| `qdrant_point_id` | **dropped** — use `chunk_id` |
---
## Qdrant payload mapping (legacy reference)
| Qdrant payload field | Source column |
|----------------------|---------------|
| `chunk_id` | `chunk_uuid` |
| `book_id` | `book_id` |
| `logical_unit_id` | `logical_unit_id` |
| `parent_title` | `parent_title` |
| `page_start` | `page_start` |
| `page_end` | `page_end` |
| `chunk_index` | `chunk_index` |
| `chunker_version` | `chunker_version` |
| `section_id` | `section_id` |
| `subsection_id` | `subsection_id` |
Vector: 768 dimensions (EmbeddingGemma).
Collection naming convention: `guidelines_{book_id}_{chunker_version}` (e.g. `guidelines_mor_header+semantic_v1`).
---
## Idempotency rules
| Operation | Skip condition |
|-----------|----------------|
| Extract (Stage 12) | `source_path` already in `processed_chunks` |
| Chunk (Stage 3) | `(logical_unit_id, chunk_index, chunker_version, book_id)` exists |
| Embed (Stage 4) | `embedding_status = 'done'` |
Force re-run: pass `--force` flag (implementation) to delete and regenerate rows for scoped book + version.
---
## References
- `ingestion_pipeline_spec.md`
- `semantic_chunking_spec.md`
- `corpus_profiles.md`

View File

@@ -0,0 +1,198 @@
# Semantic Chunking Specification
## Purpose
Defines how raw Docling markdown (stored in `processed_chunks`) is transformed into retrieval-ready semantic chunks. Applies a **hybrid strategy**: book-aware assembly → structural pre-split → embedding-based semantic split.
## Owner
Knowledge Engineering Team
## Design Principles
1. **Physical PDF splits ≠ RAG chunks.** Extraction batches exist for Docling memory and parallelism.
2. **Structure before semantics.** Markdown headers and TOC boundaries are hard guardrails; semantic splitting refines oversized pieces only.
3. **Never split inside protected blocks.** Tables, code fences, and figure captions stay intact.
4. **Every chunk is citable.** Page range, book, logical unit, and source extraction IDs are mandatory metadata.
5. **Re-chunkable.** `chunker_version` allows algorithm updates without re-running Docling.
## Chunking Pipeline
```mermaid
flowchart TD
A[Read processed_chunks for book] --> B[Parse source_path metadata]
B --> C[Assemble logical units]
C --> D[Header pre-split]
D --> E{token_count > max_tokens?}
E -->|no| F[Emit chunk as-is]
E -->|yes| G[Semantic split]
G --> H[Enforce min_tokens / merge tiny pieces]
F --> I[Write semantic_chunks]
H --> I
```
### Step 1 — Parse `source_path`
Extract structured metadata from each checkpoint row's filename. Regex patterns are defined in `corpus_profiles.md`.
Minimum parsed fields:
| Field | Description |
|-------|-------------|
| `book_id` | `ado`, `tny`, `oho`, `mor` |
| `page_start`, `page_end` | Inclusive page numbers from filename |
| `section_id` | Section number (OHO) or null |
| `subsection_id` | Subsection id e.g. `1.1` (MOR) or null |
| `batch_index` | Intra-unit batch sequence or null |
### Step 2 — Assemble logical units
Group checkpoint rows into **logical units** before any splitting. Assembly rules differ by constraint tier (see `corpus_profiles.md`).
| Tier | Books | Assembly rule |
|------|-------|---------------|
| Loose | ADO, TNY | Stitch **all** batches in page order → one stream per book; then subdivide by headers/TOC |
| Section | OHO | Group by `section_id`; stitch batches within section in page order |
| Subsection | MOR | **No cross-row assembly** — each row is one logical unit |
Concatenation separator between stitched batches:
```text
\n\n<!-- batch-boundary -->\n\n
```
The HTML comment marker is stripped from final chunk content but logged in assembly metadata for debugging mid-batch topic splits.
### Step 3 — Header pre-split
Split assembled text on markdown headers before semantic chunking.
Default header levels:
| Level | Metadata key | Use |
|-------|--------------|-----|
| `#` | `h1` | Part / major division (ADO, TNY when detected) |
| `##` | `h2` | Section |
| `###` | `h3` | Subsection |
| `<!-- image --> ` | `<!-- image -->` | Image|
Implementation reference: LangChain `MarkdownHeaderTextSplitter` (prototype in `knowledge/tests/markdown_chunking_oho.py`).
Rules:
- Propagate header metadata to all descendant chunks (`parent_title` = nearest `##` or `###`)
- If a header section is empty after strip, discard it
- Do not split inside fenced code blocks (```) or HTML tables
### Step 4 — Semantic split (oversized pieces only)
Apply embedding-based breakpoint detection **only** when `token_count(content) > max_tokens`.
| Parameter | Default | Notes |
|-----------|---------|-------|
| `max_tokens` | 800 | Target upper bound per chunk |
| `min_tokens` | 120 | Merge or carry forward pieces below this |
| `overlap_tokens` | 64 | Optional overlap at semantic boundaries |
| `breakpoint_threshold_type` | `percentile` | Alternative: `standard_deviation`, `interquartile` |
| `breakpoint_threshold_amount` | 95 | Percentile for similarity drop detection |
| `embedding_model` | EmbeddingGemma | Must match Stage 4 embed model |
Recommended library: LangChain Experimental `SemanticChunker` or LlamaIndex `SemanticSplitterNodeParser`.
Sentence/paragraph granularity:
- Split input into sentences (or paragraphs for list-heavy clinical text)
- Embed consecutive windows; detect largest cosine-similarity drops
- Cut at breakpoints; merge segments below `min_tokens` with neighbors
**MOR shortcut:** If logical unit token count ≤ `max_tokens`, skip semantic split entirely (pass-through after optional header split).
### Step 5 — Post-processing
1. **Merge tiny chunks:** If `token_count < min_tokens`, merge with previous chunk in same logical unit (unless previous would exceed `max_tokens * 1.25`)
2. **Assign `chunk_index`:** Zero-based, ordered within logical unit
3. **Compute page range:** For multi-batch units, use min(`page_start`) and max(`page_end`) across constituent extraction rows; for semantic sub-chunks within a unit, inherit parent page range (fine-grained page mapping is a future enhancement)
4. **Deduplicate:** Hash `(book_id, logical_unit_id, chunk_index, chunker_version, content)` — skip exact duplicates on re-run
## Chunker Versions
| Version | Description |
|---------|-------------|
| `header_v1` | Header pre-split only; no semantic split |
| `header+semantic_v1` | Header pre-split + semantic split on oversized pieces (default) |
Bump version string when algorithm parameters or assembly rules change materially.
## Book-specific behavior summary
### ADO / TNY (loose tier)
Highest assembly effort:
1. Load all `processed_chunks` ordered by `page_start`
2. Concatenate into `book_stream`
3. Subdivide `book_stream`:
- **Preferred:** TOC page→section map (when available)
- **Fallback:** Split on `#` / `##` headers from Docling output
4. Header pre-split within each section
5. Semantic split oversized sections
Risk: topic spans batch boundary with no header at boundary → semantic chunker must see full assembled section text, not individual batches.
### OHO (section tier)
1. Group rows by `section_id` from filename (`sec_N_...`)
2. Sort by `batch_index` / `page_start`; concatenate
3. Header pre-split within section
4. Semantic split if section text > `max_tokens`
Section boundaries from `corpus/pdf/oho/index.txt` are authoritative — never merge across sections.
### MOR (subsection tier)
1. Each row = one logical unit (`subsection_id` from `sub_X.X_...`)
2. Optional header pre-split on `##` / `###` within subsection
3. Semantic split only if subsection > `max_tokens`
4. Many subsections (≤5 pages) may produce a **single chunk**
Subsection titles from `corpus/pdf/mor/toc_structure.json` populate `parent_title`.
## Protected content rules
Do not split inside:
- Markdown tables (`| ... |` blocks)
- Fenced code blocks
- Numbered/bulleted lists shorter than `min_tokens` (keep list intact)
- Figure/table captions immediately following a figure reference line
If semantic split would break a table, prefer keeping the entire table in one chunk even if it slightly exceeds `max_tokens` (hard cap: `max_tokens * 1.5`).
## Output contract
Each row in `semantic_chunks` must satisfy:
- `content` is non-empty UTF-8 markdown
- `token_count` is computed with the same tokenizer as the embedding model
- `source_extraction_ids` is a JSON array of one or more `processed_chunks.id`
- `logical_unit_id` is stable across re-runs (see `corpus_profiles.md`)
- `chunker_version` matches the active pipeline version
## Evaluation criteria (acceptance)
Before promoting a new `chunker_version`:
| Check | Target |
|-------|--------|
| Sample retrieval on 20 clinical queries | ≥ baseline vs header-only |
| Chunks with `token_count < min_tokens` | < 5% of total |
| Chunks with `token_count > max_tokens * 1.5` | < 2% of total |
| Chunks missing `parent_title` (MOR/OHO) | 0% |
| Citation traceability (manual spot check) | 100% link to source pages |
## References
- `ingestion_pipeline_spec.md` full pipeline context
- `corpus_profiles.md` filename patterns and assembly keys
- `schema.md` `semantic_chunks` table
- `knowledge/tests/markdown_chunking_oho.py` header split prototype

View File

@@ -28,7 +28,7 @@ Knowledge Engineering Team
- ladybugDB schema details (predicate names, ontology version)
- embedding model specifics (model ID, tensor shapes)
- LLM prompt templates and decoding parameters
- knowledge curation pipeline details (source ingestion, validation)
- ingestion SQLite checkpoint paths and chunker version strings (see `spec/ingestion/` for pipeline design; operational paths are internal)
## Breaking-change Policy
- Knowledge schema versioning via semantic versioning (MAJOR.MINOR.PATCH)

View File

@@ -18,12 +18,33 @@ Qdrant vector database instances, ladybugDB graph database instances, embedding
- Retrieval pipeline: hybrid search (vector + BM25) → graph expansion → reranking
- Grounding module: verifies LLM outputs against source guidelines with citation extraction
- Arbitration engine: resolves conflicting evidence using belief propagation
- Continuous integration: automated guideline ingestion from trusted sources (NIH, CDC, radiology societies)
- Textbook ingestion pipeline: PDF split → Docling checkpoint (SQLite) → book-aware semantic chunking → pgvector upsert (see `spec/ingestion/`, `spec/pg_semantic_vector_db/`)
- Versioned knowledge bases with temporal validity tracking
- Monitoring: retrieval relevance, grounding accuracy, latency SLOs
## Ingestion Specifications
See `spec/ingestion/` for the textbook RAG ingestion pipeline:
| Document | Scope |
|----------|-------|
| `ingestion_pipeline_spec.md` | Stages 04 end-to-end |
| `semantic_chunking_spec.md` | Hybrid header + semantic chunking algorithm |
| `corpus_profiles.md` | ADO, TNY, OHO, MOR assembly and filename rules |
| `schema.md` | SQLite `processed_chunks` and `semantic_chunks` tables |
## Vector Store Specifications
See `spec/pg_semantic_vector_db/` for the PostgreSQL + pgvector retrieval schema:
| Document | Scope |
|----------|-------|
| `er_diagram.md` | Entities, relationships, and SQLite→PG mapping |
| `supabase_schema.md` | Applied Supabase migrations, RPC, security model |
Migrations: `knowledge/supabase/migrations/`
## Interface Contract
See `bento/knowledge/spec/interface-contract.md`.
See `spec/interface_contract.md`.
## Consumers
- frontend:guideline-spec (for displaying grounded explanations in UI)

View File

@@ -0,0 +1,509 @@
# pg-Semantic-Vector-DB — Entity-Relationship Design
## Purpose
Define the **PostgreSQL + pgvector** data model for retrieval-ready semantic chunks from clinical textbooks (ADO, TNY, OHO, MOR). This is the **authoritative RAG store** queried at runtime (HNSW similarity search + SQL metadata filters).
## Scope
| In scope | Out of scope (separate stores) |
|----------|--------------------------------|
| Textbook semantic chunks and their EmbeddingGemma vectors | SQLite ingestion checkpoints (`processed_chunks`) |
| Citation metadata for NFR-18 grounding | BERT drift/referee embeddings (separate `drift_embeddings` table per architecture) |
| Corpus edition and chunker versioning | ladybugDB ontology graph |
| Provenance links back to extraction checkpoints | Session/case embeddings (clinical data domain) |
| HNSW index lifecycle per corpus + model | Qdrant (Phase 2 hot cache only) |
## Storage boundary
```mermaid
flowchart LR
subgraph sqlite [SQLite — ingestion staging]
PC[processed_chunks]
SC_STG[semantic_chunks staging]
end
subgraph pg [PostgreSQL + pgvector — retrieval]
CE[corpus_edition]
LU[logical_unit]
SC[semantic_chunk]
SCE[semantic_chunk_embedding]
end
PC -->|Stage 3 chunk| SC_STG
SC_STG -->|Stage 4 upsert| SC
SC --> SCE
```
SQLite remains **per-book, per-pipeline** checkpoint storage. PostgreSQL holds **deduplicated, queryable, versioned** chunks with vectors.
---
## ER diagram
```mermaid
erDiagram
corpus_source ||--o{ corpus_edition : "has editions"
corpus_edition ||--o{ structure_node : "has TOC tree"
structure_node ||--o{ structure_node : "parent of"
corpus_edition ||--o{ logical_unit : "contains"
structure_node |o--o| logical_unit : "may map to"
logical_unit ||--|{ semantic_chunk : "split into"
chunker_profile ||--o{ semantic_chunk : "produced by"
semantic_chunk ||--o{ chunk_provenance : "traced from"
semantic_chunk ||--o{ semantic_chunk_embedding : "embedded as"
embedding_model ||--o{ semantic_chunk_embedding : "uses"
corpus_edition ||--o{ ingestion_run : "processed in"
chunker_profile ||--o{ ingestion_run : "uses"
corpus_edition ||--o{ vector_index_snapshot : "indexed under"
embedding_model ||--o{ vector_index_snapshot : "indexed with"
corpus_source {
uuid corpus_source_id PK
text book_id UK "ado | tny | oho | mor"
text display_name
text constraint_tier "loose | section | subsection"
text locale "vi-VN"
timestamptz created_at
}
corpus_edition {
uuid edition_id PK
uuid corpus_source_id FK
text edition_label "e.g. 2024-print, v1"
text source_pdf_sha256
text status "draft | active | superseded | archived"
date effective_from
date effective_to
timestamptz created_at
}
structure_node {
uuid structure_node_id PK
uuid edition_id FK
uuid parent_structure_node_id FK "nullable — root nodes"
text node_type "part | section | subsection"
text node_key "e.g. sec_3, 1.1"
text title
int page_start
int page_end
int sort_order
}
logical_unit {
uuid logical_unit_id PK
uuid edition_id FK
uuid structure_node_id FK "nullable"
text unit_key "stable key e.g. sec_3, sub_1.1"
text parent_title
int page_start
int page_end
timestamptz assembled_at
}
chunker_profile {
text chunker_version PK "e.g. header+semantic_v1"
text description
int max_tokens
int min_tokens
int overlap_tokens
jsonb params_json
timestamptz created_at
}
semantic_chunk {
uuid chunk_id PK "API citation id"
uuid logical_unit_id FK
uuid edition_id FK
text chunker_version FK
int chunk_index "0-based within logical unit"
text content "markdown"
int token_count
text content_hash "SHA-256"
int section_id "nullable"
text subsection_id "nullable"
text parent_title
int page_start
int page_end
boolean is_active "soft retire on re-chunk"
timestamptz created_at
}
chunk_provenance {
uuid provenance_id PK
uuid chunk_id FK
text checkpoint_db "e.g. mor_ingestion_corpus.db"
bigint processed_chunk_id "SQLite processed_chunks.id"
text source_path "original PDF slice path"
}
embedding_model {
uuid model_id PK
text model_name "EmbeddingGemma"
text model_version
int dimensions "768"
text purpose "rag"
boolean is_active
timestamptz registered_at
}
semantic_chunk_embedding {
uuid embedding_id PK
uuid chunk_id FK
uuid model_id FK
uuid edition_id FK
vector embedding "pgvector, 768-d"
text embedding_status "pending | indexed | failed"
timestamptz embedded_at
}
ingestion_run {
uuid run_id PK
uuid edition_id FK
text chunker_version FK
uuid model_id FK "nullable until Stage 4"
text stage "chunk | embed | full"
text status "running | succeeded | failed"
int units_processed
int chunks_written
int embeddings_written
text error_message
timestamptz started_at
timestamptz finished_at
}
vector_index_snapshot {
uuid index_snapshot_id PK
uuid edition_id FK
uuid model_id FK
text index_name "e.g. hnsw_semantic_chunk_embedding_rag"
jsonb hnsw_params "m, ef_construction"
boolean is_active
timestamptz built_at
}
```
---
## Entity definitions
### 1. `corpus_source`
Registry of ingestible textbook corpora. One row per book family.
| Attribute | Notes |
|-----------|-------|
| `book_id` | Stable slug: `ado`, `tny`, `oho`, `mor` |
| `constraint_tier` | Mirrors `corpus_profiles.md`: drives assembly rules at ingest |
| `locale` | Default `vi-VN` for pilot textbooks |
| `full_title`, `short_title`, `subtitle` | Bibliographic titles for citations |
| `primary_language` | ISO-style language code (`en`, `vi`) |
| `subject_area` | RAG classification (e.g. Rheumatology) |
| `bibliography_json` | Extensible work metadata (keywords, resource_type) |
Bibliographic seed data: [`corpus/source_metadata.md`](../../corpus/source_metadata.md).
**Cardinality:** one `corpus_source` → many `corpus_edition`.
---
### 2. `corpus_edition`
Immutable snapshot of a source document generation. Re-chunking and re-embedding are scoped to an edition.
| Attribute | Notes |
|-----------|-------|
| `source_pdf_sha256` | Detects PDF replacement without silent drift |
| `status` | Only one `active` edition per `corpus_source` at a time (enforced by partial unique index) |
| `effective_from` / `effective_to` | Temporal validity for citations and audit |
| `publisher_name`, `publisher_place` | Publication imprint |
| `published_year`, `published_date` | Publication date for citations |
| `isbn_13`, `isbn_10` | ISBN identifiers |
| `edition_number`, `source_uri` | Edition label and canonical source file |
| `bibliography_json` | Extensible edition metadata (LCCN, series, etc.) |
**Why separate from `corpus_source`:** Same book (TNY) may be re-ingested after PDF correction; old chunks stay addressable for audit while new edition becomes `active`.
---
### 2b. `corpus_contributor`
Authors, editors, and other credited contributors for a work or specific edition.
| Attribute | Notes |
|-----------|-------|
| `corpus_source_id` | Required FK to work |
| `edition_id` | NULL = work-level contributor; set for edition-specific overrides |
| `role` | `author`, `editor`, `translator`, `compiler`, `institution` |
| `full_name`, `sort_order` | Citation ordering |
Seed data: [`corpus/source_metadata.md`](../../corpus/source_metadata.md).
---
### 3. `structure_node`
Optional document hierarchy (TOC). Strongly populated for OHO/MOR; ADO/TNY may be partially inferred from headers post-assembly.
| Attribute | Notes |
|-----------|-------|
| `node_type` | `part`, `section`, `subsection` |
| `node_key` | Matches filename/TOC keys: `sec_3`, `1.1` |
| Self-FK | Tree; root nodes have `parent_structure_node_id IS NULL` |
**Relationship:** `structure_node` **0..1 — 0..1** `logical_unit` (a logical unit may exist without a TOC node when boundaries come from header detection only).
---
### 4. `logical_unit`
The **assembly boundary** before chunking — the unit described in `semantic_chunking_spec.md`.
| Attribute | Notes |
|-----------|-------|
| `unit_key` | Stable across runs: `sec_{N}`, `sub_{X.Y}`, `h2_{slug}` |
| `edition_id` | Same `unit_key` in two editions = different rows |
**Cardinality:** one `logical_unit` → one or many `semantic_chunk` (after header/semantic split).
**Assembly mapping by tier:**
| Tier | Books | `logical_unit` origin |
|------|-------|------------------------|
| Loose | ADO, TNY | One row per detected section after full-book stitch |
| Section | OHO | One row per `section_id` |
| Subsection | MOR | One row per `subsection_id` (= one checkpoint row) |
---
### 5. `chunker_profile`
Version registry for the chunking algorithm. Not a runtime entity for queries, but FK anchor for reproducibility.
| Attribute | Notes |
|-----------|-------|
| `chunker_version` | PK string: `header+semantic_v1` |
| `params_json` | Thresholds, breakpoint settings, protected-block rules hash |
**Cardinality:** one profile → many `semantic_chunk` rows (across editions and logical units).
---
### 6. `semantic_chunk`
Core **retrieval text unit**. This is what RAG returns (content + citation metadata).
| Attribute | Notes |
|-----------|-------|
| `chunk_id` | UUID v4 — external citation id (replaces SQLite `chunk_uuid` / Qdrant point id) |
| `chunk_index` | Order within parent `logical_unit` |
| `is_active` | Set `false` when superseded by re-chunk; keeps audit trail |
| Citation fields | `parent_title`, `page_start`, `page_end`, `section_id`, `subsection_id` |
**Uniqueness:**
```sql
UNIQUE (logical_unit_id, chunk_index, chunker_version)
-- scoped implicitly by edition via logical_unit.edition_id
```
**Cardinality:** one chunk → zero or one active embedding per model (see below).
---
### 7. `chunk_provenance`
Many-to-one trace from a semantic chunk back to SQLite extraction rows. Supports multi-batch assembly (ADO/TNY/OHO).
| Attribute | Notes |
|-----------|-------|
| `checkpoint_db` | Which `{book}_ingestion_corpus.db` |
| `processed_chunk_id` | FK into SQLite, not PostgreSQL |
| `source_path` | Denormalized for debugging without opening SQLite |
**Cardinality:** one `semantic_chunk`**1..N** `chunk_provenance` (N > 1 when batches were stitched).
---
### 8. `embedding_model`
Registry of embedding models allowed to populate the RAG index.
| Attribute | Notes |
|-----------|-------|
| `purpose` | `rag` only in this schema; drift models live elsewhere |
| `dimensions` | Must match pgvector column width (768 for EmbeddingGemma) |
PoC: single active row (`EmbeddingGemma`, 768-d).
---
### 9. `semantic_chunk_embedding`
**The vector store.** Separated from `semantic_chunk` so text can be re-embedded under a new model without duplicating content rows.
| Attribute | Notes |
|-----------|-------|
| `embedding` | `vector(768)` with HNSW index |
| `embedding_status` | Pipeline state: `pending``indexed` |
| `edition_id` | Denormalized for partition-friendly queries |
**Uniqueness:**
```sql
UNIQUE (chunk_id, model_id)
```
**Cardinality:** one chunk → at most one embedding per model.
**Query pattern (runtime RAG):**
```sql
SELECT sc.chunk_id, sc.content, sc.parent_title, sc.page_start, sc.page_end,
1 - (sce.embedding <=> :query_vec) AS similarity
FROM semantic_chunk_embedding sce
JOIN semantic_chunk sc ON sc.chunk_id = sce.chunk_id
JOIN corpus_edition ce ON ce.edition_id = sc.edition_id
JOIN corpus_source cs ON cs.corpus_source_id = ce.corpus_source_id
WHERE ce.status = 'active'
AND sc.is_active = TRUE
AND sce.embedding_status = 'indexed'
AND cs.book_id = ANY(:book_ids)
ORDER BY sce.embedding <=> :query_vec
LIMIT :k;
```
---
### 10. `ingestion_run`
Audit log for chunk and embed pipeline executions (PostgreSQL counterpart to SQLite `chunking_runs` + embed stage).
| Attribute | Notes |
|-----------|-------|
| `stage` | `chunk`, `embed`, or `full` |
| Counters | `units_processed`, `chunks_written`, `embeddings_written` |
**Cardinality:** one edition may have many runs (idempotent re-runs, version bumps).
---
### 11. `vector_index_snapshot`
Tracks HNSW index builds for blue/green reindex when `chunker_version` or `embedding_model` changes.
| Attribute | Notes |
|-----------|-------|
| `is_active` | Only one active snapshot per `(edition_id, model_id)` |
| `hnsw_params` | e.g. `{"m": 16, "ef_construction": 64}` |
At PoC scale (~15K vectors), a single HNSW index on `semantic_chunk_embedding.embedding` filtered by `edition_id` is sufficient; this entity documents index lifecycle for future corpus growth.
---
## Relationship summary
| From | To | Cardinality | Semantics |
|------|-----|-------------|-----------|
| `corpus_source` | `corpus_edition` | 1:N | Book has versioned PDF editions |
| `corpus_edition` | `structure_node` | 1:N | Edition has TOC tree |
| `structure_node` | `structure_node` | 1:N | Parent/child hierarchy |
| `corpus_edition` | `logical_unit` | 1:N | Edition contains assembly units |
| `structure_node` | `logical_unit` | 0..1:0..1 | Optional TOC alignment |
| `logical_unit` | `semantic_chunk` | 1:N | Unit splits into retrieval chunks |
| `chunker_profile` | `semantic_chunk` | 1:N | Algorithm version provenance |
| `semantic_chunk` | `chunk_provenance` | 1:N | Trace to SQLite extractions |
| `semantic_chunk` | `semantic_chunk_embedding` | 1:N | One row per embedding model |
| `embedding_model` | `semantic_chunk_embedding` | 1:N | Model produces many vectors |
| `corpus_edition` | `ingestion_run` | 1:N | Pipeline execution history |
| `corpus_edition` | `vector_index_snapshot` | 1:N | Index rebuild history |
---
## Design decisions
### Why split `semantic_chunk` and `semantic_chunk_embedding`?
- Re-embed with a new model without cloning text rows
- Keep inactive/historical embeddings while marking chunks inactive
- HNSW index targets only the embedding table (smaller hot set)
### Why `corpus_edition` instead of embedding `book_id` only?
- Supports PDF corrections and temporal citation ("per TNY 2024 edition, p. 42")
- Clean supersession: deactivate old edition chunks without deleting audit data
- Aligns with architecture "versioned knowledge bases with temporal validity tracking"
### Why keep provenance as a junction table?
- ADO/TNY/OHO chunks often span multiple `processed_chunks` rows
- SQLite checkpoint DBs stay per-book; provenance stores the cross-reference explicitly
### Active-edition invariant
At query time, RAG MUST filter:
```sql
ce.status = 'active' AND sc.is_active = TRUE AND sce.embedding_status = 'indexed'
```
Only one active edition per `corpus_source` prevents mixed-corpus retrieval.
---
## Mapping from SQLite staging (`schema.md`)
| SQLite `semantic_chunks` | PostgreSQL |
|--------------------------|------------|
| `chunk_uuid` | `semantic_chunk.chunk_id` |
| `logical_unit_id` | `logical_unit.unit_key` (+ FK via edition) |
| `chunk_index` | `semantic_chunk.chunk_index` |
| `content` | `semantic_chunk.content` |
| `token_count` | `semantic_chunk.token_count` |
| `book_id` | `corpus_source.book_id` (via edition) |
| `section_id`, `subsection_id` | same columns on `semantic_chunk` |
| `parent_title`, `page_start`, `page_end` | same |
| `source_extraction_ids` (JSON) | `chunk_provenance` rows (1 per id) |
| `chunker_version` | FK → `chunker_profile` |
| `embedding_status` | `semantic_chunk_embedding.embedding_status` |
| `qdrant_point_id` | **dropped**`chunk_id` is the retrieval key |
| `content_hash` | `semantic_chunk.content_hash` |
---
## Indexes (planned)
| Index | Table | Purpose |
|-------|-------|---------|
| HNSW | `semantic_chunk_embedding.embedding` | Cosine similarity search |
| `(edition_id, is_active)` | `semantic_chunk` | Filter active chunks per edition |
| `(corpus_source_id) WHERE status = 'active'` | `corpus_edition` | Partial unique — one active edition |
| `(logical_unit_id, chunk_index, chunker_version)` | `semantic_chunk` | Idempotent upsert |
| `(chunk_id, model_id)` | `semantic_chunk_embedding` | Unique embedding per model |
| `(book_id)` | `corpus_source` | Filter by textbook at join time |
---
## PoC entity subset
For first implementation (MOR → OHO → TNY/ADO), minimum viable tables:
1. `corpus_source`
2. `corpus_edition`
3. `logical_unit`
4. `chunker_profile`
5. `semantic_chunk`
6. `chunk_provenance`
7. `embedding_model`
8. `semantic_chunk_embedding`
Defer until needed: `structure_node`, `vector_index_snapshot` (use DDL-only HNSW), full `ingestion_run` UI.
---
## References
- `../ingestion/schema.md` — SQLite staging schema
- `../ingestion/semantic_chunking_spec.md` — logical unit and chunk contract
- `../ingestion/corpus_profiles.md` — per-book assembly tiers
- `../knowledge_spec.md` — RAG stack (pgvector HNSW, EmbeddingGemma 768-d)
- `supabase_schema.md` — Supabase migration reference (`knowledge/supabase/migrations/`)
- `../../../Design_Material/SPRINT_1_2_ARCHITECTURE_SPEC.md` — pgvector over Qdrant at PoC scale

View File

@@ -0,0 +1,98 @@
# Supabase Schema Reference — pg-Semantic-Vector-DB
Maps the ER design to applied Supabase migrations under `knowledge/supabase/migrations/`.
## Migration order
| File | Contents |
|------|----------|
| `20260705000000_semantic_vector_db_schema.sql` | `knowledge` schema, 11 tables, constraints, HNSW index |
| `20260705000001_semantic_vector_db_seed.sql` | ADO/TNY/OHO/MOR sources, `header+semantic_v1`, EmbeddingGemma |
| `20260705000002_semantic_vector_db_rls_and_rpc.sql` | RLS, grants, `match_semantic_chunks()` RPC |
| `20260705000003_expose_knowledge_api_schema.sql` | PostgREST grants for `knowledge` schema |
| `20260705000004_chunker_profile_v2.sql` | `header+semantic_v2` chunker profile |
| `20260705000005_corpus_bibliography.sql` | Bibliography columns, `corpus_contributor`, citation view |
| `20260705000006_corpus_bibliography_seed.sql` | Bibliographic seed from `corpus/source_metadata.md` |
## Schema: `knowledge`
| Table / view | PK | Notable constraints |
|-------|-----|---------------------|
| `corpus_source` | `corpus_source_id` | `UNIQUE (book_id)`; bibliography columns |
| `corpus_edition` | `edition_id` | Partial unique: one `active` per source; publisher/ISBN |
| `corpus_contributor` | `contributor_id` | Authors/editors per work or edition |
| `v_corpus_citation` | — | View: active edition + contributor string |
| `structure_node` | `structure_node_id` | `UNIQUE (edition_id, node_key)` |
| `logical_unit` | `logical_unit_id` | `UNIQUE (edition_id, unit_key)` |
| `chunker_profile` | `chunker_version` | — |
| `semantic_chunk` | `chunk_id` | `UNIQUE (logical_unit_id, chunk_index, chunker_version)` |
| `chunk_provenance` | `provenance_id` | `UNIQUE (chunk_id, checkpoint_db, processed_chunk_id)` |
| `embedding_model` | `model_id` | `UNIQUE (model_name, model_version)` |
| `semantic_chunk_embedding` | `embedding_id` | `UNIQUE (chunk_id, model_id)`; HNSW on `embedding` |
| `ingestion_run` | `run_id` | — |
| `vector_index_snapshot` | `index_snapshot_id` | Partial unique: one active per edition+model |
## Vector column
```sql
embedding extensions.vector(768) -- EmbeddingGemma, cosine HNSW
```
Extension: `CREATE EXTENSION vector WITH SCHEMA extensions` (Supabase default pattern).
## RPC: `knowledge.match_semantic_chunks`
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `query_embedding` | `extensions.vector(768)` | required | Query vector from EmbeddingGemma |
| `match_count` | `integer` | `5` | Top-k limit |
| `filter_book_ids` | `text[]` | `NULL` | Restrict to `ado`, `tny`, `oho`, `mor` |
| `filter_edition_ids` | `uuid[]` | `NULL` | Restrict to specific editions |
Returns: chunk content, citation fields, `book_id`, `similarity` (1 cosine distance).
## Security model
| Role | Access |
|------|--------|
| `service_role` | Full CRUD on all `knowledge.*` tables; executes RPC |
| `authenticated` | SELECT on active chunks + indexed embeddings; executes RPC |
| `anon` | Denied (no policies) |
Ingestion pipeline and RAG coordinator should use **`service_role`** from the backend only.
## SQLite staging → Supabase upsert keys
| SQLite (`semantic_chunks`) | Supabase upsert key |
|----------------------------|---------------------|
| `book_id` | Resolve → `corpus_edition` via active edition for `corpus_source.book_id` |
| `logical_unit_id` | `logical_unit.unit_key` + `edition_id` |
| `chunk_index`, `chunker_version` | `semantic_chunk` unique constraint |
| `source_extraction_ids[]` | One `chunk_provenance` row per id |
| `chunk_uuid` | `semantic_chunk.chunk_id` (preserve on upsert) |
## Activate a new edition
```sql
BEGIN;
UPDATE knowledge.corpus_edition
SET status = 'superseded', effective_to = CURRENT_DATE
WHERE corpus_source_id = (SELECT corpus_source_id FROM knowledge.corpus_source WHERE book_id = 'mor')
AND status = 'active';
INSERT INTO knowledge.corpus_edition (corpus_source_id, edition_label, source_pdf_sha256, status, effective_from)
VALUES (
(SELECT corpus_source_id FROM knowledge.corpus_source WHERE book_id = 'mor'),
'v1',
'<sha256>',
'active',
CURRENT_DATE
);
COMMIT;
```
## References
- [`er_diagram.md`](er_diagram.md) — entity definitions and ER diagram
- [`../ingestion/schema.md`](../ingestion/schema.md) — SQLite checkpoint schema
- [`../../supabase/README.md`](../../supabase/README.md) — deploy instructions