Files

1558 lines
80 KiB
Python
Raw Permalink Normal View History

"""RAG 管道:素材导入、增量重建与检索。"""
import shutil
import hashlib
import uuid
import logging
import threading
import time
import os
import re
import unicodedata
from collections import deque
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from sqlmodel import Session, select
from sqlalchemy import text, or_
from ..config import KB_DIR
from ..models import Campaign, KnowledgeBase, KnowledgeUsage, KnowledgeCategory, KnowledgeFile, now
from ..billing import estimate_tokens, record_usage_event
from . import backend, parse, chunk, embed, store, dedup
from . import hybrid
from .quarantine import file_exclusions
from .context import source_excerpt
from .. import perf_metrics
logger = logging.getLogger("yawen.rag.pipeline")
# Reuse a bounded set of recall workers across requests. Creating a fresh
# executor for every multi-library query defeated thread-local SQLite/FTS
# reuse and could create an unbounded number of threads under concurrent load.
# The queue is intentionally bounded by the executor's fixed worker count;
# callers still get the same per-request fan-out semantics.
_recall_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="rag-recall")
_PREWARM_LOCK = threading.Lock()
_PREWARMING: set[int] = set()
# A bounded, process-local rolling window keeps retrieval latency observable
# without adding a database write to every query. The worker process logs a
# compact P50/P95 summary periodically; callers still receive per-query phase
# timings in ``retrieval_timings`` below.
_RETRIEVAL_METRICS_LOCK = threading.Lock()
_RETRIEVAL_METRICS: deque[dict[str, float | int | bool]] = deque(maxlen=512)
_RETRIEVAL_METRICS_LOG_EVERY = 25
_LAST_RETRIEVAL_METRIC: dict | None = None
def _record_query_embedding_usage(*, idempotency_key: str, user_id: int,
organization_id: int, campaign_id: int,
trace_id: str, model: str, quantity: float,
metadata: dict) -> None:
"""Write embedding telemetry in its own short transaction.
Retrieval is read-heavy and can fan out across several content tasks. A
usage event flushed through the caller's long-lived transaction made those
readers compete for SQLite's single writer and surfaced ``database is
locked`` as a false retrieval failure. The event is accounting metadata;
it must never make a successful retrieval fail.
"""
try:
from ..db import engine
with Session(engine) as usage_session:
# Telemetry must never wait for the full 30 s application timeout
# behind a content-task write transaction. If SQLite is busy,
# skip this non-critical event quickly and keep retrieval usable.
usage_session.connection().exec_driver_sql("PRAGMA busy_timeout=500")
record_usage_event(
usage_session, idempotency_key=idempotency_key,
user_id=user_id, organization_id=organization_id,
campaign_id=campaign_id, trace_id=trace_id, model=model,
service_type="embedding", unit="token", quantity=quantity,
status="estimated", metadata=metadata)
usage_session.commit()
except Exception as exc: # pragma: no cover - telemetry must be best effort
logger.warning("Embedding 用量记录失败(不阻断知识库检索):%s", exc)
def retrieval_metrics_snapshot() -> dict:
"""Return process-local retrieval latency percentiles for diagnostics.
This is intentionally read-only and bounded. It is safe to expose from a
diagnostics endpoint later, while keeping the search API unchanged.
"""
with _RETRIEVAL_METRICS_LOCK:
rows = list(_RETRIEVAL_METRICS)
if not rows:
return {"samples": 0, "p50_ms": 0.0, "p95_ms": 0.0,
"cache_hit_rate": 0.0, "phases": {}}
def percentile(values: list[float], fraction: float) -> float:
values = sorted(values)
if not values:
return 0.0
index = min(len(values) - 1, max(0, int(round((len(values) - 1) * fraction))))
return round(values[index], 2)
total = [float(row.get("total_ms", 0.0)) for row in rows]
phase_names = ("validation_ms", "embedding_ms", "recall_ms", "postprocess_ms")
phases = {name: {"p50_ms": percentile([float(row.get(name, 0.0)) for row in rows], 0.5),
"p95_ms": percentile([float(row.get(name, 0.0)) for row in rows], 0.95)}
for name in phase_names}
return {"samples": len(rows), "p50_ms": percentile(total, 0.5),
"p95_ms": percentile(total, 0.95),
"cache_hit_rate": round(sum(bool(row.get("cache_hit")) for row in rows) / len(rows), 4),
"phases": phases}
def _record_retrieval_metrics(timings: dict, result_count: int) -> None:
"""Record a bounded sample and emit a periodic percentile summary."""
global _RETRIEVAL_METRICS_LOG_EVERY, _LAST_RETRIEVAL_METRIC
_LAST_RETRIEVAL_METRIC = {**timings, "result_count": int(result_count)}
perf_metrics.ingest_rag(_LAST_RETRIEVAL_METRIC)
with _RETRIEVAL_METRICS_LOCK:
_RETRIEVAL_METRICS.append({**timings, "result_count": int(result_count)})
count = len(_RETRIEVAL_METRICS)
should_log = count == 1 or count % _RETRIEVAL_METRICS_LOG_EVERY == 0
if should_log:
summary = retrieval_metrics_snapshot()
# ``rag_worker`` deliberately has no logging configuration because its
# stdout is a JSON protocol. WARNING is handled by Python's fallback
# logger, so the summary remains visible in rag-worker.log without
# touching that protocol or requiring a global logging setup.
logger.warning("RAG检索指标:样本=%s,P50=%.2fms,P95=%.2fms,缓存命中率=%.1f%%,阶段=%s",
summary["samples"], summary["p50_ms"], summary["p95_ms"],
summary["cache_hit_rate"] * 100, summary["phases"])
def last_retrieval_metric() -> dict:
return dict(_LAST_RETRIEVAL_METRIC or {})
def _schedule_lexical_prewarm(kb_id: int, organization_id: int = 1) -> None:
"""Warm the default full-library FTS scope after a successful import.
FTS construction can scan a large library, so it must never delay the
upload response or the first user query. The hybrid module's file lock
makes retries and multiple API workers safe.
"""
kid = int(kb_id)
with _PREWARM_LOCK:
if kid in _PREWARMING:
return
_PREWARMING.add(kid)
def run() -> None:
started = __import__("time").perf_counter()
try:
from . import hybrid
hybrid.prewarm_index(int(organization_id or 1), kid)
logger.info("知识库全文索引已后台预热:知识库 %s,%.3f 秒", kid,
__import__("time").perf_counter() - started)
except Exception as exc:
# Vector import has already succeeded; a failed warmup only means
# the next query will retry it under the normal index lock.
logger.warning("知识库全文索引预热失败:知识库 %s,原因:%s", kid, exc)
finally:
with _PREWARM_LOCK:
_PREWARMING.discard(kid)
threading.Thread(target=run, name=f"rag-prewarm-{kid}", daemon=True).start()
SUPPORTED_SUFFIXES = {".doc", ".docx", ".srt", ".md", ".txt", ".json", ".jsonl", ".pdf"}
# 音视频后缀(whisper 本地转写)
MEDIA_SUFFIXES = {".mp4", ".mov", ".m4a", ".mp3", ".wav", ".flv", ".mkv", ".avi", ".webm", ".aac", ".ogg", ".flac"}
def _source_dir(kb: KnowledgeBase) -> Path:
return Path(kb.source_dir) if kb.source_dir else KB_DIR / f"{kb.id}_{kb.name}"
def _disk_files(source: Path) -> list[Path]:
if not source.exists() or not source.is_dir():
return []
return sorted(path for path in source.iterdir()
if path.is_file() and path.suffix.lower() in SUPPORTED_SUFFIXES | MEDIA_SUFFIXES)
def reconcile_kb_files(session: Session, kb_id: int | None = None) -> dict:
"""启动时对账物理文件、文件表和向量计数,不自动重建大库。"""
query = select(KnowledgeBase)
if kb_id is not None:
query = query.where(KnowledgeBase.id == kb_id)
repaired = []
for kb in session.exec(query).all():
source = _source_dir(kb)
files = _disk_files(source)
known = {item.file_name: item for item in session.exec(
select(KnowledgeFile).where(KnowledgeFile.kb_id == kb.id)
).all()}
for path in files:
digest = dedup.file_hash(path)
record = known.get(path.name)
if record is None:
record = KnowledgeFile(kb_id=kb.id, file_name=path.name,
content_hash=digest, revision=1)
elif not record.content_hash:
# 旧数据首次补指纹时不把未知历史误报为内容变更。
record.content_hash = digest
record.revision = max(1, int(record.revision or 1))
elif record.content_hash != digest:
record.content_hash = digest
record.revision = max(1, int(record.revision or 1)) + 1
session.add(record)
try:
chunk_count = store.count(kb.id)
except Exception:
chunk_count = 0
registered_count = len(known) or len(session.exec(
select(KnowledgeFile).where(KnowledgeFile.kb_id == kb.id)
).all())
kb.file_count = len(files)
kb.chunk_count = chunk_count
if backend.is_disabled():
kb.index_status = "disabled" if files else "empty"
elif files and chunk_count == 0:
kb.index_status = "needs_reindex"
elif not files and registered_count:
kb.index_status = "error"
elif not files:
kb.index_status = "empty"
else:
kb.index_status = "ready"
session.add(kb)
repaired.append({"kb_id": kb.id, "files": len(files), "chunks": chunk_count,
"needs_reindex": bool(files and chunk_count == 0)})
session.commit()
return {"kbs": repaired}
def category_path(session: Session, category_id: int | None) -> str:
"""把分类节点解析成“父级 / 子级”路径,写入每个向量的元数据。"""
if not category_id:
return "未分类"
names = []
seen = set()
current = session.get(KnowledgeCategory, category_id)
while current and current.id not in seen:
seen.add(current.id)
names.append(current.name)
current = session.get(KnowledgeCategory, current.parent_id) if current.parent_id else None
return " / ".join(reversed(names)) or "未分类"
def _parse_source(path: Path, kb: KnowledgeBase, progress_cb=None) -> tuple[list[dict], int]:
if path.suffix.lower() in MEDIA_SUFFIXES:
from . import media as media_mod
if progress_cb:
progress_cb(f"转写 {path.name}(本地 whisper,长音频需几分钟)", -1)
return media_mod.transcribe_media(path), 2200
configured = int(getattr(kb, "chunk_target", 0) or 0)
target = configured or (2200 if path.suffix.lower() == ".srt" else chunk.TARGET)
return parse.parse_file(path), target
def _indexed_sources(session: Session, kb: KnowledgeBase, source: Path, cache) -> list[dict]:
"""只将原文件和索引版本一致、且实际有块的资料作为跳过上传的依据。"""
counts = store.count_by_file(kb.id)
if not counts:
return []
first_meta, legacy_texts, versions = {}, {}, {}
for item in store.iter_chunks(kb.id):
meta = item.get("meta") or {}
name = meta.get("file")
if not name:
continue
first_meta.setdefault(name, meta)
versions.setdefault(name, set()).add(str(meta.get("file_hash") or ""))
if not meta.get("file_hash") or not meta.get("file_chunk_count"):
legacy_texts.setdefault(name, []).append(
(int(meta.get("seq", len(legacy_texts.get(name, [])))),
(dedup.normalized_text(meta.get("heading", "")), dedup.normalized_text(item.get("text", "")))))
candidates = []
for record in session.exec(select(KnowledgeFile).where(KnowledgeFile.kb_id == kb.id)).all():
path = source / record.file_name
if not counts.get(record.file_name) or not path.is_file():
continue
raw_hash = dedup.file_hash(path)
meta = first_meta.get(record.file_name, {})
if meta.get("file_hash") and versions.get(record.file_name) != {raw_hash}:
continue
if meta.get("file_chunk_count") and int(meta["file_chunk_count"]) != counts.get(record.file_name):
continue
body_hash = cache.get(raw_hash)
if not meta.get("file_hash") or not meta.get("file_chunk_count"):
# 没有版本指纹的旧索引必须与当前原文逐块核对,不能只看文件表。
if path.suffix.lower() in MEDIA_SUFFIXES:
continue
units, target = _parse_source(path, kb)
raw_chunks = chunk.chunk_texts(units, target=target)
unique, _ = dedup.unique_chunks(raw_chunks)
def canonical(items):
return [(dedup.normalized_text((item.get("meta") or {}).get("heading", "")),
dedup.normalized_text(item["text"])) for item in items]
expected = canonical(raw_chunks)
indexed = [text for _, text in sorted(legacy_texts.get(record.file_name, []))]
if expected != indexed and canonical(unique) != indexed:
continue
body_hash = dedup.document_hash(units)
cache.set(raw_hash, body_hash)
candidates.append({"file_name": record.file_name, "raw_hash": raw_hash,
"body_hash": body_hash, "path": path})
return sorted(candidates, key=lambda item: item["file_name"])
def _duplicate_of(candidates: list[dict], raw_hash: str, body_hash: str = "") -> str:
for candidate in candidates:
if candidate["raw_hash"] == raw_hash or (body_hash and candidate["body_hash"] == body_hash):
return candidate["file_name"]
return ""
def _fill_body_hashes(candidates: list[dict], kb: KnowledgeBase, cache):
for candidate in candidates:
if candidate["body_hash"]:
continue
# 相同音视频先按文件字节去重;不同媒体不为去重而重复转写。
if candidate["path"].suffix.lower() in MEDIA_SUFFIXES:
continue
units, _ = _parse_source(candidate["path"], kb)
candidate["body_hash"] = dedup.document_hash(units)
cache.set(candidate["raw_hash"], candidate["body_hash"])
def _store_usage(session, kb, path, file_hash, revision, chunks, operation):
record_usage_event(
session, idempotency_key=f"storage:kb:{kb.id}:{file_hash}",
user_id=kb.owner_user_id, organization_id=kb.organization_id,
metadata={"kb_id": kb.id, "file_name": path.name, "kind": "knowledge_file"},
service_type="storage", unit="byte", quantity=path.stat().st_size, status="measured")
record_usage_event(
session, idempotency_key=f"embedding:index:{kb.id}:{file_hash}:{revision}:{operation}",
user_id=kb.owner_user_id, organization_id=kb.organization_id,
model=getattr(embed, "MODEL_NAME", "local-embedding"), service_type="embedding",
unit="token", quantity=sum(estimate_tokens(item["text"]) for item in chunks),
status="estimated", metadata={"kb_id": kb.id, "file_name": path.name,
"chunk_count": len(chunks), "operation": operation})
def _import_kb_files_locked(session: Session, kb_id: int, file_paths: list[str], progress_cb=None,
*, operation="import", candidates=None) -> dict:
kb = session.get(KnowledgeBase, kb_id)
if not kb:
raise ValueError("知识库不存在")
source = _source_dir(kb)
source.mkdir(parents=True, exist_ok=True)
if backend.is_disabled():
# Lightweight mode: preserve source files and registry rows, but never
# download/load embedding models or build a local vector index.
imported = []
for fp in file_paths:
incoming = Path(fp)
if not incoming.is_file() or incoming.suffix.lower() not in SUPPORTED_SUFFIXES | MEDIA_SUFFIXES:
continue
dest = source / incoming.name
if incoming.resolve() != dest.resolve():
shutil.copy2(incoming, dest)
digest = dedup.file_hash(dest)
record = session.exec(select(KnowledgeFile).where(
KnowledgeFile.kb_id == kb_id, KnowledgeFile.file_name == dest.name)).first()
if record is None:
record = KnowledgeFile(kb_id=kb_id, file_name=dest.name)
record.content_hash = digest
record.revision = max(1, int(record.revision or 1))
session.add(record)
imported.append(dest.name)
kb.file_count = len(_disk_files(source))
kb.source_dir = str(source)
kb.chunk_count = 0
kb.index_status = "disabled"
session.add(kb)
session.commit()
return {"files": kb.file_count, "chunks": 0, "imported_files": len(imported),
"imported_file_names": imported, "skipped_duplicates": 0,
"duplicate_files": [], "removed_duplicate_chunks": 0,
"vector_disabled": True}
cache = dedup.FingerprintCache(kb_id)
if candidates is None:
candidates = _indexed_sources(session, kb, source, cache)
previous_status = kb.index_status
kb.source_dir = str(source)
kb.index_status = "indexing"
session.add(kb)
session.commit()
result = {"files": 0, "chunks": 0, "imported_files": 0,
"imported_file_names": [], "skipped_duplicates": 0, "duplicate_files": [],
"removed_duplicate_chunks": 0}
try:
for fp in file_paths:
incoming = Path(fp)
if not incoming.is_file() or incoming.suffix.lower() not in SUPPORTED_SUFFIXES | MEDIA_SUFFIXES:
continue
file_name, dest = incoming.name, source / incoming.name
if progress_cb:
progress_cb(f"检查 {file_name} 是否重复", -1)
raw_hash = dedup.file_hash(incoming)
duplicate = _duplicate_of(candidates, raw_hash)
units, target, body_hash = None, 0, ""
if not duplicate:
units, target = _parse_source(incoming, kb, progress_cb)
body_hash = dedup.document_hash(units)
_fill_body_hashes(candidates, kb, cache)
duplicate = _duplicate_of(candidates, raw_hash, body_hash)
if duplicate:
result["skipped_duplicates"] += 1
result["duplicate_files"].append({"file_name": file_name, "duplicate_of": duplicate})
if progress_cb:
progress_cb(f"{file_name}:与 {duplicate} 内容重复,已跳过", 0)
continue
raw_chunks = chunk.chunk_texts(units, target=target)
chunks, removed = dedup.unique_chunks(raw_chunks)
if not chunks:
raise ValueError(f"{file_name} 未解析出可索引正文,已保留原文件与原索引;扫描件请先 OCR,旧版 doc 请转换为 docx")
record = session.exec(select(KnowledgeFile).where(
KnowledgeFile.kb_id == kb_id, KnowledgeFile.file_name == file_name)).first()
revision = max(1, int(record.revision or 1)) if record else 1
if record and record.content_hash != raw_hash:
revision += 1
for item in chunks:
item["meta"].update(file_hash=raw_hash, file_revision=str(revision),
document_hash=body_hash, dedup_version=1, file_chunk_count=len(chunks))
if progress_cb:
progress_cb(f"{file_name}:去重后 {len(chunks)} 个片段,正在建立索引" +
(f"(跳过 {removed} 个重复片段)" if removed else ""), -1)
# 完成解析及嵌入后才替换索引/源文件,失败不会先破坏用户旧文件。
vectors = embed.embed_texts([item["text"] for item in chunks])
stage = source / f".{uuid.uuid4().hex}.upload"
backup = source / f".{uuid.uuid4().hex}.previous"
copying = incoming.resolve() != dest.resolve()
snapshot = None
index_written = source_replaced = source_backed_up = False
try:
if copying:
shutil.copy2(incoming, stage)
if dedup.file_hash(stage) != raw_hash:
raise RuntimeError("上传文件在处理过程中发生变化,请重试")
snapshot = store.snapshot_file(kb_id, file_name)
store.replace_file_chunks(kb_id, chunks, vectors, file_name,
category_path(session, (record.category_id or record.ai_category_id) if record else None))
index_written = True
if copying:
if dest.exists():
dest.replace(backup)
source_backed_up = True
stage.replace(dest)
source_replaced = True
if record is None:
record = KnowledgeFile(kb_id=kb_id, file_name=file_name)
record.content_hash, record.revision = raw_hash, revision
session.add(record)
session.commit()
except Exception as exc:
session.rollback()
rollback_errors = []
if index_written and snapshot is not None:
try:
store.restore_file(kb_id, file_name, snapshot)
except Exception as restore_exc:
rollback_errors.append(f"索引恢复失败:{restore_exc}")
if copying:
try:
if source_replaced:
dest.unlink(missing_ok=True)
if source_backed_up:
backup.replace(dest)
except OSError as restore_exc:
rollback_errors.append(f"源文件恢复失败(备份 {backup.name}):{restore_exc}")
if rollback_errors:
raise RuntimeError(f"导入失败:{exc};{';'.join(rollback_errors)}") from exc
raise
finally:
stage.unlink(missing_ok=True)
backup.unlink(missing_ok=True)
_store_usage(session, kb, dest, raw_hash, revision, chunks, operation)
result["imported_files"] += 1
result["imported_file_names"].append(file_name)
result["chunks"] += len(chunks)
result["removed_duplicate_chunks"] += removed
candidates[:] = [candidate for candidate in candidates if candidate["file_name"] != file_name]
candidates.append({"file_name": file_name, "raw_hash": raw_hash,
"body_hash": body_hash, "path": dest})
cache.set(raw_hash, body_hash)
if progress_cb:
progress_cb(f"{file_name}:已导入 {len(chunks)} 个片段" +
(f",去重 {removed} 个片段" if removed else ""), len(chunks))
kb.file_count = len(_disk_files(source))
kb.chunk_count = store.count(kb_id)
kb.index_status = "ready" if kb.chunk_count else "empty"
session.add(kb)
session.commit()
cache.save()
result["files"] = kb.file_count
_schedule_lexical_prewarm(kb.id, int(getattr(kb, "organization_id", 1) or 1))
return result
except Exception:
session.rollback()
kb = session.get(KnowledgeBase, kb_id)
kb.file_count = len(_disk_files(source))
kb.chunk_count = store.count(kb_id)
kb.index_status = previous_status if kb.chunk_count else "error"
session.add(kb)
session.commit()
cache.save()
raise
def import_kb_files(session: Session, kb_id: int, file_paths: list[str], progress_cb=None) -> dict:
"""同库文件/正文精确去重,再安全增量导入;改名副本无需再次嵌入。"""
with dedup.library_lock(kb_id):
result = _import_kb_files_locked(session, kb_id, file_paths, progress_cb)
# The upload transaction is committed before this call. Build the full
# lexical index asynchronously so the first user search is not the one
# paying the index-construction cost.
kb = session.get(KnowledgeBase, int(kb_id))
if kb and kb.index_status == "ready" and kb.chunk_count:
hybrid.schedule_prewarm(kb.organization_id, kb.id)
# 新资料入库后后台自动做一次主题无关的全文阅读,写入可复用缓存。
# 任务独立于上传请求,模型不可用或阅读失败都不会影响索引结果。
imported = result.get("imported_file_names") or []
if imported:
try:
from ..training_reading import schedule_auto_read
schedule_auto_read(session, kb.id, imported)
except Exception:
pass
return result
def rebuild_kb(session: Session, kb_id: int, progress_cb=None) -> dict:
with dedup.library_lock(kb_id):
result = _rebuild_kb_locked(session, kb_id, progress_cb)
kb = session.get(KnowledgeBase, int(kb_id))
if kb and kb.index_status == "ready" and kb.chunk_count:
hybrid.schedule_prewarm(kb.organization_id, kb.id)
# A rebuild may discover new or changed files outside the regular upload
# route. Queue the same topic independent reading pass so later training
# map assessments can reuse the saved summaries immediately.
imported = result.get("imported_file_names") or []
if imported:
try:
from ..training_reading import schedule_auto_read
schedule_auto_read(session, kb.id, imported)
except Exception:
# Index rebuild succeeded; an unavailable model must not turn it
# into a failed request. The next upload/rebuild can retry reading.
pass
return result
def prewarm_ready_indexes(session: Session) -> int:
"""Queue lexical indexes and (when enabled) the initial generic reading pass.
The initial AI pass is opt-in at process level because an existing large
installation can contain many hours of material. Uploads and rebuilds
always schedule their changed files independently below. Failed or
interrupted passes are recovered on startup regardless of that opt-in.
"""
queued = 0
bootstrap = os.environ.get("YAWAN_AUTO_READ_EXISTING", "").strip().lower() in {"1", "true", "yes", "on"}
from ..training_reading import read_status, schedule_auto_read, auto_read_paused, _write_auto_status
now_epoch = time.time()
for kb in session.exec(select(KnowledgeBase).where(
KnowledgeBase.index_status == "ready", KnowledgeBase.chunk_count > 0)).all():
if hybrid.schedule_prewarm(kb.organization_id, kb.id):
queued += 1
# Existing installations opt in to the initial full pass. A failed
# pass or a worker interrupted by process restart is always recovered,
# even when the opt-in is absent, so a stale status cannot strand a
# library forever. ``updated_epoch`` is written atomically by the
# reader and a generous timeout avoids duplicating a legitimately long
# batch still running in another process.
status = read_status(kb)
if auto_read_paused() and status.get("status") in {"reading", "queued"}:
_write_auto_status(kb.organization_id or 1, kb.id,
{**status, "status": "paused", "error": "管理员已暂停自动预读"})
continue
try:
updated = float(status.get("updated_epoch") or 0)
except (TypeError, ValueError):
updated = 0
stale = (status.get("status") == "reading" and
now_epoch - updated > 900)
retry = status.get("status") in {"error", "queued"} or stale
if bootstrap or retry:
try:
names = [item.file_name for item in session.exec(
select(KnowledgeFile).where(KnowledgeFile.kb_id == kb.id)
).all() if item.file_name]
if names:
schedule_auto_read(session, kb.id, names)
except Exception:
# A model or cache problem must never prevent the API from
# starting; the next rebuild/upload can retry the reading pass.
pass
return queued
def _rebuild_kb_locked(session: Session, kb_id: int, progress_cb=None) -> dict:
kb = session.get(KnowledgeBase, kb_id)
if not kb:
raise ValueError("知识库不存在")
source = _source_dir(kb)
previous = {"file_count": kb.file_count, "chunk_count": kb.chunk_count, "index_status": kb.index_status}
previous_records = {record.file_name: (record.content_hash, record.revision)
for record in session.exec(select(KnowledgeFile).where(KnowledgeFile.kb_id == kb_id)).all()}
try:
snapshot = store.snapshot_kb(kb_id)
except Exception as exc:
if int(kb.chunk_count or 0) > 0:
raise RuntimeError(f"无法创建索引快照,已取消重建以保护原数据:{str(exc)[:300]}") from exc
snapshot = {"ids": [], "documents": [], "embeddings": [], "metadatas": []}
# 源目录丢失属于异常,不能把它解释成用户要删除全部索引。
if not source.exists() and int(kb.chunk_count or 0) > 0:
raise ValueError("知识库源目录不存在,已取消重建并保留原索引")
files = [str(path) for path in _disk_files(source)]
try:
# 保留旧索引直到对应文件解析/嵌入完成;重建也应用同库文件去重。
result = _import_kb_files_locked(session, kb_id, files, progress_cb,
operation="rebuild", candidates=[])
canonical = set(result["imported_file_names"])
previous_names = {meta.get("file") for meta in snapshot.get("metadatas", []) if meta}
for file_name in previous_names - canonical:
if file_name:
store.delete_file(kb_id, file_name)
kb.file_count = len(_disk_files(source))
kb.chunk_count = store.count(kb_id)
kb.index_status = "ready" if kb.chunk_count else "empty"
session.add(kb)
session.commit()
return {**result, "files": kb.file_count, "rolled_back": False}
except Exception as exc:
session.rollback()
restore_error = ""
try:
store.restore_kb(kb_id, snapshot)
for record in session.exec(select(KnowledgeFile).where(KnowledgeFile.kb_id == kb_id)).all():
if record.file_name in previous_records:
record.content_hash, record.revision = previous_records[record.file_name]
session.add(record)
else:
session.delete(record)
kb.file_count = previous["file_count"]
kb.chunk_count = previous["chunk_count"]
kb.index_status = previous["index_status"]
except Exception as restore_exc:
kb.index_status = "error"
restore_error = f";回滚失败:{str(restore_exc)[:180]}"
session.add(kb)
session.commit()
raise RuntimeError(f"知识库重建失败,已尝试恢复原索引:{str(exc)[:300]}{restore_error}") from exc
def consumed_chunk_ids(session: Session, agent_id: int, kb_ids: list[int]) -> dict[int, set[str]]:
"""返回仅对当前 Agent 不可再次使用的知识片段。"""
if not agent_id or not kb_ids:
return {}
usages = session.exec(select(KnowledgeUsage).where(
KnowledgeUsage.agent_id == agent_id,
or_(KnowledgeUsage.status.in_(["reserved", "consumed"]), KnowledgeUsage.banned == True), # noqa: E712
)).all()
result: dict[int, set[str]] = {kid: set() for kid in kb_ids}
for usage in usages:
if usage.kb_id in result:
result[usage.kb_id].add(usage.chunk_id)
return result
def reserve_chunks(session: Session, agent_id: int, campaign_id: int,
segment_id: int, hits: list[dict]) -> list[dict]:
"""模型调用前原子预留;正文落盘成功后才转为正式 consumed。"""
# 预取候选状态并使用 executemany,避免每个 chunk 两次往返 SQLite。
# 这条路径通常一次处理几十个片段,批量写入可显著减少写锁持有时间。
unique_hits, seen = [], set()
for hit in hits:
kb_id = int(hit.get("kb_id") or 0)
chunk_id = str(hit.get("id") or "")
if not kb_id or not chunk_id:
continue
key = (kb_id, chunk_id)
if key in seen:
continue
seen.add(key)
unique_hits.append(hit)
if not unique_hits or not agent_id:
return []
kb_ids = sorted({int(hit.get("kb_id") or 0) for hit in unique_hits})
existing = session.exec(select(KnowledgeUsage).where(
KnowledgeUsage.agent_id == int(agent_id),
KnowledgeUsage.kb_id.in_(kb_ids),
KnowledgeUsage.chunk_id.in_([str(hit.get("id") or "") for hit in unique_hits])
)).all()
state = {(int(row.kb_id), str(row.chunk_id)): row for row in existing}
inserts, reopens, candidates = [], [], []
for hit in unique_hits:
kb_id = int(hit.get("kb_id") or 0)
chunk_id = str(hit.get("id") or "")
values = {
"agent_id": agent_id, "kb_id": kb_id, "chunk_id": chunk_id,
"file_name": str(hit.get("meta", {}).get("file") or ""),
"heading": str(hit.get("meta", {}).get("heading") or ""),
"excerpt": str(hit.get("text") or "")[:1000],
"campaign_id": campaign_id, "segment_id": segment_id,
"used_at": now(),
}
row = state.get((kb_id, chunk_id))
if row is None:
inserts.append(values); candidates.append(hit)
elif str(row.status) == "available" and not bool(row.banned):
reopens.append(values); candidates.append(hit)
if inserts:
session.execute(text("""
INSERT OR IGNORE INTO knowledgeusage
(agent_id, kb_id, chunk_id, file_name, heading, excerpt, campaign_id, segment_id, status, used_at, released_at, banned)
VALUES (:agent_id, :kb_id, :chunk_id, :file_name, :heading, :excerpt, :campaign_id, :segment_id, 'reserved', :used_at, '', 0)
"""), inserts)
if reopens:
session.execute(text("""
UPDATE knowledgeusage
SET file_name = :file_name, heading = :heading, excerpt = :excerpt,
campaign_id = :campaign_id, segment_id = :segment_id,
status = 'reserved', used_at = :used_at, released_at = ''
WHERE agent_id = :agent_id AND kb_id = :kb_id AND chunk_id = :chunk_id
AND status = 'available' AND banned = 0
"""), reopens)
# 并发请求可能在预取后先认领了同一片段;以最终状态确认本事务实际
# 拿到的键,避免把 INSERT OR IGNORE 的冲突行误报为已预留。
claimed_keys = set()
if candidates:
rows = session.exec(select(KnowledgeUsage).where(
KnowledgeUsage.agent_id == int(agent_id),
KnowledgeUsage.kb_id.in_(kb_ids),
KnowledgeUsage.chunk_id.in_([str(hit.get("id") or "") for hit in candidates]),
KnowledgeUsage.status == "reserved",
KnowledgeUsage.campaign_id == int(campaign_id),
KnowledgeUsage.segment_id == int(segment_id),
)).all()
claimed_keys = {(int(row.kb_id), str(row.chunk_id)) for row in rows}
session.commit()
return [hit for hit in candidates if (int(hit.get("kb_id") or 0), str(hit.get("id") or "")) in claimed_keys]
def consume_reserved_chunks(session: Session, agent_id: int, campaign_id: int,
segment_id: int, hits: list[dict]) -> None:
"""只有正文和锚点成功写入后才视为当前 Agent 已消费。"""
params = [{"agent_id": agent_id, "kb_id": int(hit.get("kb_id") or 0),
"chunk_id": str(hit.get("id") or ""), "campaign_id": campaign_id,
"segment_id": segment_id} for hit in hits if hit.get("kb_id") and hit.get("id")]
if params:
session.execute(text("""
UPDATE knowledgeusage SET status = 'consumed'
WHERE agent_id = :agent_id AND kb_id = :kb_id AND chunk_id = :chunk_id
AND campaign_id = :campaign_id AND segment_id = :segment_id AND status = 'reserved'
"""), params)
session.commit()
def release_reserved_chunks(session: Session, agent_id: int, campaign_id: int,
segment_id: int, hits: list[dict]) -> None:
"""失败、重试或锚点不合格时归还未落盘素材。"""
params = [{"agent_id": agent_id, "kb_id": int(hit.get("kb_id") or 0),
"chunk_id": str(hit.get("id") or ""), "campaign_id": campaign_id,
"segment_id": segment_id, "released_at": now()} for hit in hits if hit.get("kb_id") and hit.get("id")]
if params:
session.execute(text("""
UPDATE knowledgeusage SET status = 'available', released_at = :released_at
WHERE agent_id = :agent_id AND kb_id = :kb_id AND chunk_id = :chunk_id
AND campaign_id = :campaign_id AND segment_id = :segment_id AND status = 'reserved'
"""), params)
session.commit()
def search_kbs_remote(session: Session, kb_ids: list[int], query: str, k: int = 6,
excluded_chunk_ids: dict[int, set[str]] | None = None, *,
campaign_id: int = 0, trace_id: str = "", user_id: int = 0,
organization_id: int = 1, category_ids: list[int] | None = None,
file_whitelists: dict[int, set[str]] | None = None) -> list[dict]:
"""有界进程池检索;分类和调用方文件范围在召回前按知识库取交集。"""
kids = list(dict.fromkeys(int(kid) for kid in kb_ids))
if not kids or not str(query or '').strip() or k <= 0:
return []
if backend.is_disabled():
return _lightweight_keyword_search(
session, kids, query, k=k, excluded_chunk_ids=excluded_chunk_ids,
campaign_id=campaign_id, trace_id=trace_id, user_id=user_id,
organization_id=organization_id, category_ids=category_ids,
file_whitelists=file_whitelists, validate_access=True)
whitelists = {int(kid): set(names) for kid, names in (file_whitelists or {}).items()}
if category_ids:
for kid in kids:
allowed = _category_file_whitelist(session, kid, list(category_ids))
if allowed is not None:
whitelists[kid] = allowed & whitelists[kid] if kid in whitelists else allowed
# 显式空集合表示没有获选文件,绝不能当作“不限范围”。
eligible = [kid for kid in kids if kid not in whitelists or whitelists[kid]]
if not eligible:
return []
# Warm missing full-library FTS indexes from the API process. The search
# worker remains focused on the bounded vector query and is not burdened by
# a long background SQLite build that could be killed with the worker.
for kid in eligible:
try:
rev = hybrid.revision(kid)
if not hybrid.index_available(organization_id, kid, rev):
hybrid.schedule_prewarm(organization_id, kid, rev)
except Exception:
# Prewarming is best effort; vector recall below remains available.
pass
hits = _search_kbs_worker(session, eligible, query, k, excluded_chunk_ids,
campaign_id, trace_id, user_id, organization_id,
file_whitelists=whitelists)
# 最后仅遍历一次防御校验,不因选择多个库而重复或扩大结果。
results, seen = [], set()
for hit in hits:
kid = int(hit.get('kb_id') or 0)
if kid not in eligible:
continue
meta = hit.get('meta') or {}
if kid in whitelists and meta.get('file') not in whitelists[kid]:
continue
chunk_id = hit.get('id')
identity = (kid, str(chunk_id)) if chunk_id else (kid, meta.get('file'), meta.get('seq'), hit.get('text'))
if identity not in seen:
seen.add(identity)
results.append(hit)
return results
def _search_kbs_worker(session: Session, kb_ids: list[int], query: str, k: int = 6,
excluded_chunk_ids: dict[int, set[str]] | None = None,
campaign_id: int = 0, trace_id: str = "", user_id: int = 0,
organization_id: int = 1, file_whitelists: dict[int, set[str]] | None = None) -> list[dict]:
from .worker_pool import get_pool, RagWorkerError
payload = {
'kb_ids': [int(kid) for kid in kb_ids], 'query': query, 'k': k,
'excluded': {str(kid): sorted(ids) for kid, ids in (excluded_chunk_ids or {}).items()},
'campaign_id': int(campaign_id or 0), 'trace_id': trace_id or '',
'user_id': int(user_id or 0), 'organization_id': int(organization_id or 1),
'file_whitelists': {str(kid): sorted(names) for kid, names in (file_whitelists or {}).items()},
}
try:
response = get_pool().request(payload)
except RagWorkerError as exc:
metric = getattr(exc, "retrieval_metric", None)
if metric:
perf_metrics.ingest_rag(metric)
from .. import system_log
import logging
message = f'知识库检索失败 · 知识库:{kb_ids} · 原因:{system_log.redact(exc, 500)}'
try:
# 独立短事务记录错误,不提交调用方尚未完成的业务变更。
with Session(session.get_bind()) as log_session:
if log_session.get_bind().dialect.name == 'sqlite':
log_session.connection().exec_driver_sql('PRAGMA busy_timeout=100')
system_log.write(log_session, source='rag', message=message, trace_id=trace_id,
status='error')
except Exception:
logging.getLogger('yawen.rag').error(message)
# 不回退主进程执行:避免超时后继续无界计算并重复计量。
raise
timings = {**response.get('worker_timings', {}), **response.get('transport_timings', {})}
if response.get("retrieval_metric"):
metric = dict(response["retrieval_metric"])
# Transport diagnostics are measured in the API process and are kept
# alongside worker phase metrics for cold start/IPC analysis.
# ``worker_ms`` includes the complete child search (embedding, vector
# recall and post-processing). Using it as IPC time made the
# diagnostics wildly overstate transport overhead. The worker pool
# computes the residual pipe/JSON time explicitly as ``ipc_ms``;
# retain a zero fallback for old workers that do not provide it.
metric["embedding_ipc_ms"] = timings.get(
"ipc_ms", metric.get("embedding_ipc_ms", 0.0)
)
# Keep queue wait separate from child search and IPC so a saturated
# resident worker is distinguishable from slow disk/vector recall.
metric["queue_wait_ms"] = timings.get("queue_ms", 0.0)
metric["transport_total_ms"] = timings.get("total_ms", 0.0)
metric["worker_pid"] = timings.get("worker_pid")
metric["worker_start_ms"] = timings.get("process_start_ms", 0.0)
metric["worker_reused"] = timings.get("process_reused", False)
# Normalize the worker's compact embedding envelope to the telemetry
# schema used by the API diagnostics endpoint.
metric["embedding_model"] = metric.get("embedding_model") or metric.get("model")
metric["embedding_version"] = metric.get("embedding_version") or metric.get("model_version")
metric["embedding_pid"] = metric.get("embedding_pid") or metric.get("pid")
metric["embedding_process_start_ms"] = timings.get(
"process_start_ms", metric.get("embedding_process_start_ms", 0.0)
)
metric["embedding_cache_hit"] = metric.get("embedding_cache_hit", metric.get("cache_hit", False))
metric["embedding_model_load_ms"] = metric.get("embedding_model_load_ms", metric.get("model_load_ms", 0.0))
metric["embedding_tokenizer_ms"] = metric.get("embedding_tokenizer_ms", metric.get("tokenizer_ms", 0.0))
metric["embedding_encode_ms"] = metric.get("embedding_encode_ms", metric.get("encode_ms", 0.0))
metric["embedding_postprocess_ms"] = metric.get("embedding_postprocess_ms", metric.get("postprocess_ms", 0.0))
metric["embedding_count"] = metric.get("embedding_count", 0)
metric["embedding_total_ms"] = sum(float(metric.get(name, 0.0) or 0.0)
for name in ("embedding_model_load_ms", "embedding_tokenizer_ms",
"embedding_encode_ms", "embedding_postprocess_ms"))
perf_metrics.ingest_rag(metric)
for item in response['results']:
item['transport_timings'] = timings
return response['results']
_LIGHTWEIGHT_TEXT_SUFFIXES = SUPPORTED_SUFFIXES - {".doc"}
_LIGHTWEIGHT_ASCII_RE = re.compile(r"[a-z0-9_][a-z0-9_.+-]*", re.IGNORECASE)
_LIGHTWEIGHT_CJK_RE = re.compile(r"[\u4e00-\u9fff]{2,}")
_LIGHTWEIGHT_NUMBER_RE = re.compile(r"\d+(?:[-–.]\d+)+")
def _lightweight_chunk_target(path: Path, kb: KnowledgeBase | None = None) -> int:
configured = int(getattr(kb, "chunk_target", 0) or 0)
return configured or (2200 if path.suffix.lower() == ".srt" else chunk.TARGET)
def _lightweight_chunk_id(kb_id: int, file_name: str, content_hash: str,
heading: str, seq: int, text: str) -> str:
return hashlib.sha256(
f"{kb_id}\0{file_name}\0{content_hash or ''}\0{heading}\0{seq}\0{text}".encode("utf-8")
).hexdigest()
def iter_lightweight_chunks(session: Session, kb_ids: list[int], *,
excluded_chunk_ids: dict[int, set[str]] | None = None,
category_ids_by_kb: dict[int, list[int]] | None = None,
file_whitelists: dict[int, set[str]] | None = None,
apply_quarantine: bool = True) -> list[dict]:
"""Return all on-demand lightweight chunks with the same IDs used by search.
Vector-disabled mode never persists embeddings or local vectors. It parses
registered source files at query time; planner validation uses this helper to
inspect the exact material pool that DSH Worker will see.
"""
kids = list(dict.fromkeys(int(kid) for kid in kb_ids))
exclusions = {int(kid): set(values) for kid, values in (excluded_chunk_ids or {}).items()}
specified_ranges = {int(kid): set(names) for kid, names in (file_whitelists or {}).items()}
out: list[dict] = []
for kid in kids:
kb = session.exec(select(KnowledgeBase).where(KnowledgeBase.id == kid)
.execution_options(populate_existing=True)).first()
if kb is None or str(kb.index_status or "") == "indexing":
continue
whitelist = _category_file_whitelist(
session, kid, list((category_ids_by_kb or {}).get(kid) or []))
if kid in specified_ranges:
specified = specified_ranges[kid]
whitelist = specified if whitelist is None else whitelist & specified
quarantined = file_exclusions(session, kid) if apply_quarantine else set()
if whitelist is not None:
whitelist = whitelist - quarantined
if whitelist is not None and not whitelist:
continue
source = _source_dir(kb)
records = session.exec(select(KnowledgeFile).where(KnowledgeFile.kb_id == kid)).all()
for record in sorted(records, key=lambda item: item.file_name):
file_name = str(record.file_name or "")
if whitelist is not None and file_name not in whitelist:
continue
if file_name in quarantined:
continue
path = source / file_name
if not path.is_file() or path.suffix.lower() not in _LIGHTWEIGHT_TEXT_SUFFIXES:
continue
try:
units = parse.parse_file(path)
chunks = chunk.chunk_texts(units, target=_lightweight_chunk_target(path, kb))
except Exception as exc:
logger.warning("轻量素材库存解析失败,已跳过 %s/%s:%s", kid, file_name, exc)
continue
excluded = exclusions.get(kid, set())
for seq, item in enumerate(chunks):
body = str(item.get("text") or "").strip()
if not body:
continue
meta = dict(item.get("meta") or {})
heading = str(meta.get("heading") or "")
chunk_id = _lightweight_chunk_id(
kid, file_name, str(record.content_hash or ""), heading, seq, body)
if chunk_id in excluded:
continue
item_meta = dict(meta)
item_meta.update({"kb_id": kid, "file": file_name, "heading": heading,
"seq": seq, "retrieval_mode": "keyword-light"})
out.append({"id": chunk_id, "text": body, "meta": item_meta,
"kb_id": kid, "distance": 0.0})
return out
def simulate_lightweight_plan(session: Session, kb_ids: list[int], queries: list[str], *,
excluded_chunk_ids: dict[int, set[str]] | None = None,
category_ids_by_kb: dict[int, list[int]] | None = None,
file_whitelists: dict[int, set[str]] | None = None,
initial_top_k: int = 6, widened_top_k: int = 24,
char_budget: int = 12000) -> dict:
"""Deterministically replay per-node keyword recall and chunk reservation.
The plan is approvable only if every planned node can reserve at least one
source chunk after earlier nodes have consumed their selected materials. This
catches exhausted/mismatched lightweight KBs before Worker execution.
"""
pool = iter_lightweight_chunks(
session, kb_ids, excluded_chunk_ids=excluded_chunk_ids,
category_ids_by_kb=category_ids_by_kb, file_whitelists=file_whitelists)
available = {str(item["id"]): item for item in pool}
node_results: list[dict] = []
for index, query_text in enumerate(queries, start=1):
query_text = str(query_text or "")
terms = _lightweight_terms(query_text)
scored: list[dict] = []
if terms:
for item in available.values():
score, matched = _lightweight_score(item["text"], terms)
if score > 0 and matched > 0:
candidate = dict(item)
candidate["_score"] = score
candidate["_matched"] = matched
scored.append(candidate)
scored.sort(key=lambda item: (-float(item["_score"]), -int(item["_matched"]),
int(item["kb_id"]),
str(item["meta"].get("file") or ""),
int(item["meta"].get("seq") or 0)))
initial_hits = scored[:max(1, min(24, int(initial_top_k or 6)))]
widened = max(int(initial_top_k or 6), int(widened_top_k or 24))
# Mirror DSH Worker: widen only when the first recall page is sparse.
hits = scored[:max(1, min(64, widened))] if len(initial_hits) < int(initial_top_k or 6) else initial_hits
selected: list[dict] = []
used_chars = 0
for hit in hits:
remaining = int(char_budget or 12000) - used_chars
if remaining < 240 and selected:
break
excerpt = source_excerpt(hit.get("text", ""), query_text,
limit=min(2400, max(240, remaining)))
cost = len(excerpt)
if selected and used_chars + cost > int(char_budget or 12000):
continue
selected.append(hit)
used_chars += cost
if used_chars >= int(char_budget or 12000):
break
reserved_ids = [str(hit["id"]) for hit in selected]
for cid in reserved_ids:
available.pop(cid, None)
node_results.append({
"order": index,
"query": query_text[:500],
"candidate_count": len(scored),
"reserved_count": len(reserved_ids),
"reserved_characters": used_chars,
"chunk_ids": reserved_ids,
"files": sorted({str(hit["meta"].get("file") or "") for hit in selected}),
})
shortfalls = [item for item in node_results if item["reserved_count"] <= 0]
return {
"ok": not shortfalls,
"total_chunks": len(pool),
"remaining_chunks": len(available),
"nodes": node_results,
"shortfalls": shortfalls,
"retrieval_mode": "keyword-light",
"vector_backend": "disabled",
}
def _lightweight_terms(query: str) -> list[str]:
"""Extract conservative Chinese/English/numeric terms without a tokenizer.
Chinese contiguous phrases are kept in full and expanded to bigrams. This is
intentionally simple and deterministic: it provides usable source-grounded
recall in lightweight mode while avoiding embedding models, Chroma and torch.
"""
text = unicodedata.normalize("NFKC", str(query or "")).lower()
terms: list[str] = []
def add(value: str) -> None:
value = value.strip().lower()
if len(value) >= 2 and value not in terms:
terms.append(value)
for match in _LIGHTWEIGHT_NUMBER_RE.finditer(text):
add(match.group(0))
for match in _LIGHTWEIGHT_CJK_RE.finditer(text):
token = match.group(0)
add(token)
if len(token) > 2:
for idx in range(len(token) - 1):
add(token[idx:idx + 2])
for match in _LIGHTWEIGHT_ASCII_RE.finditer(text):
add(match.group(0))
return terms[:80]
def _lightweight_score(text: str, terms: list[str]) -> tuple[float, int]:
body = unicodedata.normalize("NFKC", text or "").lower()
score = 0.0
matched = 0
for term in terms:
count = body.count(term)
if count <= 0:
continue
matched += 1
if _LIGHTWEIGHT_CJK_RE.fullmatch(term):
weight = 5.0 if len(term) >= 4 else (3.0 if len(term) == 3 else 1.0)
elif _LIGHTWEIGHT_NUMBER_RE.fullmatch(term):
weight = 5.0
elif len(term) >= 5:
weight = 3.0
else:
weight = 2.0
score += min(count, 6) * weight
if matched and terms:
# Prefer chunks matching several independent query concepts, while term
# frequency remains the dominant signal.
score *= 1.0 + 0.35 * (matched / min(len(terms), 12))
return score, matched
def _lightweight_keyword_search(session: Session, kb_ids: list[int], query: str, k: int = 6,
excluded_chunk_ids: dict[int, set[str]] | None = None, *,
campaign_id: int = 0, trace_id: str = "", user_id: int = 0,
organization_id: int = 1, category_ids: list[int] | None = None,
file_whitelists: dict[int, set[str]] | None = None,
validate_access: bool = True) -> list[dict]:
"""Keyword recall directly from registered source files when vectors are off.
The workbench still owns source files, KnowledgeFile registry, category
filters, quarantine, per-Agent consumed chunks and citation reservation.
Parsing/chunking is performed on demand and never persists local vectors.
"""
started = time.perf_counter()
kids = list(dict.fromkeys(int(kid) for kid in kb_ids))
query = str(query or "").strip()
k = max(1, min(100, int(k or 6)))
if not kids or not query:
return []
if len(query) > 10000:
raise ValueError("检索问题不能超过 10000 字")
terms = _lightweight_terms(query)
if not terms:
return []
campaign = session.get(Campaign, campaign_id) if campaign_id else None
resolved_user = int(user_id or (campaign.owner_user_id if campaign else 0) or 0)
resolved_org = int((campaign.organization_id if campaign and not user_id else organization_id) or 1)
exclusions = {int(kid): set(values) for kid, values in (excluded_chunk_ids or {}).items()}
specified_ranges = {int(kid): set(names) for kid, names in (file_whitelists or {}).items()}
actor = None
if validate_access and resolved_user:
from ..models import AppUser
actor = session.exec(select(AppUser).where(AppUser.id == resolved_user)
.execution_options(populate_existing=True)).first()
if actor is None or not actor.enabled:
raise ValueError("当前用户不存在或已停用,无法检索知识库")
if int(actor.organization_id or 1) != resolved_org:
raise ValueError("当前用户与知识库检索组织不一致")
candidates: list[dict] = []
for kid in kids:
kb = session.exec(select(KnowledgeBase).where(KnowledgeBase.id == kid)
.execution_options(populate_existing=True)).first()
if kb is None or int(kb.organization_id) not in {0, resolved_org}:
raise ValueError("知识库不存在或当前用户没有访问权限")
if kb.index_status == "indexing":
raise ValueError("知识库正在导入,请完成后重新检索")
if validate_access and actor:
from ..access import can_access, canvas_allows_resource
from ..models import KnowledgeBaseGrant
grant = session.exec(select(KnowledgeBaseGrant).where(
KnowledgeBaseGrant.kb_id == kid, KnowledgeBaseGrant.user_id == actor.id,
KnowledgeBaseGrant.enabled == True)).first() # noqa: E712
if not (can_access(kb, actor, "use", grant.permission if grant else None)
or canvas_allows_resource(session, kb, actor, "use")):
raise ValueError("知识库不存在或当前用户没有访问权限")
whitelist = _category_file_whitelist(session, kid, list(category_ids or []))
if kid in specified_ranges:
specified = specified_ranges[kid]
whitelist = specified if whitelist is None else whitelist & specified
# An explicitly empty set means no eligible file; it must not mean all.
if whitelist is not None and not whitelist:
continue
quarantined = file_exclusions(session, kid)
if whitelist is not None:
whitelist = whitelist - quarantined
if not whitelist:
continue
source = _source_dir(kb)
records = session.exec(select(KnowledgeFile).where(KnowledgeFile.kb_id == kid)).all()
for record in sorted(records, key=lambda item: item.file_name):
file_name = str(record.file_name or "")
if whitelist is not None and file_name not in whitelist:
continue
if file_name in quarantined:
continue
path = source / file_name
if not path.is_file() or path.suffix.lower() not in _LIGHTWEIGHT_TEXT_SUFFIXES:
continue
try:
units = parse.parse_file(path)
chunks = chunk.chunk_texts(units, target=_lightweight_chunk_target(path, kb))
except Exception as exc:
logger.warning("轻量知识库解析失败,已跳过 %s/%s:%s", kid, file_name, exc)
continue
excluded = exclusions.get(kid, set())
for seq, item in enumerate(chunks):
text = str(item.get("text") or "").strip()
if not text:
continue
meta = dict(item.get("meta") or {})
heading = str(meta.get("heading") or "")
chunk_id = _lightweight_chunk_id(
kid, file_name, str(record.content_hash or ""), heading, seq, text)
if chunk_id in excluded:
continue
score, matched = _lightweight_score(text, terms)
if score <= 0 or matched <= 0:
continue
meta.update({"kb_id": kid, "file": file_name, "heading": heading,
"seq": seq, "retrieval_mode": "keyword-light"})
candidates.append({
"id": chunk_id,
"text": text,
"meta": meta,
"kb_id": kid,
"distance": 0.0,
"_score": round(score, 3),
"_matched": matched,
})
candidates.sort(key=lambda item: (-float(item["_score"]), -int(item["_matched"]),
int(item["kb_id"]),
str(item["meta"].get("file") or ""),
int(item["meta"].get("seq") or 0)))
results = candidates[:k]
elapsed_ms = round((time.perf_counter() - started) * 1000, 2)
timings = {"validation_ms": 0.0, "embedding_ms": 0.0, "recall_ms": elapsed_ms,
"postprocess_ms": 0.0, "total_ms": elapsed_ms, "retrieval_mode": "keyword-light",
"vector_backend": "disabled", "candidate_count": len(candidates)}
for item in results:
item["retrieval_timings"] = timings
item.pop("_score", None)
item.pop("_matched", None)
logger.info("轻量关键词检索完成:%s 个库,query=%r,%s 个候选,%s 条结果,%.3fs",
len(kids), query[:80], len(candidates), len(results), elapsed_ms / 1000)
return results
def _category_file_whitelist(session: Session, kb_id: int, category_ids: list[int]) -> set[str] | None:
"""分类 → 允许的文件名集合;空 list 返回 None(不限制)。"""
if not category_ids:
return None
ids = list(category_ids)
# 含子孙分类
cats = session.exec(select(KnowledgeCategory).where(KnowledgeCategory.kb_id == kb_id)).all()
by_parent: dict = {}
for c in cats:
by_parent.setdefault(c.parent_id, []).append(c.id)
expand = list(ids)
queue = list(ids)
while queue:
cur = queue.pop()
for child in by_parent.get(cur, []):
if child not in expand:
expand.append(child)
queue.append(child)
files = session.exec(select(KnowledgeFile).where(
KnowledgeFile.kb_id == kb_id)).all()
whitelist = set()
for f in files:
cat = f.category_id if f.category_id is not None else f.ai_category_id
if cat in expand:
whitelist.add(f.file_name)
return whitelist
def search_kbs(session: Session, kb_ids: list[int], query: str, k: int = 6,
excluded_chunk_ids: dict[int, set[str]] | None = None, *,
campaign_id: int = 0, trace_id: str = "", user_id: int = 0,
organization_id: int = 1, category_ids: list[int] | None = None,
file_whitelists: dict[int, set[str]] | None = None) -> list[dict]:
"""独立中文关键词与向量召回、融合精排;缓存不绕过权限或消费过滤。"""
global _LAST_RETRIEVAL_METRIC
_LAST_RETRIEVAL_METRIC = None
import logging
import time
from . import hybrid
from ..access import can_access, canvas_allows_resource
from ..models import AppUser, KnowledgeBaseGrant
started = time.perf_counter()
validation_started = started
if not kb_ids or not str(query or "").strip():
return []
if backend.is_disabled():
return _lightweight_keyword_search(
session, kb_ids, query, k=k, excluded_chunk_ids=excluded_chunk_ids,
campaign_id=campaign_id, trace_id=trace_id, user_id=user_id,
organization_id=organization_id, category_ids=category_ids,
file_whitelists=file_whitelists, validate_access=True)
k = max(1, min(100, int(k)))
query = str(query).strip()
if len(query) > 10000:
raise ValueError("检索问题不能超过 10000 字")
ids = list(dict.fromkeys(int(kid) for kid in kb_ids))
campaign = session.get(Campaign, campaign_id) if campaign_id else None
resolved_user = int(user_id or (campaign.owner_user_id if campaign else 0) or 0)
resolved_org = int((campaign.organization_id if campaign and not user_id else organization_id) or 1)
# A real requester is checked on every call, including cache hits. Internal
# jobs without an account still keep the explicit organization boundary.
actor = session.exec(select(AppUser).where(AppUser.id == resolved_user)
.execution_options(populate_existing=True)).first() if resolved_user else None
if resolved_user and (actor is None or not actor.enabled):
raise ValueError("当前用户不存在或已停用,无法检索知识库")
if actor and int(actor.organization_id or 1) != resolved_org:
raise ValueError("当前用户与知识库检索组织不一致")
kb_rows, allowed, scopes = {}, {}, []
# Authorization is part of candidate-pool identity. Keep a compact
# signature alongside scopes so a changed grant cannot reuse a prior pool.
permission_signature = []
# Validate before embedding and before looking in any query cache.
for kid in ids:
kb = session.exec(select(KnowledgeBase).where(KnowledgeBase.id == kid)
.execution_options(populate_existing=True)).first()
if kb is None or int(kb.organization_id) not in {0, resolved_org}:
raise ValueError("知识库不存在或当前用户没有访问权限")
if kb.index_status == "indexing":
raise ValueError("知识库正在建立索引,请完成后重新检索")
if actor:
grant = session.exec(select(KnowledgeBaseGrant).where(
KnowledgeBaseGrant.kb_id == kid, KnowledgeBaseGrant.user_id == actor.id,
KnowledgeBaseGrant.enabled == True)).first() # noqa: E712
if not (can_access(kb, actor, 'use', grant.permission if grant else None)
or canvas_allows_resource(session, kb, actor, 'use')):
raise ValueError("知识库不存在或当前用户没有访问权限")
permission_signature.append((kid, str(getattr(grant, 'permission', '') or 'canvas')))
else:
permission_signature.append((kid, 'internal'))
kb_rows[kid] = kb
quarantined = file_exclusions(session, kid)
whitelist = _category_file_whitelist(session, kid, list(category_ids or []))
if file_whitelists is not None and kid in file_whitelists:
specified = set(file_whitelists[kid])
whitelist = specified if whitelist is None else whitelist & specified
if quarantined:
whitelist = (set(store.count_by_file(kid)) if whitelist is None else whitelist) - quarantined
allowed[kid] = whitelist
scopes.append((kid, hybrid.revision(kid), None if whitelist is None else sorted(whitelist)))
validation_ms = (time.perf_counter() - validation_started) * 1000
exclusions = {int(kid): set(values) for kid, values in (excluded_chunk_ids or {}).items()}
# Bound work even when a long-running Agent has consumed many thousands of
# chunks. The candidate pool itself is cached before consumption filtering.
excluded_count = max((len(exclusions.get(kid, set())) for kid in ids), default=0)
# Keep the first-stage pool small enough for fast disk/vector reads. A
# bounded allowance covers recently consumed chunks without letting a
# heavily used Agent turn every query into a 2k-row scan.
needed = max(32, k * 4) + min(excluded_count, 256)
# Buckets allow the same cached pool to survive a few newly consumed chunks.
limit = min(512, 1 << (needed - 1).bit_length())
model = str(getattr(embed, "EMBEDDING_MODEL", "local-embedding"))
cache_model = model + ":" + str(getattr(embed, "EMBEDDING_POLICY", "default"))
# Include lexical-index readiness in the cache namespace. A first query
# may intentionally use vector-only recall while a large FTS index is being
# built in the background; once that index becomes ready the key changes so
# later requests immediately receive the hybrid (vector + keyword) result
# instead of reusing the temporary vector-only pool for its full TTL.
def _fts_scope_ready(kid, rev):
files = allowed[kid]
# A full-library index is also sufficient for a category scope: the
# lexical channel filters its metadata after matching.
return hybrid.index_available(resolved_org, kid, rev, files) or (
files is not None and hybrid.index_available(resolved_org, kid, rev, None))
fts_ready = all(_fts_scope_ready(kid, rev) for kid, rev, _ in scopes)
cache_model += ":fts-ready" if fts_ready else ":fts-pending"
retrieval_mode = "hybrid" if fts_ready else "vector"
key = hybrid.candidate_key(
resolved_org, resolved_user, scopes, query, limit, cache_model,
mode=retrieval_mode, top_k=k, permissions=permission_signature,
index_version=":".join(str(rev) for _, rev, _ in scopes),
)
pool = hybrid.cache_get(key)
cache_hit = pool is not None
embedding_ms = 0.0
recall_ms = 0.0
if pool is None:
embedding_started = time.perf_counter()
qv = embed.embed_query(query)
embedding_ms = (time.perf_counter() - embedding_started) * 1000
# Embed workers expose phase-level diagnostics (model load, tokenizer,
# inference and serialization). Keep these alongside retrieval timing
# without changing the vector API or test doubles.
embedding_metric = getattr(embed, "last_embedding_metric", lambda: {})()
_record_query_embedding_usage(
idempotency_key=f"embedding:query:{trace_id or uuid.uuid4().hex}:{hashlib.sha256(query.encode('utf-8')).hexdigest()}",
user_id=resolved_user, organization_id=resolved_org, campaign_id=campaign_id,
trace_id=trace_id, model=model, quantity=estimate_tokens(query),
metadata={"kb_ids": ids, "operation": "query"})
def recall_scope(scope):
"""召回单个知识库;独立 I/O 并行,避免多库检索按库串行等待。"""
kid, rev, _ = scope
whitelist = allowed[kid]
try:
scope_started = time.perf_counter()
# Apply the same metadata scope to BOTH independent recalls.
vhits = store.search(kid, qv, k=limit, **({'file_names': whitelist} if whitelist is not None else {}))
vector_metric = getattr(store, "last_search_metric", lambda: {})()
# lexical_search itself is bounded: it reuses the ready
# full-library index for category scopes, or returns vector-only
# while an absent index is being prewarmed in the background.
lhits = hybrid.lexical_search(resolved_org, kid, rev, query, limit, whitelist)
lexical_metric = getattr(hybrid, "last_lexical_metric", lambda: {})()
channels = []
for hits in (vhits, lhits):
rows = []
for channel_rank, item in enumerate(hits, 1):
if whitelist is not None and (item.get('meta') or {}).get('file') not in whitelist:
raise RuntimeError('检索返回了所选分类以外的文件')
item = dict(item)
item['kb_id'], item['kb_name'] = kid, kb_rows[kid].name
item['channel_rank'] = channel_rank
rows.append(item)
channels.append(rows)
if hybrid.revision(kid) != rev:
raise RuntimeError('知识库索引正在更新,请稍后重新检索')
return channels, {
"kb_id": kid,
"kb_name": kb_rows[kid].name,
"collection_get_ms": vector_metric.get("collection_get_ms", 0.0),
"metadata_filter_ms": round(float(vector_metric.get("metadata_filter_ms", 0.0) or 0.0)
+ float(lexical_metric.get("metadata_filter_ms", 0.0) or 0.0), 2),
"vector_ms": vector_metric.get("vector_ms", 0.0),
"fts_ms": lexical_metric.get("fts_ms", 0.0),
"fts_connection_reused": bool(lexical_metric.get("connection_reused", False)),
"scope_ms": round((time.perf_counter() - scope_started) * 1000, 2),
"warm_state": lexical_metric.get("warm_state") or hybrid.warm_state(resolved_org, kid, rev, whitelist),
}
except Exception as exc:
logging.getLogger('yawen.rag.search').exception('知识库 %s 检索失败', kid)
raise RuntimeError(f'知识库 {kid} 检索失败:{exc}') from exc
# Vector and FTS calls are network/disk bound. Running the independent
# knowledge-base scopes concurrently cuts multi-library latency to the
# slowest scope while keeping the same bounded candidate limit.
recall_started = time.perf_counter()
if len(scopes) > 1:
# The process-wide bounded pool keeps recall concurrency stable and
# lets each worker reuse its read-only FTS connection across
# requests. ``executor.map`` still preserves scope ordering.
recalled = list(_recall_executor.map(recall_scope, scopes))
elif scopes:
recalled = [recall_scope(scopes[0])]
else:
recalled = []
scope_metrics = [item[1] for item in recalled]
recalled_channels = [item[0] for item in recalled]
vector = [item for channels in recalled_channels for item in channels[0]]
lexical = [item for channels in recalled_channels for item in channels[1]]
recall_ms = (time.perf_counter() - recall_started) * 1000
# Channel scores are ordered across libraries before fusion; incomparable
# per-library FTS BM25 scores use reciprocal positions instead of raw sums.
vector.sort(key=lambda row: (float(row.get('distance') or 0.0), int(row['kb_id']), str(row['id'])))
lexical.sort(key=lambda row: (row['channel_rank'], int(row['kb_id']), str(row['id'])))
pool = {'vector': vector, 'lexical': lexical}
hybrid.cache_put(key, pool)
else:
# Preserve per-library visibility on cache hits. Stage durations are
# zero because no vector/FTS I/O ran, while warm state still tells the
# operator whether the cached pool was backed by a ready index.
scope_metrics = [{
"kb_id": kid, "kb_name": kb_rows[kid].name,
"collection_get_ms": 0.0, "metadata_filter_ms": 0.0,
"vector_ms": 0.0, "fts_ms": 0.0, "scope_ms": 0.0,
"fts_connection_reused": False,
"warm_state": hybrid.warm_state(resolved_org, kid, rev, allowed[kid]),
} for kid, rev, _ in scopes]
postprocess_started = time.perf_counter()
# Re-check consumption BEFORE bounded candidate reranking on every request.
# Cached pools are never final, previously authorized answers.
rerank_limit = min(128, max(32, k * 2))
filtered = {}
for channel in ('vector', 'lexical'):
filtered[channel] = [row for row in pool[channel]
if row['id'] not in exclusions.get(int(row['kb_id']), set())][:rerank_limit]
candidates = hybrid.fuse(filtered['vector'], filtered['lexical'], query)
# Optional local Cross-Encoder rerank. The module is deliberately lazy and
# returns the fused order unchanged when no model is configured or loading
# fails, so the default offline deployment keeps the existing latency and
# behaviour guarantees.
from . import rerank as _rerank
candidates = _rerank.rerank(candidates, query, limit=min(64, max(16, k * 4)))
rerank_metric = _rerank.last_metric()
fusion_metric = hybrid.last_fusion_metric() if not cache_hit else {}
for kid, rev, _ in scopes:
if hybrid.revision(kid) != rev:
raise RuntimeError('知识库索引正在更新,请稍后重新检索')
results = hybrid.finalize(candidates, k, exclusions, allowed)
postprocess_ms = (time.perf_counter() - postprocess_started) * 1000
timings = {
"cache_hit": cache_hit,
"validation_ms": round(validation_ms, 2),
"embedding_ms": round(embedding_ms, 2),
"recall_ms": round(recall_ms, 2),
"postprocess_ms": round(postprocess_ms, 2),
"total_ms": round((time.perf_counter() - started) * 1000, 2),
"retrieval_scopes": scope_metrics,
"warm_state": {str(row.get("kb_id")): row.get("warm_state") for row in scope_metrics},
"merge_ms": fusion_metric.get("merge_ms", 0.0),
"dedup_ms": fusion_metric.get("dedup_ms", 0.0),
"rrf_ms": fusion_metric.get("rrf_ms", 0.0),
"rerank_enabled": bool(rerank_metric.get("enabled", False)),
"rerank_model": rerank_metric.get("model", ""),
"rerank_model_loaded": bool(rerank_metric.get("model_loaded", False)),
"rerank_candidate_count": int(rerank_metric.get("candidate_count", 0) or 0),
"rerank_count": int(rerank_metric.get("reranked_count", 0) or 0),
"rerank_model_load_ms": rerank_metric.get("model_load_ms", 0.0),
"rerank_inference_ms": rerank_metric.get("inference_ms", 0.0),
"rerank_fallback": bool(rerank_metric.get("fallback", True)),
}
if pool is not None:
metric = locals().get("embedding_metric") or {}
if metric:
query_cache = getattr(embed, "query_cache_snapshot", lambda: {})()
timings.update({
"embedding_source": metric.get("embedding_source"),
"embedding_count": metric.get("embedding_count", 1),
"embedding_model": metric.get("model", metric.get("model_name")),
"embedding_version": metric.get("model_version", metric.get("version")),
"embedding_pid": metric.get("pid", metric.get("process_id")),
"embedding_dim": metric.get("embedding_dimension", metric.get("embedding_dim", 0)),
"embedding_cache_hit": metric.get("cache_hit", False),
"embedding_model_load_ms": metric.get("model_load_ms", 0.0),
"embedding_tokenizer_ms": metric.get("tokenizer_ms", 0.0),
"embedding_encode_ms": metric.get("encode_ms", metric.get("encode_inference_ms", 0.0)),
"embedding_postprocess_ms": metric.get("postprocess_ms", metric.get("postprocess_serialization_ms", 0.0)),
"embedding_process_start_ms": metric.get("process_start_ms", 0.0),
"embedding_ipc_ms": metric.get("ipc_ms", 0.0),
"embedding_total_ms": metric.get("total_ms", embedding_ms),
"embedding_cache_size": query_cache.get("size", 0),
"embedding_cache_hits": query_cache.get("hits", 0),
"embedding_cache_misses": query_cache.get("misses", 0),
"embedding_cache_hit_rate": query_cache.get("hit_rate", 0.0),
})
_record_retrieval_metrics(timings, len(results))
for item in results:
item["retrieval_timings"] = timings
logging.getLogger('yawen.rag.search').info(
'知识库检索完成:%s 个库,%s 个候选,%s 条结果,缓存%s,%.3f 秒',
len(ids), len(candidates), len(results), '命中' if cache_hit else '未命中', time.perf_counter() - started)
return results
def import_folder(session: Session, kb_id: int, folder_zip: str, rel_base: str = "", progress_cb=None) -> dict:
"""导入已解压的文件夹(含多层子文件夹)。
file_paths 里每个条目形如 "相对路径\t绝对路径",相对路径用于来源标注。"""
entries = []
for line in str(folder_zip).split("\n"):
line = line.strip()
if not line or "\t" not in line:
continue
rel, abs_path = line.rsplit("\t", 1)
entries.append((rel, abs_path))
if not entries:
raise ValueError("文件夹里没有可导入的文件")
def cb(name: str, n: int):
if progress_cb:
progress_cb(name, n)
return import_kb_files(session, kb_id, [abs_path for _, abs_path in entries], progress_cb=cb)