An internal, permission aware search assistant. Employees sign in with their Hybridge Google account, ask questions in plain language, and get streamed answers grounded in company documents, with citations that open the exact source passage. Authorized users grow the knowledge base by dropping documents into a governed ingestion pipeline. Built entirely on Google Cloud with open source components. No third party SaaS.
Two tabs, mirroring the quality bar of modern AI chat apps:
A chat home with a serif greeting and a rounded composer. Answers stream in live. Every factual claim carries a small numbered citation. Clicking a citation opens the source document in a right pane, scrolled to the exact cited passage with a highlight. A model chip switches between Flash (fast) and Pro (deeper reasoning). Conversations persist in a left rail with pin, rename, and delete.
The governed way in for new knowledge. Uploaders see a reactive particle vortex as the drop target. Dropping a file or pasting a link opens a scope popup: who can find it (everyone or specific groups), tags, document class, and effective date. A status list tracks each document from Processing to Ready. Everyone else sees a lock. The tab itself is hidden for people without upload rights.
A single page app on Firebase Hosting talks to a FastAPI service on Cloud Run. Postgres (Cloud SQL) holds everything relational plus the vectors. Google Cloud Storage keeps the original files. Vertex AI provides embeddings, reranking, and Gemini generation. All infrastructure is defined in Terraform, and every deploy is versioned and tagged.
flowchart LR
subgraph Browser
SPA["React SPA
Firebase Hosting"]
end
subgraph GCP["Google Cloud · project hybridge-search"]
IDP["Identity Platform
Google SSO, domain locked"]
API["FastAPI on Cloud Run
token verify · ACL · RAG"]
DB[("Cloud SQL Postgres
pgvector · FTS · chat")]
GCS[("Cloud Storage
original files")]
VAI["Vertex AI
embeddings · rerank · Gemini"]
JOB["Ingestion job
Cloud Run + Pub/Sub"]
end
SPA -->|Google sign in| IDP
SPA -->|Bearer token, HTTPS| API
API --> DB
API --> VAI
API -->|signed URLs| GCS
JOB --> DB
JOB --> VAI
GCS --> JOB
Before writing code we researched four ways to back a retrieval system. The decision was driven by our real workload: a corpus that starts near zero and grows document by document, strict permission filtering on every query, and chat grade latency.
| Approach | How it works | Strengths | Why not (for us) |
|---|---|---|---|
| Vector DB (chosen) | Embed chunks, nearest neighbor search, plus keyword search, fused and reranked | Fast (tens of ms), scales incrementally, filters combine naturally with SQL, mature | Chosen. Weakness (exact terms, part numbers) is covered by the BM25 side of the hybrid |
| Vectorless / PageIndex | LLM navigates a table of contents tree, reasoning its way to the right section | Excellent on long structured docs, no index to rebuild | Each query costs multiple LLM calls and seconds of latency; per query cost grows with corpus |
| Graph DB / GraphRAG | Extract entities and relations, answer by walking the graph | Multi hop questions across documents | Expensive extraction on every ingest, brittle as the corpus shifts, heavy maintenance |
| Keyword only (BM25) | Classic full text search | Cheap, exact match, explainable | Misses paraphrase and synonyms; no semantic recall |
We landed on hybrid retrieval with reranking on Postgres pgvector: dense vectors for meaning, BM25 for exact words, reciprocal rank fusion to merge, and a cross encoder reranker for final precision. Postgres also gives us one database for chunks, metadata, permissions, chat history, and users, which keeps ACL filtering inside the same query planner. When scale demands it, the same schema moves to AlloyDB with minimal change.
Every document enters through one governed pipeline, whether it arrives as a file drop or a pasted link.
Before anything else, the raw bytes are reduced to a fingerprint (a content hash). If that fingerprint already exists in the database, the upload is skipped and the uploader simply gets a pointer to the existing document. People can drop files freely without wondering whether someone already added them.
Every document keeps a stable source key (its normalized name or URL). When new content arrives under the same key, it becomes version n+1: the old version is stamped superseded and linked to its successor, the new one becomes current. Search only ever sees current versions, but nothing is deleted, so the full history stays auditable.
The untouched original goes to Cloud Storage before any processing. When someone later wants the actual document (not chunks, not a reconstruction), the API checks their permissions first and then issues a short lived signed link. The file itself is never publicly reachable.
Parsers turn the file into layout blocks instead of one long string: headings, paragraphs, tables. Text and HTML parse locally; PDFs and office formats go through Document AI. Keeping the structure matters, because the next step uses it to cut in sensible places.
Blocks are packed into token sized chunks that respect paragraph boundaries and keep headings attached to their sections. Neighboring chunks overlap slightly so no idea is severed at a boundary. Every chunk records its exact character range in the source, and that is precisely what lets a citation open the document on the right passage.
Gemini reads the document once and proposes a card: title, a short summary, key entities, tags, and an effective date. The uploader confirms the governance fields (who can see it, what class it is). The card stays tiny whether the document is one page or four hundred, so document level filtering stays fast forever.
Each chunk is converted into 768 numbers by Vertex AI's gemini embedding model. Texts that mean similar things land near each other in that space, even when they share no words. Questions are embedded with the same model at ask time, so "how long do implants last" can find a paragraph about longevity outcomes.
Three indexes take each new chunk incrementally, with nothing rebuilt: a graph based vector index (HNSW) for nearest neighbor search, a full text index for exact words, and an index over the permission lists. This is why a dropped document is searchable minutes later, no matter how large the corpus already is.
Failures are first class: a document that cannot be parsed lands in a Failed state with the reason stored, visible in the Dropoff status list.
Better answers come from better metadata, so at ingestion we build a small document card for every document, and it stays small no matter how long the document is. Gemini reads the document and proposes a title, a summary capped at 200 characters, key entities, tags from a fixed taxonomy, and an effective date. The uploader confirms or edits the governance fields in the scope popup. (Click Document card in the pipeline above for the visual.)
| Field | Set by | Used for |
|---|---|---|
| title, summary, entities | Gemini proposes | fast document level filtering and display |
| tags | Gemini proposes, uploader confirms | department style filtering, source pills in answers |
| group_acl | uploader | who can ever see this content (empty means everyone) |
| document class | uploader | authority: Official Policy 8 · Approved SOP 6 · Draft 3 · Note 1 |
| authority score | computed server side | class base, plus 2 if an admin uploaded, plus 1 for uploader role, minus 1 for link sources, clamped 1 to 10. Never client supplied. |
| effective date | uploader confirms | recency tiebreaks and conflict handling |
| version, supersedes, status | pipeline | version chain and current flag |
Every chunk inherits the tenant id, document id, permissions, version, and character offsets, so a citation can always be traced to an exact byte range of an exact version of an exact document.
Chunking is layout aware, not a blind character count: the chunker walks the parsed blocks, keeps headings attached to their sections, respects paragraph boundaries, and packs blocks into token bounded chunks with a controlled overlap between neighbors. Each chunk records its exact character start and end. Encoding uses Vertex AI gemini-embedding-001 at 768 dimensions, batched for throughput, with queries embedded by the same model into the same space. 768 is the deliberate middle ground: strong recall at a quarter of the storage and index cost of 3072. (Click Chunk and Embed in the pipeline above for the visuals.)
Everything lands in one Postgres instance (Cloud SQL) with the pgvector extension. Three indexes make retrieval fast, and all three accept inserts incrementally, which matters for a corpus that grows daily. (Click Index in the pipeline above for the visual.)
tsvector column, powering the BM25 style keyword side.Original files live in Cloud Storage; Postgres stores the pointer. Chat history, users, groups, and audit logs share the same database, which is why one SQL query can combine "semantically similar" with "this user is allowed to see it" with "only current versions".
flowchart LR Q["Question"] --> ACL["Permission prefilter
from identity, in SQL"] ACL --> D["Dense search
pgvector cosine, top 50"] ACL --> S["Keyword search
BM25 full text, top 50"] D --> F["Reciprocal rank fusion"] S --> F F --> R["Rerank to top 8
Vertex AI ranker"] R --> C["Conflict resolution
authority, recency"] C --> G["Gemini generates
grounded, cited, streamed"]
The caller's groups are resolved server side from their verified identity and applied as a database filter. Chunks outside their groups, and chunks of superseded versions, never enter the candidate pool. Permissions are not trimmed off the results afterward; the forbidden material is simply never searched. Request filters can narrow this further but can never widen it.
The question is embedded into the same 768 number space as every chunk, and the vector index returns the fifty nearest by meaning. This is the side that handles paraphrase: "time off rules" finds the PTO policy even if the words never match.
In parallel, classic full text search ranks chunks by the exact words of the question. This side nails part numbers, model names, and precise phrases. Each side contributes its top fifty, so nothing depends on one method being perfect.
The two lists are merged by rank position, not by raw score, so the two scoring scales never need to be reconciled. A chunk that both searches rank highly rises to the top; a chunk only one side found still survives into the merged list.
Wide nets are cheap but imprecise. A stronger model now reads the question together with each surviving candidate and reorders them by how well they actually answer it, keeping the best eight as evidence. Recall first, precision second.
If the evidence disagrees, the metadata decides. Superseded documents were already excluded; among the rest, higher authority wins and newer effective dates break ties. When two comparable sources still conflict, the answer states the disagreement openly, names the source it relied on, and cites both.
Gemini writes the answer from the selected passages only, streaming token by token, with a citation on every claim. Clicking a citation opens the source at the exact highlighted passage. And if retrieval produced nothing the caller may see, the model is never invoked at all: the assistant answers "I don't have that in the knowledge base" and the question costs nothing.
Access is organized around groups, created by uploaders on the fly (clinical, regulatory, leadership, and so on). Users belong to groups; documents are scoped to groups at Dropoff time. An empty scope means everyone at Hybridge. (Click Permission filter in the pipeline above for the visual.)
Re uploading a changed document creates a new version and supersedes the old one. Retrieval only sees current versions, so stale guidance cannot resurface, while the full chain remains stored for audit. (Click Version resolve in the write path for the visual.)
When two retrieved documents disagree, the system resolves by governance instead of chance: drop superseded documents, prefer higher authority, tiebreak by effective date, and if the top two remain comparable, say so explicitly in the answer. (Click Resolve conflicts in the read path for the visual.)
Every claim carries a numbered citation. Each citation stores the document id, chunk id, and character offsets, so clicking it opens the document scrolled to the highlighted passage. Sources under an answer show title, tags, document class, effective date, and authority. If the knowledge base cannot support an answer, the assistant abstains rather than guesses.
Google sign in via Identity Platform, restricted to the hybridgeimplants.com domain with verified email required. Users are provisioned on first login. Every API call verifies the Firebase token server side.
Deny by default group permissions enforced in SQL before retrieval, on the source viewer, and on file downloads. Roles gate upload and admin surfaces in both API and UI.
Originals in Cloud Storage behind short lived signed URLs, issued only after a permission check. Secrets live in Secret Manager. Nothing sensitive is committed to the repository.
Stream errors are logged in full on the server but the client only ever sees a generic message, so internal details cannot leak. Ingestion of links has a guard against requests to internal or metadata addresses.
Logins, group changes, and membership changes write an audit log with actor, action, target, and time.
Google Cloud BAA is in place for the organization. Access is scoped, audited, and revocable per group; encryption at rest and in transit is on by default across the stack, with customer managed keys staged as a hardening step.
Also in the discipline column: 122 automated tests across backend and frontend, adversarial code review on every pull request (which caught real permission and correctness bugs before merge), and an automated security review pass that hardened the error path.
React 19 with TypeScript and Tailwind, built with Vite, served from Firebase Hosting. The visual identity is a warm beige and brown system designed for this product, in both tabs and this document.
This system assumes the corpus is always growing rather than imported once: