Vector database for RAG: wording and meaning in one query
SereneDB, an open-source SQL database, keeps your chunks, their embeddings and their metadata in one table under one inverted index. A single SQL query ranks them by BM25 and by vector distance, applies the permission filter inside both searches and fuses the results. langchain-serenedb plugs it into LangChain.
pip install langchain-serenedbWhy vector search alone retrieves the wrong chunks
The model can only answer from the chunks retrieval hands it. Vector search always returns its nearest neighbours, even when the answer hinges on an exact ID; keyword search misses a page that says the same thing in other words; and a filter applied after ranking leaves holes. Pick a failure, move the slider, switch the filter. Every list below is what the statement under “SQL” returns on SereneDB 26.11.2, over a 21-chunk sample.
- 5Runbook: AB-1234 rollbackops/runbooks/rollback.md0.78✓ relevant
- 1Deploy timeout errorswiki/deploy-errors.md1.00
- 2Retry policy for deployswiki/retry.md0.99
- 3Release guidewiki/release.md0.98
- 4Connection pool sizingwiki/db-pool.md0.96
WITH hits AS ( SELECT 'bm25' AS branch, id, s FROM ( SELECT id, BM25(chunks_idx.tableoid) AS s FROM chunks_idx WHERE content @@ 'deploy fails with AB-1234' AND access IN ('all', 'staff') ORDER BY s DESC LIMIT 8) lex UNION ALL SELECT 'vector', id, -d FROM ( SELECT id, embedding <=> [0.05, 0.95, 0.05, 0.10]::FLOAT[4] AS d FROM chunks_idx WHERE access IN ('all', 'staff') ORDER BY d LIMIT 8) vec ), normed AS ( SELECT branch, id, CASE WHEN MAX(s) OVER w = MIN(s) OVER w THEN 1.0 ELSE (s - MIN(s) OVER w) / (MAX(s) OVER w - MIN(s) OVER w) END AS ns FROM hits WINDOW w AS (PARTITION BY branch) ), top AS ( SELECT id, SUM(CASE WHEN branch = 'bm25' THEN 0 ELSE 1 END * ns) AS score FROM normed GROUP BY id ORDER BY score DESC, id LIMIT 5 ) SELECT t.id, d.title, round(t.score, 2) AS score FROM top t JOIN chunks c ON c.id = t.id JOIN documents d ON d.id = c.doc_id ORDER BY t.score DESC, t.id;
- 4Retry policy for deployswiki/retry.md0.00
- 5Release guidewiki/release.md0.00
- 1CI pipeline setupwiki/ci.md1.00✓ relevant
- 2Config file referencedocs/config.md0.70
- 3CI cost report, Q3finance/ci-costs.md0.16
WITH hits AS ( SELECT 'bm25' AS branch, id, s FROM ( SELECT id, BM25(chunks_idx.tableoid) AS s FROM chunks_idx WHERE content @@ 'how do I configure CI?' AND access IN ('all', 'staff') ORDER BY s DESC LIMIT 8) lex UNION ALL SELECT 'vector', id, -d FROM ( SELECT id, embedding <=> [0.95, 0.20, 0.05, 0.05]::FLOAT[4] AS d FROM chunks_idx WHERE access IN ('all', 'staff') ORDER BY d LIMIT 8) vec ), normed AS ( SELECT branch, id, CASE WHEN MAX(s) OVER w = MIN(s) OVER w THEN 1.0 ELSE (s - MIN(s) OVER w) / (MAX(s) OVER w - MIN(s) OVER w) END AS ns FROM hits WINDOW w AS (PARTITION BY branch) ), top AS ( SELECT id, SUM(CASE WHEN branch = 'bm25' THEN 1 ELSE 0 END * ns) AS score FROM normed GROUP BY id ORDER BY score DESC, id LIMIT 5 ) SELECT t.id, d.title, round(t.score, 2) AS score FROM top t JOIN chunks c ON c.id = t.id JOIN documents d ON d.id = c.doc_id ORDER BY t.score DESC, t.id;
- 2Access requests: prod databaseswiki/access.md0.95✓ relevant
- 5On-call approverswiki/oncall.md0.68✓ relevant
- 4— removed after rankingsecurity/vault.md · restrictedremoved
- 3— removed after rankingsecurity/root.md · restrictedremoved
- 1— removed after rankingsecurity/audit.md · restrictedremoved
WITH hits AS ( SELECT 'bm25' AS branch, id, s FROM ( SELECT id, BM25(chunks_idx.tableoid) AS s FROM chunks_idx WHERE content @@ 'who approves prod database access?' ORDER BY s DESC LIMIT 8) lex UNION ALL SELECT 'vector', id, -d FROM ( SELECT id, embedding <=> [0.05, 0.10, 0.95, 0.05]::FLOAT[4] AS d FROM chunks_idx ORDER BY d LIMIT 8) vec ), normed AS ( SELECT branch, id, CASE WHEN MAX(s) OVER w = MIN(s) OVER w THEN 1.0 ELSE (s - MIN(s) OVER w) / (MAX(s) OVER w - MIN(s) OVER w) END AS ns FROM hits WINDOW w AS (PARTITION BY branch) ), top AS ( SELECT id, SUM(CASE WHEN branch = 'bm25' THEN 0.4 ELSE 0.6 END * ns) AS score FROM normed GROUP BY id ORDER BY score DESC, id LIMIT 5 ) SELECT t.id, d.title, round(t.score, 2) AS score FROM top t JOIN chunks c ON c.id = t.id JOIN documents d ON d.id = c.doc_id WHERE c.access IN ('all', 'staff') ORDER BY t.score DESC, t.id;
What a database for RAG needs
A RAG pipeline answers only as well as the chunks it retrieves. Whichever database you evaluate, check these seven points; each row says how SereneDB covers it, with the docs behind it.
| Retrieval needs | In SereneDB | Looks like |
|---|---|---|
| Exact terms: IDs, error codes, names | BM25 over an analyzed text column, stemmed and with stop words removed. Docs → | content @@ 'AB-1234' |
| Meaning: the same thing in other words | FLOAT[N] embeddings under ivf or hnsw, in the same index as the text. Docs → | embedding <=> query |
| Filters inside the search | Indexed metadata columns become an index filter in both searches, before the top k; EXPLAIN shows it in the scan. Docs → | access IN ('all', 'staff') |
| Fusion and reranking | RRF, NORMALIZED or WEIGHTED_SUM fusion in one statement, then ai_rerank() on the candidates. Docs → | FusionStrategy.RRF |
| Fresh writes | Rows are searchable within refresh_interval (1000 ms), or at once after VACUUM (REFRESH_TABLE); upserts and deletes follow the same refresh. Docs → | VACUUM (REFRESH_TABLE) chunks |
| Structured data in the same query | Typed metadata columns, JSON fields, joins and aggregates sit in the statement that ranks. Docs → | JOIN documents d ON … |
| Framework integration | A LangChain VectorStore (langchain-serenedb), and a RAGFlow document store. Docs → | pip install langchain-serenedb |
RAG architecture: one table, one index, one query
The retrieval half of a RAG pipeline is a table and a query. Each chunk is a row with its text, its embedding and its metadata, and one inverted index covers all three. langchain-serenedb writes the DDL and every query for you. Hover a stage to see what it does, or a part of the index to find its line.
Load what the model should know
Any LangChain document loader works: wiki exports, tickets, contracts, source files. Attach the metadata you will filter on here, such as the source, the team, the access level or the date; it travels with every chunk into the index.
Cut documents into chunks
Retrieval returns chunks, not documents. Each chunk keeps its document’s metadata, so a permission filter still applies after the split and the answer can cite its source. The chunk size is your choice; it depends on your documents and your model.
Embed with your model, or in SQL
Pass any LangChain embeddings model to the store, or call ai_embed() in SQL against OpenAI or any OpenAI-compatible endpoint: OpenRouter, Gemini, or a local Ollama, vLLM, LM Studio, LiteLLM or llama.cpp server. Embed the chunks once, when you write them; at search time only the question is embedded.
Text, vectors and metadata in one index
One CREATE INDEX … USING inverted names the text column with a dictionary for BM25, the embedding with ivf for nearest-neighbour search, and the metadata columns you filter on. EXPLAIN shows the filter inside each search’s index scan, before the top k is taken. More on the index kinds on vector search, on dictionaries and BM25 on full-text search.
Hybrid retrieval, then a reranker
BM25 and vector search run as two branches of one query, the filter applies inside both, and the rankings are fused. ai_rerank() can then score each fused candidate against the question and keep the best few.
Answer from the retrieved chunks
The top chunks become the prompt context, numbered so the model can cite them. as_retriever() works as anywhere else in LangChain; in SQL, string_agg builds the same context and ai_generate() writes the answer.
Three ways to fuse the two rankings
HybridSearchConfig(fusion=FusionStrategy.RRF)Fuses positions, so BM25 scores and distances need no calibration (rrf_k = 60). It rewards chunks that both searches like: on the CI sample it puts the config reference and the README badge 2nd and 3rd, because both lists contain them.
sum(1 / (rrf_k + rank))Min-max normalizes each branch, the distance inverted, then weights them, so a decisive win in one search survives. At 0.5/0.5 it keeps the build runners and secrets pages 2nd and 3rd. The playground above uses this one.
Weights the raw branch scores. Use it only when both branches already score on one scale. How fusion works →
Build a RAG pipeline on SereneDB in five steps
Five steps from an empty server to cited answers. Each step shows the LangChain call, and the SQL tab shows the statement it stands for. Every SQL statement and result below ran on SereneDB 26.11.2 on 2026-11-03.
docker run -d --name serenedb -p 7890:7890 \ -e POSTGRES_PASSWORD=<password> \ -v serenedb-data:/var/lib/serenedb serenedb/serenedb pip install langchain-serenedb # for the SQL tab psql -h localhost -p 7890 -U postgres
from langchain_serenedb import Column, HybridIndexConfig, SereneDBEngine engine = SereneDBEngine.from_connection_string( "host=127.0.0.1 port=7890 user=postgres password=<password> dbname=postgres" ) engine.init_vectorstore_table( "chunks", vector_size=768, # your embedding model's size metadata_columns=[Column("access", "TEXT"), Column("source", "TEXT")], hybrid_index_config=HybridIndexConfig(), )
CREATE TEXT SEARCH DICTIONARY rag_en AS split_text(case := 'lower') | remove_stopwords(stopwords := ['a', 'an', 'and', 'at', 'do', 'does', 'how', 'i', 'in', 'is', 'it', 'its', 'of', 'on', 'the', 'to', 'what', 'when', 'who', 'with']) | stem_words(locale := 'en') WITH (frequency, position, norm); CREATE TABLE documents ( id INTEGER PRIMARY KEY, title VARCHAR, source VARCHAR ); CREATE TABLE chunks ( id INTEGER PRIMARY KEY, doc_id INTEGER, access VARCHAR, content VARCHAR, embedding FLOAT[4] ); CREATE INDEX chunks_idx ON chunks USING inverted ( id, access, -- filter content rag_en, -- bm25 embedding ivf (metric = 'cosine') -- ann );
from langchain_serenedb import FusionStrategy, HybridSearchConfig, SereneDBVectorStore store = SereneDBVectorStore.create_sync( engine, embedding_service=embeddings, # any LangChain embeddings model table_name="chunks", metadata_columns=["access", "source"], hybrid_search_config=HybridSearchConfig( fusion=FusionStrategy.RRF, primary_top_k=20, secondary_top_k=20 ), ) store.add_documents(chunks)
INSERT INTO documents VALUES (1, 'Incident AB-1234 postmortem', 'ops/incidents/ab-1234.md'), (2, 'Runbook: AB-1234 rollback', 'ops/runbooks/rollback.md'), (3, 'Deploy timeout errors', 'wiki/deploy-errors.md'), (4, 'Retry policy for deploys', 'wiki/retry.md'), (5, 'Release guide', 'wiki/release.md'), (6, 'Connection pool sizing', 'wiki/db-pool.md'), (7, 'Customer escalation AB-1234', 'crm/escalations/ab-1234.md'); INSERT INTO chunks VALUES (1, 1, 'staff', 'Incident AB-1234: migration AB-1234 locked the orders table and the release was rolled back.', [0.10, 0.70, 0.15, 0.62]), (2, 2, 'staff', 'When AB-1234 recurs, revert the migration and redeploy the previous release.', [0.10, 0.75, 0.10, 0.55]), (3, 3, 'all', 'A deploy times out when the health check does not pass within 60 seconds.', [0.05, 0.97, 0.05, 0.15]), (4, 4, 'all', 'A failed rollout retries twice before it pages the on-call engineer.', [0.10, 0.92, 0.05, 0.20]), (5, 5, 'all', 'Ship every release to staging first, then promote it to production.', [0.15, 0.90, 0.05, 0.20]), (6, 6, 'all', 'Size the connection pool of the orders service to twice the worker count.', [0.05, 0.85, 0.10, 0.30]), (7, 7, 'admin', 'Acme reported failed checkouts during release AB-1234 and asked for credits.', [0.05, 0.50, 0.10, 0.85]); VACUUM (REFRESH_TABLE) chunks;
hits = store.similarity_search( "deploy fails with AB-1234", k=5, fts_query="AB-1234", filter={"access": {"$in": ["all", "staff"]}}, ) for doc in hits: print(doc.metadata["source"], doc.page_content[:40])
WITH fused AS ( SELECT id, RANK() OVER (ORDER BY s DESC) AS rank FROM ( SELECT id, BM25(chunks_idx.tableoid) AS s FROM chunks_idx WHERE content @@ 'deploy fails with AB-1234' AND access IN ('all', 'staff') ORDER BY s DESC LIMIT 20) lex UNION ALL SELECT id, RANK() OVER (ORDER BY d) AS rank FROM ( SELECT id, embedding <=> [0.05, 0.95, 0.05, 0.10]::FLOAT[4] AS d FROM chunks_idx WHERE access IN ('all', 'staff') ORDER BY d LIMIT 20) vec ) SELECT c.id, d.title, d.source, round(SUM(1.0 / (60 + f.rank)), 4) AS rrf FROM fused f JOIN chunks c ON c.id = f.id JOIN documents d ON d.id = c.doc_id GROUP BY c.id, d.title, d.source ORDER BY rrf DESC, c.id LIMIT 5;
from langchain_core.output_parsers import StrOutputParser from langchain_core.prompts import ChatPromptTemplate from langchain_core.runnables import RunnablePassthrough retriever = store.as_retriever( search_kwargs={"k": 5, "filter": {"access": {"$in": ["all", "staff"]}}} ) prompt = ChatPromptTemplate.from_template( "Answer from the numbered context and cite it.\n\n{context}\n\nQuestion: {question}" ) def number(docs): return "\n".join( f"[{i}] {d.metadata['source']}: {d.page_content}" for i, d in enumerate(docs, 1) ) chain = ( {"context": retriever | number, "question": RunnablePassthrough()} | prompt | llm # any LangChain chat model | StrOutputParser() ) answer = chain.invoke("deploy fails with AB-1234")
CREATE OR REPLACE SECRET openai (TYPE openai, api_key '⟨sk-...⟩', model 'gpt-4o-mini'); WITH fused AS ( SELECT id, RANK() OVER (ORDER BY s DESC) AS rank FROM ( SELECT id, BM25(chunks_idx.tableoid) AS s FROM chunks_idx WHERE content @@ 'deploy fails with AB-1234' AND access IN ('all', 'staff') ORDER BY s DESC LIMIT 20) lex UNION ALL SELECT id, RANK() OVER (ORDER BY d) AS rank FROM ( SELECT id, embedding <=> [0.05, 0.95, 0.05, 0.10]::FLOAT[4] AS d FROM chunks_idx WHERE access IN ('all', 'staff') ORDER BY d LIMIT 20) vec ), candidates AS ( SELECT id FROM fused GROUP BY id ORDER BY SUM(1.0 / (60 + rank)) DESC, id LIMIT 5 ), best AS ( SELECT c.id, d.source, c.content, ai_rerank('deploy fails with AB-1234', c.content, secret_name := 'openai') AS relevance FROM candidates k JOIN chunks c ON c.id = k.id JOIN documents d ON d.id = c.doc_id ORDER BY relevance DESC, c.id LIMIT 3 ) SELECT ai_generate( 'Question: deploy fails with AB-1234' || chr(10) || string_agg( '[' || n || '] ' || source || ': ' || content, chr(10) ORDER BY n), 'Answer from the numbered context only, and cite it as [n].', secret_name := 'openai') AS answer FROM (SELECT *, ROW_NUMBER() OVER (ORDER BY relevance DESC, id) AS n FROM best) numbered;
What teams build on a RAG database
Chat-with-your-docs assistants
Wiki, Confluence or Notion pages, cut into chunks with their space, source and date. Hybrid retrieval finds the sections that match the question’s words and its meaning, and the space filter keeps other teams’ pages out. In LangChain the filter is {"space": {"$in": ["ENG", "PLATFORM"]}}.
WITH lex AS ( SELECT id, RANK() OVER (ORDER BY s DESC) AS rank FROM ( SELECT id, BM25(pages_idx.tableoid) AS s FROM pages_idx WHERE content @@ 'How do I rotate the staging API key?' AND space IN ('ENG', 'PLATFORM') ORDER BY s DESC LIMIT 10) t), vec AS ( SELECT id, RANK() OVER (ORDER BY d) AS rank FROM ( SELECT id, embedding <=> [0.92, 0.12, 0.12]::FLOAT[3] AS d FROM pages_idx WHERE space IN ('ENG', 'PLATFORM') ORDER BY d LIMIT 10) t) SELECT p.id, p.title, p.source, l.id IS NOT NULL AS bm25, v.id IS NOT NULL AS vector, round(COALESCE(1.0 / (60 + l.rank), 0) + COALESCE(1.0 / (60 + v.rank), 0), 4) AS rrf FROM pages p LEFT JOIN lex l ON l.id = p.id LEFT JOIN vec v ON v.id = p.id WHERE l.id IS NOT NULL OR v.id IS NOT NULL ORDER BY rrf DESC, p.id LIMIT 2;
| # | chunk | source | found by |
|---|---|---|---|
| 1 | Staging · API keys | confluence/ENG/api-keys | bm25 + vector |
| 2 | Secrets management | notion/platform/secrets | bm25 + vector |
Enterprise knowledge retrieval
Contracts, tickets and policies in one table. Department and date filters run inside the same query as the hybrid ranking, so structured conditions and the question meet in one place. RAGFlow can use SereneDB as its document store. In LangChain: {"department": {"$eq": "legal"}, "signed_at": {"$gte": "2024-01-01"}}.
WITH lex AS ( SELECT id, RANK() OVER (ORDER BY s DESC) AS rank FROM ( SELECT id, BM25(clauses_idx.tableoid) AS s FROM clauses_idx WHERE content @@ 'supplier contracts renew automatically Q1' AND department = 'legal' AND signed_at >= DATE '2024-01-01' ORDER BY s DESC LIMIT 10) t), vec AS ( SELECT id, RANK() OVER (ORDER BY d) AS rank FROM ( SELECT id, embedding <=> [0.92, 0.20, 0.10]::FLOAT[3] AS d FROM clauses_idx WHERE department = 'legal' AND signed_at >= DATE '2024-01-01' ORDER BY d LIMIT 10) t) SELECT c.id, c.contract, c.source, l.id IS NOT NULL AS bm25, v.id IS NOT NULL AS vector, round(COALESCE(1.0 / (60 + l.rank), 0) + COALESCE(1.0 / (60 + v.rank), 0), 4) AS rrf FROM clauses c LEFT JOIN lex l ON l.id = c.id LEFT JOIN vec v ON v.id = c.id WHERE l.id IS NOT NULL OR v.id IS NOT NULL ORDER BY rrf DESC, c.id LIMIT 2;
| # | chunk | source | found by |
|---|---|---|---|
| 1 | Northwind MSA · §12 Renewal | contracts/northwind-msa.pdf | bm25 + vector |
| 2 | Acme supply agreement · §8 | contracts/acme-2025.pdf | vector |
Code-aware AI agents
Code and docs chunked side by side. BM25 finds the chunks that use the agent’s exact words; vectors find the same logic under another name. In LangChain: {"repo": {"$eq": "api"}, "lang": {"$in": ["py", "go"]}}.
WITH lex AS ( SELECT id, RANK() OVER (ORDER BY s DESC) AS rank FROM ( SELECT id, BM25(code_idx.tableoid) AS s FROM code_idx WHERE content @@ 'Where do we retry failed HTTP calls?' AND repo = 'api' AND lang IN ('py', 'go') ORDER BY s DESC LIMIT 10) t), vec AS ( SELECT id, RANK() OVER (ORDER BY d) AS rank FROM ( SELECT id, embedding <=> [0.90, 0.25, 0.05]::FLOAT[3] AS d FROM code_idx WHERE repo = 'api' AND lang IN ('py', 'go') ORDER BY d LIMIT 10) t) SELECT c.id, c.symbol, c.path, l.id IS NOT NULL AS bm25, v.id IS NOT NULL AS vector, round(COALESCE(1.0 / (60 + l.rank), 0) + COALESCE(1.0 / (60 + v.rank), 0), 4) AS rrf FROM code_chunks c LEFT JOIN lex l ON l.id = c.id LEFT JOIN vec v ON v.id = c.id WHERE l.id IS NOT NULL OR v.id IS NOT NULL ORDER BY rrf DESC, c.id LIMIT 2;
| # | chunk | source | found by |
|---|---|---|---|
| 1 | retry_with_backoff() | api/net/retry.py:42 | bm25 + vector |
| 2 | exponentialDelay() | api/jobs/queue.go:118 | vector |
A RAG database is the database behind the retrieval step of retrieval-augmented generation. It stores chunks of your documents with their embeddings and metadata, and finds the chunks that answer a question, by meaning and by exact words, within what the user may read. SereneDB does that in one table and one SQL query.
Not as a separate system. In SereneDB, vectors are a FLOAT[N] column on the same row as the text and metadata, indexed with ivf or hnsw, and the database also runs BM25, filters, joins and aggregates. Retrieval is one SQL query over the PostgreSQL wire protocol, not a second store to keep in sync. Teams that keep their embeddings in Qdrant or Pinecone can compare the retrieval layers in SereneDB vs Qdrant and SereneDB vs Pinecone.
Yes. SereneDB is open source under the Apache-2.0 license, and its source is on GitHub. Chunks, embeddings and metadata share one table and one inverted index, BM25 and vector retrieval fuse in one SQL query, and any PostgreSQL client runs it. The MIT-licensed langchain-serenedb plugs it into LangChain.
Vector search finds chunks that mean the same thing; BM25 finds chunks with the exact identifier, error code or name. Fusing both keeps either kind of match in the context. In SereneDB both run in one statement against one index; see hybrid search.
Store the permission (team, tenant, access level) on each chunk, index it, and filter inside both searches, before fusion, rather than trimming the top k afterwards. langchain-serenedb puts a metadata filter inside both branches of its hybrid query, and an indexed metadata column is evaluated in the index scan.
Put the structured fields on the chunk row, or join them in: SereneDB is a SQL database, so a date range, a department or a join to a contracts table sits in the same query as the hybrid ranking.
Yes. langchain-serenedb implements the VectorStore interface: hybrid search through HybridSearchConfig, metadata filters, MMR and as_retriever(). It writes all the SQL and passes LangChain’s standard VectorStore integration tests; the integration post walks through it.
Yes, in SQL. ai_embed() calls OpenAI or any OpenAI-compatible endpoint, a local Ollama or vLLM server included; ai_rerank() scores how well each candidate answers the question, from 0 to 1. Both read the provider from a secret.
Writes go to the base table and reach the index at the next refresh: automatically every refresh_interval (default 1000 ms), or at once with VACUUM (REFRESH_TABLE). Re-ingesting a chunk is an upsert, and deleting its row removes it from search.
Chunking is your splitter’s job, for example LangChain’s RecursiveCharacterTextSplitter. Store each chunk with its document id and metadata, so filters still apply and answers can cite the source document.
Put a tenant column on every chunk, index it, and filter on it inside both searches, so one tenant’s chunks never compete with another’s. RAGFlow’s SereneDB connector keeps one table per tenant with the knowledge base as a filter column, so BM25 statistics cover the tenant’s whole corpus.
Yes. Since August 2026 SereneDB is one of RAGFlow’s document stores, on both the Go and the Python path: set DOC_ENGINE=serenedb, in RAGFlow 0.27.0 and later. A community member wrote the integration.
Get started with SereneDB
Install SereneDB and langchain-serenedb, and your retrieval layer is one table and one query away.