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
This commit is contained in:
Daniel 2026-09-10 02:18:53 +02:00
parent 587b23db8f
commit 4d0cdc8f2f
5 changed files with 104 additions and 6 deletions

View file

@ -0,0 +1,42 @@
"""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")

View file

@ -56,3 +56,20 @@ class ArticleView(Base):
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
article_id = Column(Integer, ForeignKey("articles.id", ondelete="CASCADE"), nullable=False)
viewed_at = Column(DateTime, default=datetime.utcnow)
class ArticleSectionIndex(Base, Embeddable):
"""One row per article section, so retrieval can cite a section not a whole article.
Sections are stored in `Article.sections` as JSON; this is a projection of
them, rebuilt whenever the article is saved.
"""
__tablename__ = "article_section_index"
__table_args__ = (UniqueConstraint("article_id", "section_id", name="uq_article_section_index"),)
id = Column(Integer, primary_key=True, index=True)
article_id = Column(Integer, ForeignKey("articles.id", ondelete="CASCADE"), nullable=False, index=True)
section_id = Column(String(64), nullable=False)
title = Column(Text, nullable=True)
content = Column(Text, nullable=True)

View file

@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
from app.database import get_db
from app.services.search_service import hybrid_ids
from app.services import embedding_service
from app.models.article import Article, ArticleView, QuestionArticleLink
from app.models.article import Article, ArticleSectionIndex, ArticleView, QuestionArticleLink
from app.models.flashcard import Flashcard, FlashcardDeck, FlashcardArticleLink
from app.models.question import Question
from app.models.section import Section
@ -163,15 +163,51 @@ def _record_view(db, user, article) -> None:
def _reembed(db, article) -> None:
"""Embed on write. A failure is not fatal — the retry task sweeps it up."""
"""Embed the article and reproject its sections. Failures wait for the retry task."""
try:
if embedding_service.embed_record(article, "article"):
db.commit()
embedding_service.embed_record(article, "article")
_rebuild_section_index(db, article)
db.commit()
except Exception:
db.rollback()
log.warning("Could not embed article %s; leaving it for the retry task", article.id, exc_info=True)
def _rebuild_section_index(db, article) -> None:
"""Mirror the article's JSON sections into their own searchable rows.
Sections live in a JSON column, so they cannot carry a vector or a full-text
index themselves. Projecting them lets a citation point at the right section
rather than the whole article. Rows are keyed by section id, so editing a
section updates it and removing one deletes it.
"""
sections = article.sections or []
keep = set()
for section in sections:
section_id = section.get("id")
if not section_id:
continue
keep.add(section_id)
row = db.query(ArticleSectionIndex).filter_by(
article_id=article.id, section_id=section_id).first()
text_changed = True
if row is None:
row = ArticleSectionIndex(article_id=article.id, section_id=section_id)
db.add(row)
else:
text_changed = (row.title != section.get("title")) or (row.content != section.get("content"))
row.title = section.get("title")
row.content = section.get("content")
# Only pay for an embedding when the text actually changed.
if text_changed or row.embedding is None:
embedding_service.embed_record(row, "article_section")
stale = db.query(ArticleSectionIndex).filter(ArticleSectionIndex.article_id == article.id)
if keep:
stale = stale.filter(~ArticleSectionIndex.section_id.in_(keep))
stale.delete(synchronize_session=False)
@router.get("/")
def list_articles(
category_id: int | None = Query(None),

View file

@ -111,6 +111,7 @@ EMBEDDABLE = {
"question": lambda row: _join(row.question_text, *(row.options or [])),
"article": lambda row: _join(row.title, row.summary, row.content),
"flashcard": lambda row: _join(row.front, row.back),
"article_section": lambda row: _join(row.title, row.content),
}
@ -149,11 +150,12 @@ def embed_question(question) -> bool:
def embeddable_models() -> dict:
"""The ORM class behind each embeddable kind."""
from app.models.article import Article
from app.models.article import Article, ArticleSectionIndex
from app.models.flashcard import Flashcard
from app.models.question import Question
return {"question": Question, "article": Article, "flashcard": Flashcard}
return {"question": Question, "article": Article, "flashcard": Flashcard,
"article_section": ArticleSectionIndex}
def stale_embedding_counts(db) -> dict:

View file

@ -77,6 +77,7 @@ CORPORA = {
"question": ("questions", ("question_text", "CAST(options AS TEXT)")),
"article": ("articles", ("title", "summary", "content")),
"flashcard": ("flashcards", ("front", "back")),
"article_section": ("article_section_index", ("title", "content")),
}