Files
border-red-post-nalati/backend/app/services/indexing.py
T

148 lines
5.4 KiB
Python

"""Bounded chunk snapshots and serialized, optimistic publication."""
import hashlib
from uuid import UUID, uuid4, uuid5
from fastapi import Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.catalog import utc_now
from app.models.indexing import Chunk, IndexPublication, IndexState, IndexVersion
from app.schemas.indexing import ActiveIndex, BuildIndex, IndexRead
from app.services.retrieval import corpus_fingerprint, load_corpus
MAX_CHUNKS = 20_000
def locked_state(db: Session) -> IndexState:
state = db.scalar(
select(IndexState)
.where(IndexState.id == 1)
.with_for_update()
.execution_options(populate_existing=True)
)
if state is None:
raise HTTPException(503, "索引状态尚未初始化,请检查数据库迁移")
return state
def catalog_write_guard(request: Request, db: Session = Depends(get_db)):
if request.method in {"POST", "PATCH", "PUT", "DELETE"}:
# Always before catalog/parent row locks; failures roll back this revision as well.
state = locked_state(db)
state.catalog_revision += 1
def version_or_404(db: Session, version_id: str) -> IndexVersion:
version = db.get(IndexVersion, version_id)
if version is None:
raise HTTPException(404, "索引版本不存在")
return version
def split_text(content: str, size: int, overlap: int):
"""Offsets are original Python Unicode character offsets, end-exclusive."""
for start in range(0, len(content), size - overlap):
end = min(len(content), start + size)
yield start, end, content[start:end]
if end == len(content):
break
def build_index(db: Session, payload: BuildIndex, actor_id: int) -> IndexRead:
state = locked_state(db)
corpus = load_corpus(db, None)
version_id = uuid4()
chunks = []
for item in corpus:
provenance = {key: value for key, value in item.items() if key != "content"}
for ordinal, (start, end, content) in enumerate(
split_text(item["content"], payload.chunk_size, payload.overlap)
):
if len(chunks) >= MAX_CHUNKS:
raise HTTPException(503, "切分数量超过本阶段容量,请增加切分长度或减少重叠")
chunks.append(
Chunk(
id=str(uuid5(version_id, f"{item['knowledge_id']}:{ordinal}")),
index_version_id=str(version_id),
knowledge_id=item["knowledge_id"],
ordinal=ordinal,
start_offset=start,
end_offset=end,
text=content,
text_sha256=hashlib.sha256(content.encode()).hexdigest(),
provenance=provenance,
)
)
version = IndexVersion(
id=str(version_id),
status="ready",
config={"splitter": "unicode-window-v1", **payload.model_dump()},
corpus_fingerprint=corpus_fingerprint(corpus),
catalog_revision=state.catalog_revision,
item_count=len(corpus),
chunk_count=len(chunks),
created_by=actor_id,
)
db.add(version)
db.flush()
db.add_all(chunks)
result = IndexRead.model_validate(version)
db.commit()
return result
def active_index(db: Session, state: IndexState | None = None) -> ActiveIndex:
state = state or locked_state(db)
version = db.get(IndexVersion, state.active_version_id) if state.active_version_id else None
current = bool(version and version.catalog_revision == state.catalog_revision)
if current:
current = version.corpus_fingerprint == corpus_fingerprint(load_corpus(db, None))
return ActiveIndex(
generation=state.generation,
catalog_revision=state.catalog_revision,
is_current=current,
active_version=IndexRead.model_validate(version) if version else None,
)
def publish_index(db: Session, version_id: UUID, expected_generation: int, actor_id: int):
state = locked_state(db)
if state.generation != expected_generation:
raise HTTPException(409, "已发布版本发生变化,请刷新后重试")
version = version_or_404(db, str(version_id))
if (
version.catalog_revision != state.catalog_revision
or version.corpus_fingerprint != corpus_fingerprint(load_corpus(db, None))
):
raise HTTPException(409, "资料或审核依据已变化,请重新构建后发布")
if state.active_version_id == version.id:
return active_index(db, state)
previous = state.active_version_id
if previous:
db.get(IndexVersion, previous).status = "superseded"
state.active_version_id = version.id
state.generation += 1
version.status = "published"
version.published_at = utc_now()
db.add(
IndexPublication(
generation=state.generation,
previous_version_id=previous,
version_id=version.id,
actor_id=actor_id,
published_at=version.published_at,
)
)
# Freeze the response before commit expires ORM objects and releases the lock.
result = ActiveIndex(
generation=expected_generation + 1,
catalog_revision=version.catalog_revision,
is_current=True,
active_version=IndexRead.model_validate(version),
)
db.commit()
return result