pdf-quiz-generator/backend/alembic/versions/x6d7e8f9a0b1_section_index.py
Daniel 4d0cdc8f2f feat: section-level index so retrieval can cite a section, not a whole article
An article embedded as a single vector, which finds the article but not the
paragraph — so a citation could only ever point at the top of a page. Sections
live in a JSON column and cannot carry a vector or a full-text index, so they are
now projected into `article_section_index`: one row per section with its own
embedding and weighted tsvector (migration x6d7e8f9a0b1).

- Rows are keyed by section id, so editing a section updates it, removing one
  deletes it, and an unchanged section is not re-embedded on every save.
- `article_section` joins the embeddable kinds, so the retry task, the full
  regeneration and the health report cover it without further changes.
- `hybrid_ids(db, query, "article_section")` searches it like any other corpus.

This is the groundwork for grouped search results (article, then the sections
that matched) and for AI citations that deep-link to the right section.

Tests: 113 backend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
2026-09-10 02:18:53 +02:00

42 lines
1.5 KiB
Python

"""Section-level index for articles, so a citation can point at the right part.
An article embeds as one vector, which is enough to find the article but not the
paragraph. Sections live in a JSON column, so they are projected into a derived
table that can carry its own vector and full-text index.
Revision ID: x6d7e8f9a0b1
Revises: w5c6d7e8f9a0
"""
from alembic import op
revision = "x6d7e8f9a0b1"
down_revision = "w5c6d7e8f9a0"
branch_labels = None
depends_on = None
def upgrade():
op.execute("""
CREATE TABLE IF NOT EXISTS article_section_index (
id SERIAL PRIMARY KEY,
article_id INTEGER NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
section_id VARCHAR(64) NOT NULL,
title TEXT,
content TEXT,
embedding vector(1024),
embedding_model VARCHAR(120),
embedded_at TIMESTAMP,
search_vector tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(content, '')), 'B')
) STORED,
CONSTRAINT uq_article_section_index UNIQUE (article_id, section_id)
)
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_asi_search ON article_section_index USING GIN (search_vector)")
op.execute("CREATE INDEX IF NOT EXISTS ix_asi_article ON article_section_index(article_id)")
op.execute("CREATE INDEX IF NOT EXISTS ix_asi_model ON article_section_index(embedding_model)")
def downgrade():
op.execute("DROP TABLE IF EXISTS article_section_index")