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
+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()