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:
parent
587b23db8f
commit
4d0cdc8f2f
5 changed files with 104 additions and 6 deletions
42
backend/alembic/versions/x6d7e8f9a0b1_section_index.py
Normal file
42
backend/alembic/versions/x6d7e8f9a0b1_section_index.py
Normal 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")
|
||||||
|
|
@ -56,3 +56,20 @@ class ArticleView(Base):
|
||||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
article_id = Column(Integer, ForeignKey("articles.id", ondelete="CASCADE"), nullable=False)
|
article_id = Column(Integer, ForeignKey("articles.id", ondelete="CASCADE"), nullable=False)
|
||||||
viewed_at = Column(DateTime, default=datetime.utcnow)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.services.search_service import hybrid_ids
|
from app.services.search_service import hybrid_ids
|
||||||
from app.services import embedding_service
|
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.flashcard import Flashcard, FlashcardDeck, FlashcardArticleLink
|
||||||
from app.models.question import Question
|
from app.models.question import Question
|
||||||
from app.models.section import Section
|
from app.models.section import Section
|
||||||
|
|
@ -163,15 +163,51 @@ def _record_view(db, user, article) -> None:
|
||||||
|
|
||||||
|
|
||||||
def _reembed(db, 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:
|
try:
|
||||||
if embedding_service.embed_record(article, "article"):
|
embedding_service.embed_record(article, "article")
|
||||||
db.commit()
|
_rebuild_section_index(db, article)
|
||||||
|
db.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
log.warning("Could not embed article %s; leaving it for the retry task", article.id, exc_info=True)
|
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("/")
|
@router.get("/")
|
||||||
def list_articles(
|
def list_articles(
|
||||||
category_id: int | None = Query(None),
|
category_id: int | None = Query(None),
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,7 @@ EMBEDDABLE = {
|
||||||
"question": lambda row: _join(row.question_text, *(row.options or [])),
|
"question": lambda row: _join(row.question_text, *(row.options or [])),
|
||||||
"article": lambda row: _join(row.title, row.summary, row.content),
|
"article": lambda row: _join(row.title, row.summary, row.content),
|
||||||
"flashcard": lambda row: _join(row.front, row.back),
|
"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:
|
def embeddable_models() -> dict:
|
||||||
"""The ORM class behind each embeddable kind."""
|
"""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.flashcard import Flashcard
|
||||||
from app.models.question import Question
|
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:
|
def stale_embedding_counts(db) -> dict:
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ CORPORA = {
|
||||||
"question": ("questions", ("question_text", "CAST(options AS TEXT)")),
|
"question": ("questions", ("question_text", "CAST(options AS TEXT)")),
|
||||||
"article": ("articles", ("title", "summary", "content")),
|
"article": ("articles", ("title", "summary", "content")),
|
||||||
"flashcard": ("flashcards", ("front", "back")),
|
"flashcard": ("flashcards", ("front", "back")),
|
||||||
|
"article_section": ("article_section_index", ("title", "content")),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue