Files
ai-video-fullstack/backend/services/knowledge.py
Xin Wang 7c9a18c806 Add knowledge retrieval configuration to Assistant model and related components
- Introduce new fields for knowledge retrieval configuration in AssistantConfig and Assistant models, including mode, top_n, and score_threshold.
- Implement KnowledgeRetrievalConfig schema with validation for top_n.
- Update backend services and routes to handle knowledge retrieval settings.
- Enhance frontend components to support knowledge retrieval configuration, including a new dialog for advanced settings.
- Add tests for knowledge retrieval configuration validation and description generation.
2026-07-12 18:57:56 +08:00

249 lines
9.1 KiB
Python

"""Minimal knowledge ingestion and retrieval, isolated from the voice pipeline."""
import asyncio
from io import BytesIO
from pathlib import Path
import re
import uuid
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError
from docx import Document as DocxDocument
from openai import AsyncOpenAI
from pypdf import PdfReader
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
import settings
from db.models import KnowledgeBase, KnowledgeChunk, KnowledgeDocument, ModelResource
def _s3_client():
return boto3.client(
"s3",
endpoint_url=settings.S3_ENDPOINT_URL,
aws_access_key_id=settings.S3_ACCESS_KEY,
aws_secret_access_key=settings.S3_SECRET_KEY,
region_name=settings.S3_REGION,
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
)
def _ensure_bucket_and_put(key: str, data: bytes, mime_type: str) -> None:
client = _s3_client()
try:
client.head_bucket(Bucket=settings.S3_BUCKET)
except ClientError as exc:
status = exc.response.get("ResponseMetadata", {}).get("HTTPStatusCode")
if status != 404:
raise
client.create_bucket(Bucket=settings.S3_BUCKET)
client.put_object(Bucket=settings.S3_BUCKET, Key=key, Body=data, ContentType=mime_type)
def _delete_object(key: str) -> None:
_s3_client().delete_object(Bucket=settings.S3_BUCKET, Key=key)
def _get_object(key: str) -> bytes:
response = _s3_client().get_object(Bucket=settings.S3_BUCKET, Key=key)
return response["Body"].read()
def extract_text(filename: str, data: bytes) -> str:
suffix = Path(filename).suffix.lower()
if suffix == ".pdf":
return "\n".join(page.extract_text() or "" for page in PdfReader(BytesIO(data)).pages)
if suffix == ".docx":
document = DocxDocument(BytesIO(data))
return "\n".join(paragraph.text for paragraph in document.paragraphs)
if suffix in {".txt", ".md", ".csv", ".json", ".html", ".htm"}:
return data.decode("utf-8", errors="replace")
raise ValueError("暂仅支持 PDF、DOCX、TXT、Markdown、CSV、JSON 和 HTML 文件")
def split_text(text: str, chunk_size: int = 800, overlap: int = 120) -> list[str]:
normalized = re.sub(r"[ \t]+", " ", text).strip()
if not normalized:
return []
chunks: list[str] = []
start = 0
while start < len(normalized):
end = min(start + chunk_size, len(normalized))
if end < len(normalized):
boundary = max(normalized.rfind("\n", start, end), normalized.rfind("", start, end))
if boundary > start + chunk_size // 2:
end = boundary + 1
chunks.append(normalized[start:end].strip())
if end >= len(normalized):
break
start = max(end - overlap, start + 1)
return [chunk for chunk in chunks if chunk]
async def _embedding_resource(session: AsyncSession, kb: KnowledgeBase) -> ModelResource:
resource = (
await session.get(ModelResource, kb.embedding_model_resource_id)
if kb.embedding_model_resource_id
else None
)
if not resource or resource.capability != "Embedding" or not resource.enabled:
raise ValueError("请先为知识库选择已启用的 Embedding 模型")
return resource
async def _embed(session: AsyncSession, kb: KnowledgeBase, texts: list[str]) -> list[list[float]]:
resource = await _embedding_resource(session, kb)
values, secrets = resource.values or {}, resource.secrets or {}
client = AsyncOpenAI(
api_key=str(secrets.get("apiKey") or ""),
base_url=str(values.get("apiUrl") or "") or None,
)
try:
response = await client.embeddings.create(
model=str(values.get("modelId") or "text-embedding-3-small"), input=texts
)
return [item.embedding for item in response.data]
finally:
await client.close()
async def create_document(
session: AsyncSession,
kb: KnowledgeBase,
*,
name: str,
source_type: str,
raw_data: bytes,
mime_type: str = "text/plain",
) -> KnowledgeDocument:
document_id = f"doc_{uuid.uuid4().hex[:12]}"
safe_name = Path(name).name
extension = ".txt" if source_type == "text" else Path(safe_name).suffix
storage_key = f"knowledge/{kb.id}/{document_id}/source{extension}"
await asyncio.to_thread(_ensure_bucket_and_put, storage_key, raw_data, mime_type)
document = KnowledgeDocument(
id=document_id,
knowledge_base_id=kb.id,
name=name,
source_type=source_type,
mime_type=mime_type,
storage_key=storage_key,
size_bytes=len(raw_data),
status="pending",
)
session.add(document)
await session.commit()
await session.refresh(document)
return document
async def process_document(document_id: str) -> None:
"""Process one persisted source. Safe to call again after a failure."""
from db.session import SessionLocal
async with SessionLocal() as session:
document = await session.get(KnowledgeDocument, document_id)
if not document:
return
kb = await session.get(KnowledgeBase, document.knowledge_base_id)
if not kb or not document.storage_key:
return
document.status = "processing"
document.error_message = ""
document.chunk_count = 0
await session.execute(delete(KnowledgeChunk).where(KnowledgeChunk.document_id == document.id))
await session.commit()
try:
data = await asyncio.to_thread(_get_object, document.storage_key)
text = (
data.decode("utf-8", errors="replace")
if document.source_type == "text"
else await asyncio.to_thread(extract_text, document.name, data)
)
chunks = split_text(text)
if not chunks:
raise ValueError("文档中没有可入库的文字")
embeddings: list[list[float]] = []
for start in range(0, len(chunks), 64):
embeddings.extend(await _embed(session, kb, chunks[start : start + 64]))
if len(embeddings) != len(chunks):
raise ValueError("Embedding 服务返回的向量数量与分块数量不一致")
for index, (content, embedding) in enumerate(zip(chunks, embeddings)):
session.add(KnowledgeChunk(
id=f"chunk_{uuid.uuid4().hex[:12]}", knowledge_base_id=kb.id,
document_id=document.id, chunk_index=index, content=content, embedding=embedding,
))
document.chunk_count = len(chunks)
document.status = "ready"
await session.commit()
except Exception as exc:
await session.rollback()
document = await session.get(KnowledgeDocument, document_id)
if document:
document.status = "failed"
document.error_message = str(exc)[:2048]
document.chunk_count = 0
await session.commit()
async def recover_interrupted_documents() -> None:
"""Make restart-interrupted work visible and retryable."""
from db.session import SessionLocal
async with SessionLocal() as session:
await session.execute(
update(KnowledgeDocument)
.where(KnowledgeDocument.status.in_(["pending", "processing"]))
.values(status="failed", error_message="服务重启导致处理被中断,请点击重试")
)
await session.commit()
async def search(
session: AsyncSession,
kb_id: str,
query: str,
top_k: int | None = None,
score_threshold: float = 0.0,
) -> list[dict]:
kb = await session.get(KnowledgeBase, kb_id)
if not kb:
return []
has_chunks = (await session.execute(
select(KnowledgeChunk.id)
.join(KnowledgeDocument, KnowledgeDocument.id == KnowledgeChunk.document_id)
.where(KnowledgeChunk.knowledge_base_id == kb_id, KnowledgeDocument.status == "ready")
.limit(1)
)).scalar_one_or_none()
if not has_chunks:
return []
query_embedding = (await _embed(session, kb, [query]))[0]
distance = KnowledgeChunk.embedding.cosine_distance(query_embedding)
statement = (
select(KnowledgeChunk, KnowledgeDocument.name, distance.label("distance"))
.join(KnowledgeDocument, KnowledgeDocument.id == KnowledgeChunk.document_id)
.where(
KnowledgeChunk.knowledge_base_id == kb_id,
KnowledgeDocument.status == "ready",
distance <= 1.0 - score_threshold,
)
.order_by(distance)
)
effective_top_k = settings.KNOWLEDGE_TOP_K if top_k is None else top_k
if effective_top_k != -1:
statement = statement.limit(effective_top_k)
rows = (await session.execute(statement)).all()
return [
{"content": chunk.content, "document": name, "score": round(max(0.0, 1.0 - float(dist)), 4)}
for chunk, name, dist in rows
]
async def delete_storage_object(document: KnowledgeDocument) -> None:
if document.storage_key:
await asyncio.to_thread(_delete_object, document.storage_key)