Pablan, as it stands
Self-hosted knowledge management for SMEs: a split-screen Markdown editor whose sections an LLM refines while you write, and RAG question answering over the documents that result. FastAPI + Postgres/pgvector on the back, SvelteKit on the front, everything OpenAI-compatible and self-hostable. Squashed into a single commit; the development history stays local. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CA43ZJda8Rbp2hKXNy8f6b
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1040 1356" width="1040" height="1356" role="img" aria-label="Retrieval: hybrid search, and the similarity path next to it">
|
||||
<title>Retrieval: hybrid search, and the similarity path next to it</title>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg: #fbfaf9;
|
||||
--surface: #ffffff;
|
||||
--sunken: #f2f0ed;
|
||||
--ink: #1a1815;
|
||||
--muted: #63605a;
|
||||
--line: #c5bfb6;
|
||||
--line-soft: #e5e1db;
|
||||
--accent: #b8770a;
|
||||
--secondary: #b04a00;
|
||||
--note: #faf3e6;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0f0e0d;
|
||||
--surface: #1a1817;
|
||||
--sunken: #232120;
|
||||
--ink: #efece8;
|
||||
--muted: #a49d94;
|
||||
--line: #4a443d;
|
||||
--line-soft: #332f2b;
|
||||
--accent: #e6b422;
|
||||
--secondary: #e08030;
|
||||
--note: #241f16;
|
||||
}
|
||||
}
|
||||
text { font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; fill: var(--ink); }
|
||||
.t-title { font-size: 15px; font-weight: 650; }
|
||||
.t-sub { font-size: 11.5px; fill: var(--muted); }
|
||||
.t-node { font-size: 12.5px; font-weight: 600; }
|
||||
.t-body { font-size: 11.5px; }
|
||||
.t-muted { font-size: 11px; fill: var(--muted); }
|
||||
.t-edge { font-size: 10.5px; fill: var(--muted); }
|
||||
.t-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; }
|
||||
.box { fill: var(--surface); stroke: var(--line); stroke-width: 1.2; }
|
||||
.box-sunken { fill: var(--sunken); stroke: var(--line-soft); stroke-width: 1; }
|
||||
.box-accent { fill: var(--surface); stroke: var(--accent); stroke-width: 1.6; }
|
||||
.note { fill: var(--note); stroke: var(--line-soft); stroke-width: 1; }
|
||||
.group { fill: none; stroke: var(--line-soft); stroke-width: 1.2;
|
||||
stroke-dasharray: 5 4; }
|
||||
.lifeline { stroke: var(--line); stroke-width: 1; stroke-dasharray: 4 4; }
|
||||
.edge { stroke: var(--line); stroke-width: 1.3; fill: none; }
|
||||
.edge-accent { stroke: var(--secondary); stroke-width: 1.5; fill: none; }
|
||||
.edge-soft { stroke: var(--line); stroke-width: 1.1; fill: none;
|
||||
stroke-dasharray: 5 4; }
|
||||
.edge-soft-accent { stroke: var(--secondary); stroke-width: 1.3; fill: none;
|
||||
stroke-dasharray: 5 4; }
|
||||
.planned { stroke-dasharray: 6 4; opacity: 0.62; }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5"
|
||||
markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 1 L 9 5 L 0 9 z" fill="var(--line)"/>
|
||||
</marker>
|
||||
<marker id="arrow-accent" viewBox="0 0 10 10" refX="9" refY="5"
|
||||
markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 1 L 9 5 L 0 9 z" fill="var(--secondary)"/>
|
||||
</marker>
|
||||
<marker id="arrow-open" viewBox="0 0 10 10" refX="9" refY="5"
|
||||
markerWidth="8" markerHeight="8" orient="auto-start-reverse">
|
||||
<path d="M 0 1 L 9 5 L 0 9" fill="none" stroke="var(--line)"
|
||||
stroke-width="1.3"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<rect width="1040" height="1356" fill="var(--bg)"/>
|
||||
<text x="28" y="30" class="t-title" text-anchor="start">Retrieval: hybrid search, and the similarity path next to it</text>
|
||||
<text x="28" y="50" class="t-sub" text-anchor="start">The permission CTE is part of the one statement. There is no search without a user.</text>
|
||||
<rect x="75.3" y="74" width="151.4" height="30" rx="8" class="box" />
|
||||
<text x="151" y="93" class="t-node" text-anchor="middle">Caller (mode / API)</text>
|
||||
<line x1="151" y1="104" x2="151" y2="1338" class="lifeline"/>
|
||||
<rect x="298.2" y="74" width="197.6" height="30" rx="8" class="box" />
|
||||
<text x="397" y="93" class="t-node" text-anchor="middle">rag/retrieval + similarity</text>
|
||||
<line x1="397" y1="104" x2="397" y2="1338" class="lifeline"/>
|
||||
<rect x="577.2" y="74" width="131.6" height="30" rx="8" class="box" />
|
||||
<text x="643" y="93" class="t-node" text-anchor="middle">llm/client.embed</text>
|
||||
<line x1="643" y1="104" x2="643" y2="1338" class="lifeline"/>
|
||||
<rect x="841" y="74" width="96" height="30" rx="8" class="box" />
|
||||
<text x="889" y="93" class="t-node" text-anchor="middle">Postgres</text>
|
||||
<line x1="889" y1="104" x2="889" y2="1338" class="lifeline"/>
|
||||
<line x1="28" y1="138" x2="1012" y2="138" class="edge-soft"/>
|
||||
<rect x="417.825" y="127" width="204.35" height="22" rx="11" class="box-sunken" />
|
||||
<text x="520" y="142" class="t-muted" text-anchor="middle">search(): hybrid, the default</text>
|
||||
<text x="274" y="170" class="t-edge" text-anchor="middle">search(db, query, user=..., top_k=5)</text>
|
||||
<line x1="151" y1="187" x2="397" y2="187" class="edge" marker-end="url(#arrow)"/>
|
||||
<text x="520" y="209" class="t-edge" text-anchor="middle">embed([query])</text>
|
||||
<line x1="397" y1="226" x2="643" y2="226" class="edge" marker-end="url(#arrow)"/>
|
||||
<text x="520" y="248" class="t-edge" text-anchor="middle">query vector (1024, bge-m3)</text>
|
||||
<line x1="643" y1="265" x2="397" y2="265" class="edge-soft" marker-end="url(#arrow-open)"/>
|
||||
<text x="643" y="287" class="t-edge" text-anchor="middle">ONE statement:</text>
|
||||
<line x1="397" y1="304" x2="889" y2="304" class="edge-accent" marker-end="url(#arrow-accent)"/>
|
||||
<rect x="423" y="320" width="419.6" height="53" rx="6" class="note" />
|
||||
<text x="436" y="337" class="t-body" text-anchor="start">1. CTE "allowed": the LIVE documents table. status=published AND</text>
|
||||
<text x="436" y="350" class="t-body" text-anchor="start"> (public | own department | doc_permissions grant | author).</text>
|
||||
<text x="436" y="363" class="t-body" text-anchor="start"> Never chunk meta, so a change applies without reindexing.</text>
|
||||
<rect x="423" y="385" width="364.25" height="40" rx="6" class="note" />
|
||||
<text x="436" y="402" class="t-body" text-anchor="start">2. vector candidates: top-20 by cosine distance (HNSW),</text>
|
||||
<text x="436" y="415" class="t-body" text-anchor="start"> joined to allowed.</text>
|
||||
<rect x="423" y="437" width="395" height="40" rx="6" class="note" />
|
||||
<text x="436" y="454" class="t-body" text-anchor="start">3. FTS candidates: top-20 by ts_rank_cd over</text>
|
||||
<text x="436" y="467" class="t-body" text-anchor="start"> websearch_to_tsquery('german', query), joined to allowed.</text>
|
||||
<rect x="423" y="489" width="450.35" height="27" rx="6" class="note" />
|
||||
<text x="436" y="506" class="t-body" text-anchor="start">4. RRF merge: score = sum of 1/(60+rank), ORDER BY score LIMIT top_k.</text>
|
||||
<text x="643" y="534" class="t-edge" text-anchor="middle">rows: chunk, document, title, meta, score,</text>
|
||||
<text x="643" y="547" class="t-edge" text-anchor="middle">vector distance, fts rank</text>
|
||||
<line x1="889" y1="564" x2="397" y2="564" class="edge-soft" marker-end="url(#arrow-open)"/>
|
||||
<text x="274" y="586" class="t-edge" text-anchor="middle">SearchResult[] with citation metadata</text>
|
||||
<text x="274" y="599" class="t-edge" text-anchor="middle">(title, heading_path, score, vector_distance, fts_match)</text>
|
||||
<line x1="397" y1="616" x2="151" y2="616" class="edge-soft" marker-end="url(#arrow-open)"/>
|
||||
<rect x="304.05" y="632" width="431.9" height="53" rx="6" class="note" />
|
||||
<text x="317.05" y="649" class="t-body" text-anchor="start">No-answer signal: the top result has fts_match=false AND</text>
|
||||
<text x="317.05" y="662" class="t-body" text-anchor="start">vector_distance >= 0.45 (calibrated for bge-m3). Callers treat the</text>
|
||||
<text x="317.05" y="675" class="t-body" text-anchor="start">whole set as low confidence rather than citing it.</text>
|
||||
<line x1="28" y1="709" x2="1012" y2="709" class="edge-soft"/>
|
||||
<rect x="353.25" y="698" width="333.5" height="22" rx="11" class="box-sunken" />
|
||||
<text x="520" y="713" class="t-muted" text-anchor="middle">text_search(): the same query with no model at all</text>
|
||||
<text x="274" y="741" class="t-edge" text-anchor="middle">text_search(db, query, user=...)</text>
|
||||
<line x1="151" y1="758" x2="397" y2="758" class="edge" marker-end="url(#arrow)"/>
|
||||
<text x="643" y="780" class="t-edge" text-anchor="middle">SAME allowed CTE, then the german tsvector GIN index alone</text>
|
||||
<line x1="397" y1="797" x2="889" y2="797" class="edge" marker-end="url(#arrow)"/>
|
||||
<rect x="300.975" y="813" width="438.05" height="53" rx="6" class="note" />
|
||||
<text x="313.975" y="830" class="t-body" text-anchor="start">The fallback for a dead embedding endpoint: keywords, not meaning,</text>
|
||||
<text x="313.975" y="843" class="t-body" text-anchor="start">so the user is told it was a search without a model. No embed call,</text>
|
||||
<text x="313.975" y="856" class="t-body" text-anchor="start">which is why it works when nothing runs behind the LLM config.</text>
|
||||
<line x1="28" y1="890" x2="1012" y2="890" class="edge-soft"/>
|
||||
<rect x="390.15" y="879" width="259.7" height="22" rx="11" class="box-sunken" />
|
||||
<text x="520" y="894" class="t-muted" text-anchor="middle">rag/similarity: threshold, not ranking</text>
|
||||
<text x="274" y="922" class="t-edge" text-anchor="middle">similar_chunks(db, text, user=..., max_distance=<one of two constants>)</text>
|
||||
<line x1="151" y1="939" x2="397" y2="939" class="edge" marker-end="url(#arrow)"/>
|
||||
<text x="520" y="961" class="t-edge" text-anchor="middle">embed([text])</text>
|
||||
<line x1="397" y1="978" x2="643" y2="978" class="edge" marker-end="url(#arrow)"/>
|
||||
<text x="643" y="1000" class="t-edge" text-anchor="middle">SAME allowed CTE, then pure vector:</text>
|
||||
<text x="643" y="1013" class="t-edge" text-anchor="middle">ORDER BY cosine distance LIMIT top_k</text>
|
||||
<line x1="397" y1="1030" x2="889" y2="1030" class="edge" marker-end="url(#arrow)"/>
|
||||
<text x="643" y="1052" class="t-edge" text-anchor="middle">rows with a distance that is ALWAYS present</text>
|
||||
<line x1="889" y1="1069" x2="397" y2="1069" class="edge-soft" marker-end="url(#arrow-open)"/>
|
||||
<text x="274" y="1091" class="t-edge" text-anchor="middle">SimilarChunk[] filtered to distance <= max_distance</text>
|
||||
<text x="274" y="1104" class="t-edge" text-anchor="middle">(similar_documents groups per document, keeping the min)</text>
|
||||
<line x1="397" y1="1121" x2="151" y2="1121" class="edge-soft" marker-end="url(#arrow-open)"/>
|
||||
<rect x="288.675" y="1137" width="462.65" height="92" rx="6" class="note" />
|
||||
<text x="301.675" y="1154" class="t-body" text-anchor="start">Why not the RRF path: its score is a fusion rank, not a similarity,</text>
|
||||
<text x="301.675" y="1167" class="t-body" text-anchor="start">and vector_distance is null for FTS-only hits. A threshold needs a</text>
|
||||
<text x="301.675" y="1180" class="t-body" text-anchor="start">comparable number. The permission filter is shared; only the ranking</text>
|
||||
<text x="301.675" y="1193" class="t-body" text-anchor="start">differs. Two calibrated constants exist, CAPTURE_CONTEXT_MAX_DISTANCE</text>
|
||||
<text x="301.675" y="1206" class="t-body" text-anchor="start">(loose) and DUPLICATE_MAX_DISTANCE (tight); exclude_builtin drops</text>
|
||||
<text x="301.675" y="1219" class="t-body" text-anchor="start">Pablan's own help pages, because a capture asks what the COMPANY knows.</text>
|
||||
<rect x="294.825" y="1241" width="450.35" height="79" rx="6" class="note" />
|
||||
<text x="307.825" y="1258" class="t-body" text-anchor="start">Document search (GET /api/documents/search) is the same call with a</text>
|
||||
<text x="307.825" y="1271" class="t-body" text-anchor="start">larger top_k, grouped per document: one code path, so browsing and</text>
|
||||
<text x="307.825" y="1284" class="t-body" text-anchor="start">chat can never disagree on access. Query mode surfaces the run as SSE</text>
|
||||
<text x="307.825" y="1297" class="t-body" text-anchor="start">state events (searching, results(count) | no_answer, answering).</text>
|
||||
<text x="307.825" y="1310" class="t-body" text-anchor="start">Counts only, never the query text or a passage (rule 12).</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
Reference in New Issue
Block a user