SereneDB

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-serenedb
hybrid retrieval · livebm25vectortop-k
An animation of three questions over the chunks of a team wiki, drawn as a field of embeddings. For each question the panel embeds it, finds the nearest chunks by cosine distance, marks the chunks that contain its stemmed terms, and fuses both rankings with the access filter inside both searches. 'deploy fails with AB-1234' returns Incident AB-1234 postmortem, Runbook: AB-1234 rollback and Retry policy for deploys. 'how do I configure CI?' returns CI pipeline setup, Build runners and Secrets in builds. 'who approves prod database access?' returns Access requests: prod databases, On-call approvers and Access policy, and the three admin-only chunks never enter either search.
Why hybrid retrieval

Why 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.

retrieval playground
fusion weightvector only
bm25 onlyhybridvector only
filter · access in (all, staff)
SereneDB: the filter runs inside the BM25 and the vector search.
AB-1234: vector only, In-index
sdb> deploy fails with AB-1234
  1. 5Runbook: AB-1234 rollbackops/runbooks/rollback.md0.78✓ relevant
  2. 1Deploy timeout errorswiki/deploy-errors.md1.00
  3. 2Retry policy for deployswiki/retry.md0.99
  4. 3Release guidewiki/release.md0.98
  5. 4Connection pool sizingwiki/db-pool.md0.96
✕Missing from the top 5: Incident AB-1234 postmortem.
sql · the statement behind this view · 21-chunk sample
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;
configure CI: bm25 only, In-index
sdb> how do I configure CI?
  1. 4Retry policy for deployswiki/retry.md0.00
  2. 5Release guidewiki/release.md0.00
  3. 1CI pipeline setupwiki/ci.md1.00✓ relevant
  4. 2Config file referencedocs/config.md0.70
  5. 3CI cost report, Q3finance/ci-costs.md0.16
✕Missing from the top 5: Build runners, Secrets in builds.
sql · the statement behind this view · 21-chunk sample
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;
prod access: bm25 0.40 · vector 0.60, Post-filter
sdb> who approves prod database access?
  1. 2Access requests: prod databaseswiki/access.md0.95✓ relevant
  2. 5On-call approverswiki/oncall.md0.68✓ relevant
  3. 4— removed after rankingsecurity/vault.md · restrictedremoved
  4. 3— removed after rankingsecurity/root.md · restrictedremoved
  5. 1— removed after rankingsecurity/audit.md · restrictedremoved
~The post-filter dropped 3 of 5 results after ranking. The model gets 2 chunks.
sql · the statement behind this view · 21-chunk sample
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;

Checklist

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 needsIn SereneDBLooks like
Exact terms: IDs, error codes, namesBM25 over an analyzed text column, stemmed and with stop words removed. Docs →content @@ 'AB-1234'
Meaning: the same thing in other wordsFLOAT[N] embeddings under ivf or hnsw, in the same index as the text. Docs →embedding <=> query
Filters inside the searchIndexed 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 rerankingRRF, NORMALIZED or WEIGHTED_SUM fusion in one statement, then ai_rerank() on the candidates. Docs →FusionStrategy.RRF
Fresh writesRows 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 queryTyped metadata columns, JSON fields, joins and aggregates sit in the statement that ranks. Docs →JOIN documents d ON …
Framework integrationA LangChain VectorStore (langchain-serenedb), and a RAGFlow document store. Docs →pip install langchain-serenedb

Architecture

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.

01 · loader
Documents
wiki · tickets · code
02 · splitter
Chunks
text + metadata
03 · embed
Embeddings
ai_embed or yours
04 · serenedb
One inverted index
content rag_enbm25
embedding ivfann
accessfilter
the filter runs inside both searches, then fuse
05 · retrieve
Top-k chunks
fused, reranked
06 · llm
Answer
grounded, cited
> stage 01 · loader

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.

python
from langchain_core.documents import Document
docs = [
Document(
page_content="Incident AB-1234: migration AB-1234 locked the orders table and the release was rolled back.",
metadata={"access": "staff", "source": "ops/incidents/ab-1234.md"},
),
Document(
page_content="A deploy times out when the health check does not pass within 60 seconds.",
metadata={"access": "all", "source": "wiki/deploy-errors.md"},
),
Document(
page_content="Acme reported failed checkouts during release AB-1234 and asked for credits.",
metadata={"access": "admin", "source": "crm/escalations/ab-1234.md"},
),
]
> stage 02 · splitter

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.

python
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(chunk_size=800, chunk_overlap=100)
chunks = splitter.split_documents(docs)
> stage 03 · embed

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.

sql
CREATE OR REPLACE SECRET openai (TYPE openai, api_key '⟨sk-...⟩');
-- the question, at search time
SELECT ai_embed('how do I configure CI?',
'text-embedding-3-small', 'openai');
> stage 04 · serenedb

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.

sql · hover a line
CREATE INDEX chunks_idx ON chunks
USING inverted (
id,
access, -- filter
content rag_en, -- bm25
embedding ivf (metric = 'cosine') -- ann
);
> stage 05 · retrieve + rerank

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.

python
from langchain_serenedb import FusionStrategy, HybridSearchConfig
config = HybridSearchConfig(
fusion=FusionStrategy.RRF, # or NORMALIZED, WEIGHTED_SUM
primary_top_k=20, # vector window
secondary_top_k=20, # BM25 window
)
hits = store.similarity_search(
"deploy fails with AB-1234",
k=5,
fts_query="AB-1234",
filter={"access": {"$in": ["all", "staff"]}},
hybrid_search_config=config,
)
> stage 06 · llm

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.

python
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")
A diagram in six stages. A loader reads documents with their metadata, a splitter cuts them into chunks, and an embeddings model or ai_embed turns each chunk into a vector. SereneDB keeps the chunks in one table under one inverted index with three parts: the text under the rag_en dictionary for BM25, the embedding under ivf for nearest-neighbour search, and the access column as a filter that runs inside both searches. The retriever fuses the two rankings and can rerank them with ai_rerank, and the model answers from the top chunks with citations.

Three ways to fuse the two rankings

HybridSearchConfig(fusion=FusionStrategy.RRF)
rrf · default
Reciprocal rank fusion

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))
normalized
Normalized, then weighted

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.

weighted_sum
Raw weighted sum

Weights the raw branch scores. Use it only when both branches already score on one scale. How fusion works →


Build it

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.

step 01 / 05 · shell
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
Ran with the 26.11.2 tag. The password is the one you set for the postgres user.
step 02 / 05 · python
Python
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(),
)
The engine creates the table and one inverted index over content, embedding and the metadata columns.
SQL
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
    );
CREATE TEXT SEARCH DICTIONARY · CREATE TABLE ×2 · CREATE INDEX. The sample uses 4-dimensional vectors; a real model uses its own size, for example FLOAT[768].
step 03 / 05 · python
Python
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)
add_documents embeds each chunk, upserts it and refreshes the index.
SQL
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;
INSERT 0 7 · INSERT 0 7 · VACUUM. Without VACUUM (REFRESH_TABLE), the background refresh publishes the rows within refresh_interval (1000 ms). Re-ingesting a chunk is INSERT … ON CONFLICT (id) DO UPDATE; a deleted row leaves search at the next refresh.
step 04 / 05 · python
Python
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])
The store sends one statement: both searches with the filter inside, fused by RRF. Its BM25 branch uses plainto_tsquery, which needs every word of its query, so pass the exact identifiers as fts_query.
SQL
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;
3 Deploy timeout errors 0.0323 · 4 Retry policy for deploys 0.0318 · 1 Incident AB-1234 postmortem 0.0315 · 2 Runbook: AB-1234 rollback 0.0315 · 5 Release guide 0.0159. The admin-only escalation entered neither search, and both AB-1234 chunks are in the top 5; vector search alone ranks the incident 6th.
step 05 / 05 · python
Python
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")
as_retriever() runs the same hybrid search with the filter; the chain numbers the chunks so the answer can cite them. The SQL tab also reranks the candidates with ai_rerank() first.
SQL
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;
One row: the model’s answer. ai_rerank() scores the five fused candidates, the best three become the context, and ai_generate() answers. Both read the provider from the secret; any OpenAI-compatible endpoint works, a local one included.

Use cases

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"]}}.

indexed columns
content rag_enembedding ivfspaceupdated_at
Documentation search →
example · space filter
How do I rotate the staging API key?
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;
retrieved · 5 sample pages
#chunksourcefound by
1Staging · API keysconfluence/ENG/api-keysbm25 + vector
2Secrets managementnotion/platform/secretsbm25 + vector
Example answer, composed from the two chunks: “Rotate the key on the Secrets page, then redeploy staging so services read it [1]. A rotated credential stays valid until its grace window closes [2].” A SALES page about staging API keys is outside the filter.

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"}}.

indexed columns
content rag_enembedding ivfdepartmentsigned_at
RAGFlow integration →
example · department + date
Which supplier contracts renew automatically in Q1?
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;
retrieved · 5 sample clauses
#chunksourcefound by
1Northwind MSA · §12 Renewalcontracts/northwind-msa.pdfbm25 + vector
2Acme supply agreement · §8contracts/acme-2025.pdfvector
Example answer: “Two contracts renew on 1 January: the Northwind MSA [1] and the Acme supply agreement [2]; both need written notice to cancel.” A 2021 contract with the same wording is outside the date filter.

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"]}}.

indexed columns
content rag_enembedding ivfrepolang
Database for AI agents →
example · repo + language
Where do we retry failed HTTP calls?
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;
retrieved · 5 sample chunks
#chunksourcefound by
1retry_with_backoff()api/net/retry.py:42bm25 + vector
2exponentialDelay()api/jobs/queue.go:118vector
Example answer: “HTTP calls retry through retry_with_backoff() [1]; the job queue has its own backoff in exponentialDelay() [2].” The browser copy in web/ is outside the repo filter.

FAQ

Frequently asked questions

Go to docs →

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

Get started with SereneDB

Install SereneDB and langchain-serenedb, and your retrieval layer is one table and one query away.