feat(sprint1): показ эмбеддингов чанков на странице отладки

Расширяем просмотр документа, чтобы оператор видел не только текстовые
чанки, но и как они лежат в ChromaDB в виде векторов — по паттерну из
work-pcs-dr-cdss.

Backend:
- services/vectorstore.get_document_chunks теперь запрашивает
  include=["embeddings"] и отдаёт вектор как list[float]. Chroma
  возвращает numpy-массивы, поэтому проверка наличия embeddings
  сделана через len(), без or-шортката.
- models.ChunkDetail: поля embedding: list[float] + embedding_dim: int.
- routers/documents прокидывает вектор и размерность в ответ.

Frontend (static/index.html):
- В карточку чанка добавлен блок .chunk-card-actions с кнопкой
  «вектор (N dim)»; раскрывается в .embedding-box с полным списком
  координат (округление до 6 знаков, моноширинный шрифт, скролл).
- Функция toggleChunkText переписана через .closest + querySelector,
  чтобы не ломаться от новой обёртки кнопок.
- Добавлена toggleEmb(embId).

Проверено на загруженных документах — возвращается по 1024 координаты
(E5-large), совпадает с ожиданиями embedding-модели.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
AR 15 M4
2026-04-23 09:59:24 +05:00
parent ccc3dde978
commit 4a5695ed9c
4 changed files with 62 additions and 5 deletions
+52 -3
View File
@@ -151,6 +151,35 @@
background: none;
padding: 4px 0 0 0;
}
.chunk-card-actions {
display: flex;
gap: 14px;
align-items: center;
margin-top: 4px;
}
.embedding-box {
margin-top: 8px;
padding: 8px 10px;
background: #f0f4ff;
border: 1px solid #c7d2fe;
border-radius: 6px;
display: none;
}
.embedding-box.open { display: block; }
.emb-header {
font-size: 11px;
color: var(--muted);
margin-bottom: 4px;
}
.emb-values {
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
font-size: 10.5px;
line-height: 1.4;
max-height: 140px;
overflow-y: auto;
word-break: break-all;
color: #3730a3;
}
.empty {
padding: 20px;
color: var(--muted);
@@ -435,10 +464,15 @@ async function toggleChunks(id) {
body.innerHTML = '<div class="mini">чанков нет</div>';
return;
}
body.innerHTML = d.chunks.map(c => {
body.innerHTML = d.chunks.map((c, i) => {
const long = c.text.length > 300;
const short = long ? c.text.slice(0, 300) + "…" : c.text;
const safeFull = esc(c.text).replace(/"/g, "&quot;");
const embId = `emb-${id}-${c.index}-${i}`;
const dim = c.embedding_dim || (c.embedding ? c.embedding.length : 0);
const embValues = (c.embedding && c.embedding.length)
? "[" + c.embedding.map(v => v.toFixed(6)).join(", ") + "]"
: "—";
return `
<div class="chunk-card">
<div class="chunk-card-meta">
@@ -448,7 +482,16 @@ async function toggleChunks(id) {
<span>${c.char_length.toLocaleString("ru")} симв.</span>
</div>
<div class="chunk-card-text" data-short="${esc(short).replace(/"/g, "&quot;")}" data-full="${safeFull}" data-expanded="0">${esc(short)}</div>
${long ? '<button class="chunk-card-toggle" onclick="toggleChunkText(this)">показать полностью</button>' : ""}
<div class="chunk-card-actions">
${long ? '<button class="chunk-card-toggle" onclick="toggleChunkText(this)">показать полностью</button>' : ""}
${dim ? `<button class="chunk-card-toggle" onclick="toggleEmb('${embId}')">вектор (${dim} dim)</button>` : ""}
</div>
${dim ? `
<div class="embedding-box" id="${embId}">
<div class="emb-header">эмбеддинг · ${dim} компонент · cosine space</div>
<div class="emb-values">${embValues}</div>
</div>
` : ""}
</div>
`;
}).join("");
@@ -458,13 +501,19 @@ async function toggleChunks(id) {
}
function toggleChunkText(btn) {
const textEl = btn.previousElementSibling;
const card = btn.closest(".chunk-card");
const textEl = card.querySelector(".chunk-card-text");
const expanded = textEl.getAttribute("data-expanded") === "1";
textEl.textContent = expanded ? textEl.getAttribute("data-short") : textEl.getAttribute("data-full");
textEl.setAttribute("data-expanded", expanded ? "0" : "1");
btn.textContent = expanded ? "показать полностью" : "свернуть";
}
function toggleEmb(embId) {
const box = document.getElementById(embId);
if (box) box.classList.toggle("open");
}
async function deleteDoc(id, name) {
if (!confirm(`Удалить документ «${name}»?`)) return;
try {