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
+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}