Files

789 lines
35 KiB
Python
Raw Permalink Normal View History

"""RAG 向量索引(collection=知识库),显式选择 Chroma 或 Qdrant。"""
import hashlib
import logging
import re
import shutil
import sqlite3
import json
import math
import time
import uuid
import threading
from ..config import CHROMA_DIR, DATA_DIR
from . import backend
logger = logging.getLogger("yawen.rag.store")
class VectorIndexDisabled(RuntimeError):
"""Raised when an operation requires a vector backend but it is disabled."""
_client = None
_client_init_lock = threading.Lock()
FALLBACK_DIR = DATA_DIR / "vector_fallback"
FALLBACK_DIR.mkdir(parents=True, exist_ok=True)
_count_cache: dict[int, tuple[str, dict[str, int]]] = {}
_search_metric_local = threading.local()
COUNT_CACHE_DIR = DATA_DIR / "vector_counts"
COUNT_CACHE_DIR.mkdir(parents=True, exist_ok=True)
def _count_cache_path(kb_id: int):
return COUNT_CACHE_DIR / f"kb_{int(kb_id)}.json"
def kb_revision(kb_id: int) -> str:
"""Cross-process version of one library, namespaced to this data directory."""
marker = COUNT_CACHE_DIR / f"kb_{int(kb_id)}.revision"
parts = [backend.namespace(), str(CHROMA_DIR.resolve()), str(FALLBACK_DIR.resolve()), str(marker.resolve())]
try:
parts.append(marker.read_text(encoding="ascii"))
except FileNotFoundError:
parts.append("initial")
if not backend.is_qdrant():
fallback = _fallback_path(kb_id)
try:
stat = fallback.stat()
parts.append(f"{stat.st_ino}:{stat.st_size}:{stat.st_mtime_ns}:{stat.st_ctime_ns}")
except FileNotFoundError:
pass
return hashlib.sha256("\0".join(parts).encode()).hexdigest()
def _bump_revision(kb_id: int) -> None:
COUNT_CACHE_DIR.mkdir(parents=True, exist_ok=True)
marker = COUNT_CACHE_DIR / f"kb_{int(kb_id)}.revision"
temporary = marker.with_suffix(f".{uuid.uuid4().hex}.tmp")
try:
temporary.write_text(uuid.uuid4().hex, encoding="ascii")
temporary.replace(marker)
finally:
temporary.unlink(missing_ok=True)
def _invalidate_count_cache(kb_id: int) -> None:
_bump_revision(kb_id)
_count_cache.pop(int(kb_id), None)
try:
_count_cache_path(kb_id).unlink(missing_ok=True)
except OSError:
pass
def _fallback_path(kb_id: int):
return FALLBACK_DIR / f"kb_{int(kb_id)}.json"
def _read_fallback(kb_id: int) -> dict | None:
if backend.is_qdrant():
return None
path = _fallback_path(kb_id)
if not path.exists():
return None
try:
payload = json.loads(path.read_text(encoding="utf-8"))
if isinstance(payload, dict) and isinstance(payload.get("items"), list):
return payload
except (OSError, ValueError, TypeError):
return None
return None
def _write_fallback(kb_id: int, items: list[dict]) -> None:
path = _fallback_path(kb_id)
temp = path.with_suffix(".tmp")
payload = {"version": 1, "kb_id": int(kb_id), "items": items}
temp.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
temp.replace(path)
_bump_revision(kb_id)
class _StreamingJSON:
"""只保留当前字段的 JSON 流;向量字段逐字符跳过,不构造 embedding 数组。"""
def __init__(self, handle):
self.handle, self.buffer, self.pos, self.eof = handle, "", 0, False
def peek(self):
if self.pos >= len(self.buffer) and not self.eof:
self.buffer, self.pos = self.handle.read(65536), 0
self.eof = not self.buffer
return self.buffer[self.pos:self.pos + 1]
def take(self):
value = self.peek()
self.pos += bool(value)
return value
def whitespace(self):
while self.peek() and self.peek().isspace():
self.take()
def expect(self, value):
self.whitespace()
if self.take() != value:
raise ValueError("索引 JSON 结构损坏")
def value(self, retain=True):
self.whitespace()
first = self.peek()
if not first:
raise ValueError("索引 JSON 意外结束")
output, depth, quoted, escaped = [], 0, False, False
while self.peek():
char = self.peek()
if not quoted and depth == 0 and output and (char in ",]}" or char.isspace()):
break
# 不保留的值也需要一个已开始标记,避免把后续逗号吞掉。
if not quoted and depth == 0 and not retain and output and (char in ",]}" or char.isspace()):
break
char = self.take()
if retain:
output.append(char)
elif not output:
output.append(True)
if quoted:
if escaped:
escaped = False
elif char == "\\":
escaped = True
elif char == '"':
quoted = False
elif char == '"':
quoted = True
elif char in "[{":
depth += 1
elif char in "]}":
depth -= 1
if depth == 0 and not quoted and first in '[{"':
break
if quoted or depth:
raise ValueError("索引 JSON 字段不完整")
return json.loads("".join(output)) if retain else None
def _stream_fallback(kb_id: int):
path = _fallback_path(kb_id)
before = path.stat()
with path.open(encoding="utf-8") as handle:
stream, found = _StreamingJSON(handle), False
stream.expect("{")
while True:
stream.whitespace()
if stream.peek() == "}":
stream.take()
break
key = stream.value()
stream.expect(":")
if key == "items":
if found:
raise ValueError("索引包含重复 items 字段")
found = True
stream.expect("[")
while True:
stream.whitespace()
if stream.peek() == "]":
stream.take()
break
stream.expect("{")
item = {}
while True:
stream.whitespace()
if stream.peek() == "}":
stream.take()
break
field = stream.value()
stream.expect(":")
if field in {"id", "text", "meta"}:
item[field] = stream.value()
else:
stream.value(retain=False)
stream.whitespace()
if stream.peek() == "}":
continue
stream.expect(",")
yield item
stream.whitespace()
if stream.peek() == "]":
continue
stream.expect(",")
elif key == "kb_id":
if stream.value() != kb_id:
raise ValueError("索引知识库编号不匹配")
else:
stream.value(retain=False)
stream.whitespace()
if stream.peek() == "}":
continue
stream.expect(",")
stream.whitespace()
if stream.peek() or not found:
raise ValueError("索引 JSON 缺少正文或包含多余数据")
after = path.stat()
if (before.st_ino, before.st_size, before.st_mtime_ns) != (after.st_ino, after.st_size, after.st_mtime_ns):
raise ValueError("读取过程中知识库索引发生变化,请在索引完成后重新评估")
def iter_chunks(kb_id: int, page_size: int = 256, file_names: set[str] | None = None):
"""顺序遍历完整索引正文;不做相似度筛选,不加载向量,不改变消费记录。"""
if backend.is_disabled():
return
if isinstance(kb_id, bool) or not isinstance(kb_id, int) or kb_id <= 0:
raise ValueError("知识库编号无效")
if not isinstance(page_size, int) or not 1 <= page_size <= 4000:
raise ValueError("索引分页大小无效")
if file_names is not None and not file_names:
return
if backend.is_qdrant():
revision = kb_revision(kb_id)
collection = get_client().get_collection(collection_name(kb_id))
total = collection.count()
seen = set()
groups = [None] if file_names is None else [sorted(file_names)[i:i + 128] for i in range(0, len(file_names), 128)]
for names in groups:
where = None if names is None else {"file": {"$in": names}}
for page in collection.iter_records(where=where, page_size=page_size):
for index, identifier in enumerate(page['ids']):
meta = page['metadatas'][index]
if identifier in seen:
raise RuntimeError("知识索引分页返回重复片段,请重新评估")
seen.add(identifier)
if meta.get('kb_id') not in (None, kb_id):
raise RuntimeError("知识索引返回了其他知识库的片段")
if file_names is not None and meta.get('file') not in file_names:
raise RuntimeError("知识索引返回了分类范围外的文件")
yield {"id": identifier, "text": page['documents'][index], "meta": meta}
if collection.count() != total or kb_revision(kb_id) != revision or (file_names is None and len(seen) != total):
raise RuntimeError("读取过程中知识索引数量变化,请重新评估")
return
if _fallback_path(kb_id).exists():
try:
for item in _stream_fallback(kb_id):
if file_names is None or (item.get('meta') or {}).get('file') in file_names:
yield item
except (OSError, ValueError, TypeError) as exc:
raise RuntimeError(f"知识库 {kb_id} 本地索引读取失败:{exc}") from exc
return
try:
collection = get_client().get_collection(collection_name(kb_id))
total, offset = collection.count(), 0
if file_names is not None:
# Filter in Chroma before paging; only selected-category documents are read.
names = sorted(file_names)
for start in range(0, len(names), 128):
offset = 0
while True:
page = collection.get(include=["documents", "metadatas"], limit=page_size, offset=offset,
where={"file": {"$in": names[start:start + 128]}})
ids = _as_list(page.get("ids"))
documents, metadata = _as_list(page.get("documents")), _as_list(page.get("metadatas"))
if len(documents) != len(ids) or len(metadata) != len(ids):
raise ValueError("分类索引正文或来源缺失")
for index, identifier in enumerate(ids):
meta = metadata[index] or {}
if meta.get('file') not in file_names:
raise ValueError("分类索引返回了范围外的文件")
yield {"id": identifier, "text": documents[index], "meta": meta}
offset += len(ids)
if len(ids) < page_size:
break
if collection.count() != total:
raise ValueError("读取过程中索引数量变化,请重新评估")
return
while offset < total:
page = collection.get(include=["documents", "metadatas"], limit=page_size, offset=offset)
ids = _as_list(page.get("ids"))
documents, metadata = _as_list(page.get("documents")), _as_list(page.get("metadatas"))
if not ids or len(documents) != len(ids) or len(metadata) != len(ids):
raise ValueError("索引分页正文或来源缺失")
for index, identifier in enumerate(ids):
yield {"id": identifier, "text": documents[index], "meta": metadata[index] or {}}
offset += len(ids)
if offset != total or collection.count() != total:
raise ValueError("读取过程中索引数量变化,请重新评估")
except Exception as exc:
raise RuntimeError(f"知识库 {kb_id} 索引读取失败:{exc}") from exc
def _as_list(value, default: list | None = None) -> list:
"""把 Chroma/NumPy 返回的序列安全转换为普通列表,避免 array 的布尔判断异常。"""
if value is None:
return list(default or [])
if isinstance(value, list):
return value
try:
return list(value)
except TypeError:
return list(default or [])
def get_client():
if backend.is_disabled():
configured = backend.configured_backend()
if configured == "chroma":
raise VectorIndexDisabled("本地向量索引未启用:未安装 chromadb,请使用轻量模式或安装可选 RAG 依赖")
raise VectorIndexDisabled("本地向量索引已禁用:设置 RAG_VECTOR_BACKEND=chroma/qdrant 后才可访问向量库")
if backend.is_qdrant():
from .qdrant_store import get_client as remote_client
return remote_client()
global _client
if _client is None:
with _client_init_lock:
if _client is None:
try:
import chromadb
except ImportError as exc:
raise VectorIndexDisabled("缺少 chromadb,本地向量索引不可用") from exc
_client = chromadb.PersistentClient(path=str(CHROMA_DIR))
return _client
def collection_name(kb_id: int) -> str:
return f"kb_{kb_id}"
def ensure_collection(kb_id: int):
started = time.perf_counter()
collection = get_client().get_or_create_collection(collection_name(kb_id))
metric = getattr(_search_metric_local, "metric", None)
if metric is not None:
metric["collection_get_ms"] = round((time.perf_counter() - started) * 1000, 2)
return collection
def last_search_metric() -> dict:
"""Return timings for the most recent vector search on this thread.
Search requests fan out across threads, so a thread-local envelope avoids
cross-library races while keeping the public ``search`` signature intact.
"""
return dict(getattr(_search_metric_local, "metric", {}) or {})
def _chunk_id(file_name: str, item: dict, occurrence: int) -> str:
"""用来源和正文生成稳定 ID,重建整个库也不会改变同一片段的消费记录。"""
meta = item.get("meta", {})
heading = str(meta.get("heading") or "")
text = " ".join(str(item.get("text") or "").split())
raw = f"{file_name}\0{heading}\0{text}\0{occurrence}".encode("utf-8")
return "chunk-" + hashlib.sha256(raw).hexdigest()
def _file_rows(collection, file_name: str) -> list[dict]:
if hasattr(collection, "iter_records"):
total = collection.count(where={"file": file_name})
rows = []
for data in collection.iter_records(where={"file": file_name}, include=["documents", "metadatas", "embeddings"]):
rows.extend({"id": identifier, "text": data["documents"][i], "embedding": data["embeddings"][i], "meta": data["metadatas"][i]}
for i, identifier in enumerate(data["ids"]))
if len(rows) != total or collection.count(where={"file": file_name}) != total:
raise RuntimeError("文件索引快照不完整,已取消修改")
return rows
rows, offset = [], 0
while True:
data = collection.get(where={"file": file_name}, include=["documents", "embeddings", "metadatas"],
limit=4000, offset=offset)
ids = _as_list(data.get("ids"))
texts, vectors, metas = (_as_list(data.get(key)) for key in ("documents", "embeddings", "metadatas"))
if any(len(values) != len(ids) for values in (texts, vectors, metas)):
raise RuntimeError("文件索引快照不完整,已取消修改")
rows.extend({"id": ident, "text": texts[i], "embedding": _as_list(vectors[i]), "meta": metas[i] or {}}
for i, ident in enumerate(ids))
offset += len(ids)
if len(ids) < 4000:
return rows
def snapshot_file(kb_id: int, file_name: str) -> dict:
"""Snapshot the file in each existing index; never seed a partial fallback."""
if backend.is_disabled():
return {"kb_id": kb_id, "file_name": file_name, "fallback_exists": False,
"backend_namespace": backend.namespace(), "fallback_items": [], "chroma_items": []}
exists = not backend.is_qdrant() and _fallback_path(kb_id).exists()
fallback = _read_fallback(kb_id) if exists else None
if exists and fallback is None:
raise RuntimeError("本地向量索引损坏,已取消修改")
return {"kb_id": kb_id, "file_name": file_name, "fallback_exists": exists,
"backend_namespace": backend.namespace(),
"fallback_items": [row for row in (fallback or {}).get("items", [])
if (row.get("meta") or {}).get("file") == file_name],
"chroma_items": _file_rows(ensure_collection(kb_id), file_name)}
def _insert_rows(collection, rows: list[dict]):
for start in range(0, len(rows), 4000):
batch = rows[start:start + 4000]
collection.add(ids=[row["id"] for row in batch], documents=[row["text"] for row in batch],
embeddings=[row["embedding"] for row in batch], metadatas=[row["meta"] for row in batch])
def restore_file(kb_id: int, file_name: str, snapshot: dict) -> None:
if snapshot.get("kb_id") != kb_id or snapshot.get("file_name") != file_name:
raise ValueError("文件索引快照归属不匹配")
if snapshot.get("backend_namespace", backend.namespace()) != backend.namespace():
raise ValueError("文件索引快照所属后端已改变,已取消恢复")
collection = ensure_collection(kb_id)
try:
collection.delete(where={"file": file_name})
_insert_rows(collection, snapshot["chroma_items"])
if snapshot["fallback_exists"]:
fallback = _read_fallback(kb_id)
if fallback is None:
raise RuntimeError("回滚时本地索引不可读取")
others = [row for row in fallback["items"] if (row.get("meta") or {}).get("file") != file_name]
_write_fallback(kb_id, others + snapshot["fallback_items"])
finally:
_invalidate_count_cache(kb_id)
def _prepared_rows(kb_id, chunks, embeddings, file_name, category_path):
if len(chunks) != len(embeddings):
raise ValueError("分段数量与向量数量不一致,已取消写入")
rows, occurrences = [], {}
for i, item in enumerate(chunks):
if not isinstance(item.get("text"), str) or not item["text"].strip():
raise ValueError("索引片段正文为空,已取消写入")
vector = _as_list(embeddings[i])
if not vector or any(not math.isfinite(float(x)) for x in vector):
raise ValueError("索引向量无效,已取消写入")
if backend.is_qdrant() and len(vector) != 512:
raise ValueError("Qdrant 索引向量必须是 512 维,已取消写入")
fingerprint = f"{item.get('meta', {}).get('heading', '')}\0{' '.join(item['text'].split())}"
occurrences[fingerprint] = occurrences.get(fingerprint, 0) + 1
ident = _chunk_id(file_name, item, occurrences[fingerprint])
meta = dict(item.get("meta") or {})
meta.setdefault("seq", i)
from .embed import EMBEDDING_POLICY
meta.update(kb_id=kb_id, file=file_name, chunk_hash=ident.removeprefix("chunk-"), embedding_policy=EMBEDDING_POLICY)
if category_path:
meta["category_path"] = category_path
rows.append({"id": ident, "text": item["text"], "embedding": vector, "meta": meta})
return rows
def replace_file_chunks(kb_id: int, chunks: list[dict], embeddings: list[list[float]],
file_name: str, category_path: str = "") -> None:
if backend.is_disabled():
raise VectorIndexDisabled("本地向量索引已禁用,不能写入向量片段")
"""Prepare before mutation; failed replacements restore that file in both stores.
Chroma-only libraries stay Chroma-only. A fallback, when already present,
continues to contain every other file rather than shadowing the whole library
with only this upload. Callers serialize writers with the per-KB import lock.
"""
rows = _prepared_rows(kb_id, chunks, embeddings, file_name, category_path)
previous = snapshot_file(kb_id, file_name)
collection = ensure_collection(kb_id)
_invalidate_count_cache(kb_id)
try:
collection.delete(where={"file": file_name})
_insert_rows(collection, rows)
if previous["fallback_exists"]:
fallback = _read_fallback(kb_id)
if fallback is None:
raise RuntimeError("本地向量索引无法读取")
others = [row for row in fallback["items"] if (row.get("meta") or {}).get("file") != file_name]
_write_fallback(kb_id, others + rows)
_invalidate_count_cache(kb_id)
except Exception as exc:
try:
restore_file(kb_id, file_name, previous)
except Exception as recovery:
raise RuntimeError(f"文件索引更新失败,恢复也失败,请保留源文件并检查索引:{recovery}") from exc
raise RuntimeError(f"文件索引更新失败,已恢复原索引:{exc}") from exc
def add_chunks(kb_id: int, chunks: list[dict], embeddings: list[list[float]],
file_name: str, category_path: str = ""):
return replace_file_chunks(kb_id, chunks, embeddings, file_name, category_path)
def delete_file(kb_id: int, file_name: str):
if backend.is_disabled():
_invalidate_count_cache(kb_id)
return
return replace_file_chunks(kb_id, [], [], file_name)
def count_by_file(kb_id: int) -> dict:
"""按当前后端及版本缓存文件片段数;Qdrant 故障不读取旧 Chroma 缓存。"""
if backend.is_disabled():
return {}
remote = backend.is_qdrant()
revision = kb_revision(kb_id)
cache_path = _count_cache_path(kb_id)
try:
# An exact remote count also verifies service availability before cached
# statistics are returned. The endpoint is cheap and does not load text.
coll = ensure_collection(kb_id) if remote else None
total = coll.count() if remote else None
cached = _count_cache.get(int(kb_id))
if cached and cached[0] == revision and (total is None or sum(cached[1].values()) == total):
return dict(cached[1])
try:
payload = json.loads(cache_path.read_text(encoding="utf-8"))
counts = payload.get("counts") if isinstance(payload, dict) else None
if payload.get("version") == 2 and payload.get("kb_id") == int(kb_id) and payload.get("revision") == revision and isinstance(counts, dict):
if any(not isinstance(k, str) or isinstance(v, bool) or not isinstance(v, int) or v < 0 for k, v in counts.items()):
raise ValueError("知识索引统计缓存无效")
if total is None or sum(counts.values()) == total:
_count_cache[int(kb_id)] = (revision, counts)
return dict(counts)
except (OSError, ValueError, TypeError, AttributeError):
pass
out: dict[str, int] = {}
if not remote and _fallback_path(kb_id).exists():
records = (item.get("meta") or {} for item in _stream_fallback(kb_id))
for meta in records:
name = meta.get("file") or "未知"
out[name] = out.get(name, 0) + 1
else:
coll = coll if coll is not None else ensure_collection(kb_id)
if coll is None:
return {}
if hasattr(coll, "iter_records"):
pages = coll.iter_records(include=["metadatas"])
for data in pages:
for meta in data["metadatas"]:
name = meta.get("file") or "未知"
out[name] = out.get(name, 0) + 1
else:
offset, page = 0, 5000
while True:
got = coll.get(include=["metadatas"], limit=page, offset=offset)
metas = got.get("metadatas") or []
for meta in metas:
name = (meta or {}).get("file") or "未知"
out[name] = out.get(name, 0) + 1
offset += len(metas)
if len(metas) < page:
break
if kb_revision(kb_id) != revision or (remote and (sum(out.values()) != total or coll.count() != total)):
raise RuntimeError("统计过程中知识索引发生变化,请重新加载")
_count_cache[int(kb_id)] = (revision, out)
temporary = cache_path.with_suffix(f".{uuid.uuid4().hex}.tmp")
try:
cache_path.parent.mkdir(parents=True, exist_ok=True)
temporary.write_text(json.dumps({"version": 2, "kb_id": int(kb_id), "revision": revision, "counts": out},
ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
temporary.replace(cache_path)
except OSError:
pass
finally:
temporary.unlink(missing_ok=True)
return dict(out)
except Exception:
if remote:
raise
return {}
def count(kb_id: int) -> int:
if backend.is_disabled():
return 0
fallback = _read_fallback(kb_id)
if fallback is not None:
return len(fallback.get("items", []))
try:
return ensure_collection(kb_id).count()
except Exception:
if backend.is_qdrant():
raise
return 0
def search(kb_id: int, query_embedding: list[float], k: int = 6, file_names: set[str] | None = None) -> list[dict]:
if backend.is_disabled():
return []
metric = {"kb_id": int(kb_id), "metadata_filter_ms": 0.0,
"vector_ms": 0.0, "collection_get_ms": 0.0}
_search_metric_local.metric = metric
started = time.perf_counter()
if file_names is not None and not file_names:
metric["total_ms"] = round((time.perf_counter() - started) * 1000, 2)
return []
fallback = _read_fallback(kb_id)
if fallback is not None:
filter_started = time.perf_counter()
if file_names is not None:
filtered = [item for item in fallback.get("items", [])
if (item.get("meta") or {}).get("file") in file_names]
else:
filtered = list(fallback.get("items", []))
metric["metadata_filter_ms"] = round((time.perf_counter() - filter_started) * 1000, 2)
vector_started = time.perf_counter()
def cosine_distance(vector: list[float]) -> float:
dot = sum(float(a) * float(b) for a, b in zip(query_embedding, vector))
left = math.sqrt(sum(float(a) * float(a) for a in query_embedding)) or 1.0
right = math.sqrt(sum(float(b) * float(b) for b in vector)) or 1.0
return 1.0 - dot / (left * right)
ranked = sorted(filtered, key=lambda item: cosine_distance(item.get("embedding", [])))[:k]
metric["vector_ms"] = round((time.perf_counter() - vector_started) * 1000, 2)
metric["total_ms"] = round((time.perf_counter() - started) * 1000, 2)
return [{"id": item.get("id"), "text": item.get("text", ""),
"meta": item.get("meta", {}), "distance": cosine_distance(item.get("embedding", []))}
for item in ranked]
coll = ensure_collection(kb_id)
# Chroma and Qdrant both return an empty result for an empty collection.
# Do not call ``count()`` on the hot retrieval path: on an external disk
# it can scan collection metadata (over 1.7s cold for the 108k-chunk KB),
# while the bounded nearest-neighbor query itself is only a few ms.
# Keeping ``available`` unknown also removes a redundant remote count for
# Qdrant; the backend safely truncates to the number of stored points.
available = None
if file_names is not None:
candidates = []
names = sorted(file_names)
for start in range(0, len(names), 128):
vector_started = time.perf_counter()
result = coll.query(query_embeddings=[query_embedding], n_results=k,
where={"file": {"$in": names[start:start + 128]}})
metric["vector_ms"] += (time.perf_counter() - vector_started) * 1000
filter_started = time.perf_counter()
for i, identifier in enumerate((result.get('ids') or [[]])[0]):
meta = result['metadatas'][0][i] or {}
if meta.get('file') in file_names:
candidates.append({'id': identifier, 'text': result['documents'][0][i], 'meta': meta,
'distance': result['distances'][0][i]})
metric["metadata_filter_ms"] += (time.perf_counter() - filter_started) * 1000
metric["metadata_filter_ms"] = round(metric["metadata_filter_ms"], 2)
metric["vector_ms"] = round(metric["vector_ms"], 2)
metric["total_ms"] = round((time.perf_counter() - started) * 1000, 2)
return sorted(candidates, key=lambda item: item['distance'])[:k]
vector_started = time.perf_counter()
res = coll.query(query_embeddings=[query_embedding], n_results=k)
metric["vector_ms"] = round((time.perf_counter() - vector_started) * 1000, 2)
out = []
if res and res.get("ids") and res["ids"][0]:
for i, doc_id in enumerate(res["ids"][0]):
out.append({
"id": doc_id,
"text": res["documents"][0][i],
"meta": res["metadatas"][0][i] if res.get("metadatas") else {},
"distance": res["distances"][0][i] if res.get("distances") else None,
})
metric["total_ms"] = round((time.perf_counter() - started) * 1000, 2)
return out
def _vector_segment_dirs(kb_id: int) -> list:
"""在删除 collection 前记录其 HNSW 段目录;只返回当前 collection 的 UUID 目录。"""
db_path = CHROMA_DIR / "chroma.sqlite3"
if not db_path.exists():
return []
with sqlite3.connect(str(db_path)) as conn:
rows = conn.execute(
"""SELECT s.id FROM collections c JOIN segments s ON s.collection = c.id
WHERE c.name = ? AND s.scope = 'VECTOR'""", (collection_name(kb_id),)
).fetchall()
root = CHROMA_DIR.resolve()
dirs = []
for (segment_id,) in rows:
if not re.fullmatch(r"[0-9a-f-]{36}", str(segment_id)):
continue
candidate = (root / str(segment_id)).resolve()
if candidate.parent == root:
dirs.append(candidate)
return dirs
def reset_kb(kb_id: int):
if backend.is_disabled():
_fallback_path(kb_id).unlink(missing_ok=True)
_invalidate_count_cache(kb_id)
return
if backend.is_qdrant():
_invalidate_count_cache(kb_id)
get_client().delete_collection(collection_name(kb_id))
_invalidate_count_cache(kb_id)
return
fallback_path = _fallback_path(kb_id)
if fallback_path.exists():
fallback_path.unlink()
_invalidate_count_cache(kb_id)
segment_dirs = _vector_segment_dirs(kb_id)
try:
get_client().delete_collection(collection_name(kb_id))
except Exception:
# collection 不存在时无需清理;其余失败交给调用方,避免误删仍被引用的段目录。
if segment_dirs:
raise
return
_invalidate_count_cache(kb_id)
for directory in segment_dirs:
if directory.exists():
shutil.rmtree(directory)
def snapshot_kb(kb_id: int) -> dict:
"""重建前导出当前 collection,供分块/嵌入异常时原样恢复。"""
if backend.is_disabled():
return {"ids": [], "documents": [], "embeddings": [], "metadatas": []}
fallback = _read_fallback(kb_id)
if fallback is not None:
return {"ids": [item.get("id") for item in fallback["items"]],
"documents": [item.get("text", "") for item in fallback["items"]],
"embeddings": [item.get("embedding", []) for item in fallback["items"]],
"metadatas": [item.get("meta", {}) for item in fallback["items"]]}
coll = ensure_collection(kb_id)
if coll.count() <= 0:
return {"ids": [], "documents": [], "embeddings": [], "metadatas": []}
if hasattr(coll, "iter_records"):
revision, total = kb_revision(kb_id), coll.count()
result = {"ids": [], "documents": [], "embeddings": [], "metadatas": []}
for page in coll.iter_records(include=["documents", "metadatas", "embeddings"]):
for key in result:
result[key].extend(page[key])
if len(result["ids"]) != total or coll.count() != total or kb_revision(kb_id) != revision:
raise RuntimeError("知识库索引快照不完整,已取消重建")
return result
# 大库分页导出(单次全量 get 会触发 sqlite 变量上限)
ids: list = []
documents: list = []
embeddings: list = []
metadatas: list = []
page = 4000
offset = 0
while True:
data = coll.get(include=["documents", "embeddings", "metadatas"], limit=page, offset=offset)
batch_ids = _as_list(data.get("ids"))
if not batch_ids:
break
ids.extend(batch_ids)
documents.extend(_as_list(data.get("documents")))
embeddings.extend(_as_list(data.get("embeddings")))
metadatas.extend(_as_list(data.get("metadatas")))
offset += len(batch_ids)
if len(batch_ids) < page:
break
return {
"ids": ids,
"documents": documents,
"embeddings": embeddings,
"metadatas": metadatas,
}
def restore_kb(kb_id: int, snapshot: dict) -> None:
"""仅用于重建失败回滚;恢复原 chunk id 和元数据,消费记录仍可对应。"""
ids = _as_list(snapshot.get("ids"))
if backend.is_disabled():
if ids:
raise VectorIndexDisabled("本地向量索引已禁用,无法恢复向量快照")
return
documents = _as_list(snapshot.get("documents"))
embeddings = _as_list(snapshot.get("embeddings"))
metadatas = _as_list(snapshot.get("metadatas"))
if any(len(values) != len(ids) for values in (documents, embeddings, metadatas)) or len(set(ids)) != len(ids):
raise ValueError("知识索引快照不完整,已取消恢复")
remote = backend.is_qdrant()
for identifier, text, vector, meta in zip(ids, documents, embeddings, metadatas):
if not isinstance(identifier, str) or not identifier or not isinstance(text, str) or not text.strip() or not isinstance(meta, dict):
raise ValueError("知识索引快照正文或来源无效,已取消恢复")
if not len(vector) or (remote and len(vector) != 512) or any(not math.isfinite(float(v)) for v in vector):
raise ValueError("知识索引快照向量无效,已取消恢复")
reset_kb(kb_id)
if not ids:
return
items = [{"id": ids[index], "text": documents[index],
"embedding": embeddings[index], "meta": metadatas[index]}
for index in range(len(ids))]
if not remote:
_write_fallback(kb_id, items)
ensure_collection(kb_id).add(
ids=ids,
documents=documents,
embeddings=embeddings,
metadatas=metadatas,
)
_invalidate_count_cache(kb_id)