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:
@@ -38,6 +38,8 @@ class ChunkDetail(BaseModel):
|
|||||||
page_number: int = 0
|
page_number: int = 0
|
||||||
text: str = ""
|
text: str = ""
|
||||||
char_length: int = 0
|
char_length: int = 0
|
||||||
|
embedding: list[float] = Field(default_factory=list)
|
||||||
|
embedding_dim: int = 0
|
||||||
|
|
||||||
|
|
||||||
class DocumentChunksResponse(BaseModel):
|
class DocumentChunksResponse(BaseModel):
|
||||||
|
|||||||
@@ -128,6 +128,8 @@ async def get_document_chunks(document_id: str):
|
|||||||
page_number=c["metadata"].get("page_number", 0),
|
page_number=c["metadata"].get("page_number", 0),
|
||||||
text=c["text"],
|
text=c["text"],
|
||||||
char_length=len(c["text"]),
|
char_length=len(c["text"]),
|
||||||
|
embedding=c.get("embedding", []),
|
||||||
|
embedding_dim=len(c.get("embedding", [])),
|
||||||
)
|
)
|
||||||
for c in raw_chunks
|
for c in raw_chunks
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -118,18 +118,22 @@ class VectorStoreService:
|
|||||||
return list(docs.values())
|
return list(docs.values())
|
||||||
|
|
||||||
def get_document_chunks(self, document_id: str) -> list[dict]:
|
def get_document_chunks(self, document_id: str) -> list[dict]:
|
||||||
"""Return all chunks for a document, sorted by chunk_index."""
|
"""Return all chunks for a document, sorted by chunk_index. Includes embeddings."""
|
||||||
results = self.collection.get(
|
results = self.collection.get(
|
||||||
where={"document_id": document_id},
|
where={"document_id": document_id},
|
||||||
include=["documents", "metadatas"],
|
include=["documents", "metadatas", "embeddings"],
|
||||||
)
|
)
|
||||||
items = []
|
items = []
|
||||||
if results["ids"]:
|
if results["ids"]:
|
||||||
|
embeddings = results.get("embeddings")
|
||||||
|
has_emb = embeddings is not None and len(embeddings) > 0
|
||||||
for i, chunk_id in enumerate(results["ids"]):
|
for i, chunk_id in enumerate(results["ids"]):
|
||||||
|
emb = embeddings[i] if has_emb and i < len(embeddings) else None
|
||||||
items.append({
|
items.append({
|
||||||
"chunk_id": chunk_id,
|
"chunk_id": chunk_id,
|
||||||
"text": results["documents"][i],
|
"text": results["documents"][i],
|
||||||
"metadata": results["metadatas"][i],
|
"metadata": results["metadatas"][i],
|
||||||
|
"embedding": [float(x) for x in emb] if emb is not None else [],
|
||||||
})
|
})
|
||||||
items.sort(key=lambda x: x["metadata"].get("chunk_index", 0))
|
items.sort(key=lambda x: x["metadata"].get("chunk_index", 0))
|
||||||
return items
|
return items
|
||||||
|
|||||||
+51
-2
@@ -151,6 +151,35 @@
|
|||||||
background: none;
|
background: none;
|
||||||
padding: 4px 0 0 0;
|
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 {
|
.empty {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -435,10 +464,15 @@ async function toggleChunks(id) {
|
|||||||
body.innerHTML = '<div class="mini">чанков нет</div>';
|
body.innerHTML = '<div class="mini">чанков нет</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
body.innerHTML = d.chunks.map(c => {
|
body.innerHTML = d.chunks.map((c, i) => {
|
||||||
const long = c.text.length > 300;
|
const long = c.text.length > 300;
|
||||||
const short = long ? c.text.slice(0, 300) + "…" : c.text;
|
const short = long ? c.text.slice(0, 300) + "…" : c.text;
|
||||||
const safeFull = esc(c.text).replace(/"/g, """);
|
const safeFull = esc(c.text).replace(/"/g, """);
|
||||||
|
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 `
|
return `
|
||||||
<div class="chunk-card">
|
<div class="chunk-card">
|
||||||
<div class="chunk-card-meta">
|
<div class="chunk-card-meta">
|
||||||
@@ -448,7 +482,16 @@ async function toggleChunks(id) {
|
|||||||
<span>${c.char_length.toLocaleString("ru")} симв.</span>
|
<span>${c.char_length.toLocaleString("ru")} симв.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="chunk-card-text" data-short="${esc(short).replace(/"/g, """)}" data-full="${safeFull}" data-expanded="0">${esc(short)}</div>
|
<div class="chunk-card-text" data-short="${esc(short).replace(/"/g, """)}" data-full="${safeFull}" data-expanded="0">${esc(short)}</div>
|
||||||
|
<div class="chunk-card-actions">
|
||||||
${long ? '<button class="chunk-card-toggle" onclick="toggleChunkText(this)">показать полностью</button>' : ""}
|
${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>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join("");
|
}).join("");
|
||||||
@@ -458,13 +501,19 @@ async function toggleChunks(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleChunkText(btn) {
|
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";
|
const expanded = textEl.getAttribute("data-expanded") === "1";
|
||||||
textEl.textContent = expanded ? textEl.getAttribute("data-short") : textEl.getAttribute("data-full");
|
textEl.textContent = expanded ? textEl.getAttribute("data-short") : textEl.getAttribute("data-full");
|
||||||
textEl.setAttribute("data-expanded", expanded ? "0" : "1");
|
textEl.setAttribute("data-expanded", expanded ? "0" : "1");
|
||||||
btn.textContent = expanded ? "показать полностью" : "свернуть";
|
btn.textContent = expanded ? "показать полностью" : "свернуть";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleEmb(embId) {
|
||||||
|
const box = document.getElementById(embId);
|
||||||
|
if (box) box.classList.toggle("open");
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteDoc(id, name) {
|
async function deleteDoc(id, name) {
|
||||||
if (!confirm(`Удалить документ «${name}»?`)) return;
|
if (!confirm(`Удалить документ «${name}»?`)) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user