- 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.
249 lines
9.1 KiB
Python
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)
|