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
|
||||
text: str = ""
|
||||
char_length: int = 0
|
||||
embedding: list[float] = Field(default_factory=list)
|
||||
embedding_dim: int = 0
|
||||
|
||||
|
||||
class DocumentChunksResponse(BaseModel):
|
||||
|
||||
@@ -128,6 +128,8 @@ async def get_document_chunks(document_id: str):
|
||||
page_number=c["metadata"].get("page_number", 0),
|
||||
text=c["text"],
|
||||
char_length=len(c["text"]),
|
||||
embedding=c.get("embedding", []),
|
||||
embedding_dim=len(c.get("embedding", [])),
|
||||
)
|
||||
for c in raw_chunks
|
||||
]
|
||||
|
||||
@@ -118,18 +118,22 @@ class VectorStoreService:
|
||||
return list(docs.values())
|
||||
|
||||
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(
|
||||
where={"document_id": document_id},
|
||||
include=["documents", "metadatas"],
|
||||
include=["documents", "metadatas", "embeddings"],
|
||||
)
|
||||
items = []
|
||||
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"]):
|
||||
emb = embeddings[i] if has_emb and i < len(embeddings) else None
|
||||
items.append({
|
||||
"chunk_id": chunk_id,
|
||||
"text": results["documents"][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))
|
||||
return items
|
||||
|
||||
+52
-3
@@ -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, """);
|
||||
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, """)}" 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 {
|
||||
|
||||
Reference in New Issue
Block a user