feat(sprint2.5): чистка чанков и переиндексация

Чанкер тащил в базу markdown-мусор: навигационные блоки «Вернуться на:»
со списками ссылок, инлайн-ссылки [текст](url) в теле, служебные
пометки _Источник: .../file.md_, лишние пустые строки. Всё это ело
контекст LLM и засоряло правую панель отладки.

- services/text_cleanup: clean_markdown_text — удаляет навигационные
  строки, строки-только-ссылки (обычно это меню), служебные _Источник:_,
  раскрывает инлайн-ссылки [x](url) → x, сжимает 3+ переносов до 2.
- services/document_processor: process_document теперь возвращает
  (id, raw_text, sections, chunks); чистку применяем к заголовкам и
  телам секций; чанки короче 20 символов выбрасываем с пересчётом
  индексов. Вспомогательная rechunk_raw_text — для переиндексации.

Чтобы переиндексировать без повторной загрузки файла, нужен исходный
текст. Вводим отдельный слой:
- новая таблица SQLite documents (id, name, file_type, raw_text,
  created_at, updated_at) + миграция Alembic 7ee7296ccd6d.
- db/models/Document + регистрация в db.models.__init__.
- services/document_service: save/get/list/delete для raw_text.
- routers/documents.upload: сохраняет raw_text в SQLite перед
  индексацией в Chroma; delete убирает и из SQLite, и из Chroma.
- Новые эндпоинты POST /documents/{id}/reindex и
  POST /documents/reindex-all — берут raw_text из SQLite, пропускают
  через rechunk_raw_text, заменяют чанки в Chroma.

Существующие 4 документа были перезалиты вручную (решение: не делать
одноразовый backfill, проще залить заново). Старая Chroma очищена,
новые чанки прошли через чистку — мусор ушёл.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
AR 15 M4
2026-04-23 11:15:08 +05:00
parent 4e45b8b181
commit e534a74460
7 changed files with 316 additions and 11 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
from db.models.document import Document
from db.models.message import Message
from db.models.thread import Thread
__all__ = ["Thread", "Message"]
__all__ = ["Thread", "Message", "Document"]
+24
View File
@@ -0,0 +1,24 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from db.base import Base
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
class Document(Base):
"""Исходный текст документа — для переиндексации с новыми правилами чанкера."""
__tablename__ = "documents"
id: Mapped[str] = mapped_column(String(36), primary_key=True) # UUID из process_document
name: Mapped[str] = mapped_column(String(500), nullable=False)
file_type: Mapped[str] = mapped_column(String(20), nullable=False)
raw_text: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow, nullable=False
)
@@ -0,0 +1,38 @@
"""add documents table for raw_text
Revision ID: 7ee7296ccd6d
Revises: e7199587be4b
Create Date: 2026-04-23 10:56:33.955742
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '7ee7296ccd6d'
down_revision: Union[str, None] = 'e7199587be4b'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('documents',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('name', sa.String(length=500), nullable=False),
sa.Column('file_type', sa.String(length=20), nullable=False),
sa.Column('raw_text', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('documents')
# ### end Alembic commands ###
+105 -4
View File
@@ -1,8 +1,10 @@
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from db.session import get_session
from models.responses import (
ChunkDetail,
ChunkPreview,
@@ -12,7 +14,8 @@ from models.responses import (
DocumentListResponse,
DocumentUploadResponse,
)
from services.document_processor import process_document
from services import document_service
from services.document_processor import process_document, rechunk_raw_text
logger = logging.getLogger(__name__)
@@ -26,6 +29,7 @@ MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
async def upload_document(
file: UploadFile = File(...),
document_name: str | None = Form(None),
session: AsyncSession = Depends(get_session),
):
from main import vectorstore_service
@@ -48,7 +52,7 @@ async def upload_document(
display_name = document_name or filename
try:
document_id, sections, chunks = process_document(file_bytes, filename)
document_id, raw_text, sections, chunks = process_document(file_bytes, filename)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -59,6 +63,13 @@ async def upload_document(
raise HTTPException(status_code=400, detail="No content could be extracted from the document")
file_type = ext.lstrip(".")
await document_service.save_document_raw(
session=session,
document_id=document_id,
name=display_name,
file_type=file_type,
raw_text=raw_text,
)
chunks_count = vectorstore_service.add_document(
document_id=document_id,
document_name=display_name,
@@ -144,14 +155,104 @@ async def get_document_chunks(document_id: str):
@router.delete("/{document_id}", response_model=DocumentDeleteResponse)
async def delete_document(document_id: str):
async def delete_document(document_id: str, session: AsyncSession = Depends(get_session)):
from main import vectorstore_service
if vectorstore_service is None:
raise HTTPException(status_code=503, detail="Service not ready")
deleted = vectorstore_service.delete_document(document_id)
await document_service.delete_document_raw(session, document_id)
if deleted == 0:
raise HTTPException(status_code=404, detail="Document not found")
return DocumentDeleteResponse(ok=True, deleted_chunks=deleted)
@router.post("/{document_id}/reindex", response_model=DocumentUploadResponse)
async def reindex_document(document_id: str, session: AsyncSession = Depends(get_session)):
"""Переразметить документ с актуальными правилами чанкера на основе сохранённого raw_text."""
from main import vectorstore_service
if vectorstore_service is None:
raise HTTPException(status_code=503, detail="Service not ready")
doc = await document_service.get_document_raw(session, document_id)
if doc is None:
raise HTTPException(status_code=404, detail="Document raw_text not found — reindex невозможен")
chunks = rechunk_raw_text(doc.raw_text)
if not chunks:
raise HTTPException(status_code=400, detail="После переразметки не осталось чанков")
vectorstore_service.delete_document(document_id)
chunks_count = vectorstore_service.add_document(
document_id=document_id,
document_name=doc.name,
file_type=doc.file_type,
chunks=[
{
"text": c.text,
"section": c.section,
"page_number": c.page_number,
"chunk_index": c.chunk_index,
}
for c in chunks
],
)
chunks_prev = [
ChunkPreview(
index=c.chunk_index,
section=c.section,
page_number=c.page_number,
text_preview=c.text[:300],
char_length=len(c.text),
)
for c in chunks[:3]
]
return DocumentUploadResponse(
document_id=document_id,
name=doc.name,
chunks_count=chunks_count,
status="reindexed",
created_at=datetime.now(timezone.utc).isoformat(),
chunks_preview=chunks_prev,
)
@router.post("/reindex-all")
async def reindex_all(session: AsyncSession = Depends(get_session)):
"""Переразметить все документы, у которых есть raw_text в SQLite."""
from main import vectorstore_service
if vectorstore_service is None:
raise HTTPException(status_code=503, detail="Service not ready")
docs = await document_service.list_documents_raw(session)
results = []
for doc in docs:
chunks = rechunk_raw_text(doc.raw_text)
if not chunks:
results.append({"document_id": doc.id, "name": doc.name, "status": "empty"})
continue
vectorstore_service.delete_document(doc.id)
n = vectorstore_service.add_document(
document_id=doc.id,
document_name=doc.name,
file_type=doc.file_type,
chunks=[
{
"text": c.text,
"section": c.section,
"page_number": c.page_number,
"chunk_index": c.chunk_index,
}
for c in chunks
],
)
results.append({"document_id": doc.id, "name": doc.name, "status": "reindexed", "chunks_count": n})
return {"total": len(results), "results": results}
+58 -6
View File
@@ -9,6 +9,9 @@ import fitz # pymupdf
from docx import Document as DocxDocument
from config import settings
from services.text_cleanup import clean_markdown_text
MIN_CHUNK_TEXT_LENGTH = 20 # чанки короче — выбрасываем (обычно это хвосты после чистки)
logger = logging.getLogger(__name__)
@@ -276,25 +279,74 @@ def chunk_sections(
# --- Main processor ---
def process_document(file_bytes: bytes, filename: str) -> tuple[str, list[ParsedSection], list[Chunk]]:
def _sections_to_markdown(sections: list[ParsedSection]) -> str:
"""Собрать секции в markdown-подобный текст — используется как raw_text для PDF/DOCX,
чтобы при переиндексации можно было снова пропустить через parse_text."""
parts = []
for s in sections:
if s.heading:
parts.append(f"{'#' * max(1, s.heading_level)} {s.heading}")
if s.body:
parts.append(s.body)
return "\n\n".join(parts).strip()
def process_document(
file_bytes: bytes, filename: str
) -> tuple[str, str, list[ParsedSection], list[Chunk]]:
"""Парсит документ, чистит markdown-мусор, режет на чанки.
Returns: (document_id, raw_text, sections, chunks)
raw_text — очищенный текст, пригодный для переиндексации с новыми правилами.
"""
document_id = str(uuid.uuid4())
ext = Path(filename).suffix.lower()
if ext == ".pdf":
sections = parse_pdf(file_bytes)
raw_text = _sections_to_markdown(sections)
elif ext in (".docx", ".doc"):
sections = parse_docx(file_bytes)
raw_text = _sections_to_markdown(sections)
elif ext == ".md":
sections = parse_text(file_bytes, is_markdown=True)
raw_text = file_bytes.decode("utf-8", errors="replace")
cleaned = clean_markdown_text(raw_text)
sections = parse_text(cleaned.encode("utf-8"), is_markdown=True)
elif ext == ".txt":
sections = parse_text(file_bytes, is_markdown=False)
raw_text = file_bytes.decode("utf-8", errors="replace")
sections = parse_text(raw_text.encode("utf-8"), is_markdown=False)
else:
raise ValueError(f"Unsupported file format: {ext}")
# Страховка — чистим секции, даже если в исходнике уже очищали.
for s in sections:
s.heading = clean_markdown_text(s.heading) if s.heading else ""
s.body = clean_markdown_text(s.body)
sections = [s for s in sections if s.heading or s.body.strip()]
if not sections:
logger.warning("No sections found in %s", filename)
return document_id, [], []
return document_id, raw_text, [], []
chunks = chunk_sections(sections)
logger.info("Processed '%s': %d sections → %d chunks", filename, len(sections), len(chunks))
return document_id, sections, chunks
# Отбрасываем пустые и совсем мелкие хвосты; переиндексируем.
chunks = [c for c in chunks if len(c.text.strip()) >= MIN_CHUNK_TEXT_LENGTH]
for i, c in enumerate(chunks):
c.chunk_index = i
logger.info("Processed '%s': %d sections → %d chunks (cleaned)", filename, len(sections), len(chunks))
return document_id, raw_text, sections, chunks
def rechunk_raw_text(raw_text: str) -> list[Chunk]:
"""Для переиндексации: режем сохранённый текст с актуальными правилами чистки."""
cleaned = clean_markdown_text(raw_text)
sections = parse_text(cleaned.encode("utf-8"), is_markdown=True)
for s in sections:
s.heading = clean_markdown_text(s.heading) if s.heading else ""
s.body = clean_markdown_text(s.body)
sections = [s for s in sections if s.heading or s.body.strip()]
chunks = chunk_sections(sections)
chunks = [c for c in chunks if len(c.text.strip()) >= MIN_CHUNK_TEXT_LENGTH]
for i, c in enumerate(chunks):
c.chunk_index = i
return chunks
+50
View File
@@ -0,0 +1,50 @@
"""SQLite-слой для raw-текстов документов — для переиндексации."""
import logging
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import Document
logger = logging.getLogger(__name__)
async def save_document_raw(
session: AsyncSession,
document_id: str,
name: str,
file_type: str,
raw_text: str,
) -> None:
"""Сохранить (или перезаписать) исходный текст документа в SQLite."""
existing = await session.get(Document, document_id)
if existing:
existing.name = name
existing.file_type = file_type
existing.raw_text = raw_text
else:
session.add(Document(
id=document_id,
name=name,
file_type=file_type,
raw_text=raw_text,
))
await session.commit()
async def get_document_raw(session: AsyncSession, document_id: str) -> Document | None:
return await session.get(Document, document_id)
async def list_documents_raw(session: AsyncSession) -> list[Document]:
stmt = select(Document).order_by(Document.created_at)
return list((await session.execute(stmt)).scalars().all())
async def delete_document_raw(session: AsyncSession, document_id: str) -> bool:
doc = await session.get(Document, document_id)
if doc is None:
return False
await session.delete(doc)
await session.commit()
return True
+39
View File
@@ -0,0 +1,39 @@
"""Чистка wiki-текстов от навигационного и служебного markdown-мусора."""
import re
RETURN_TO_RE = re.compile(r"^\*\*\s*Вернуться на\s*:?\s*\*\*\s*:?$")
LINK_ONLY_RE = re.compile(r"^\[[^\]\n]+\]\([^\)\n]+\)$")
SOURCE_NOTE_RE = re.compile(r"^_Источник\s*:.*_$")
INLINE_LINK_RE = re.compile(r"\[([^\]\n]+)\]\([^\)\n]+\)")
MULTI_BLANK_RE = re.compile(r"\n{3,}")
def clean_markdown_text(text: str) -> str:
"""Удаляет навигационный мусор и раскрывает инлайн-ссылки.
Правила:
- Строка `**Вернуться на:**` — выбрасывается.
- Строка, целиком состоящая из markdown-ссылки `[x](url)` — выбрасывается (это навигация).
- Строка `_Источник: .../file.md_` — выбрасывается.
- Инлайн-ссылки в теле `[текст](url)` заменяются на `текст`.
- 3+ подряд переносов строк сжимаются до 2.
"""
if not text:
return ""
lines = text.split("\n")
cleaned_lines = []
for line in lines:
stripped = line.strip()
if RETURN_TO_RE.match(stripped):
continue
if LINK_ONLY_RE.match(stripped):
continue
if SOURCE_NOTE_RE.match(stripped):
continue
cleaned_lines.append(line)
text = "\n".join(cleaned_lines)
text = INLINE_LINK_RE.sub(r"\1", text)
text = MULTI_BLANK_RE.sub("\n\n", text)
return text.strip()