Skip to content

Hybrid retrieval

PyAIStack can combine cosine retrieval with keyword ranking. SQLite uses FTS5; PostgreSQL uses built-in full-text search with pgvector. Hybrid retrieval is optional and normal RAG() calls keep vector retrieval.

Store Vector branch Keyword branch
SQLiteVectorStore Exact cosine scan SQLite FTS5 with BM25 ordering
PgVectorStore Exact or approximate pgvector cosine search PostgreSQL full-text rank ordering

Quick start

Pull the models, then run the separate indexing and question examples from the repository root:

ollama pull embeddinggemma
ollama pull gemma3:4b
python examples/hybrid_retrieval/index.py
python examples/hybrid_retrieval/query.py "What does policy FIN-042 require?"

The SQLite index example refuses to append to a populated database. Use another --db filename for a fresh demo. The question example only queries the index.

For PostgreSQL setup and runnable index/query examples, see PostgreSQL + pgvector.

Configure retrieval

from pyaistack import RAG, RAGConfig
from pyaistack.vectorstores import SQLiteVectorStore

with SQLiteVectorStore("hybrid_knowledge.db") as store:
    rag = RAG(
        vector_store=store,
        config=RAGConfig(
            retrieval_mode="hybrid",
            candidate_k=20,
            top_k=5,
            min_score=None,
            include_sources=True,
        ),
    )
    answer = rag.ask("What does policy FIN-042 require?")
    print(answer.text)
    for source in answer.context_sources:
        print(source.document.text)  # exact excerpt sent to the model

An existing index must use the same embedding model. SQLite and PostgreSQL are hybrid-capable. The in-memory store supports vector search only; requesting hybrid mode with it raises ConfigurationError.

How ranking works

Both branches apply the metadata filter before limiting their candidates. Each branch retains up to max(candidate_k, top_k) results. SQLite FTS5 uses BM25 ordering; PostgreSQL uses ts_rank_cd. Rank fusion adds 1 / (60 + rank) from each branch, using one-based ranks. Equal fusion scores are ordered by document ID.

Queries are treated as plain text, not executable SQL. SQLite bounds lexical input to 64 unique word/number terms joined with OR. PostgreSQL uses a bounded plain-text query with the simple text-search configuration. Queries without lexical matches still retain vector candidates.

  • result.score is always cosine similarity, including keyword-only candidates.
  • result.ranking_score is the fusion score in hybrid results; it is normally None for vector results.
  • min_score remains a cosine threshold, applied to combined candidates before final selection. A high value can remove useful keyword matches.
  • answer.sources contains retrieved chunks, including ones that may not fit.
  • answer.context_sources contains only the actual text excerpts supplied.
  • The optional displayed Sources block uses context sources. It identifies context provenance, not proof that each generated claim is supported.

Filter behavior

results = rag.search(
    "purchase policy",
    metadata_filter={"department": "finance", "tags": ["purchasing", "approval"]},
)

Every field must match. A scalar filter matches the same scalar or an element of a stored array. An array filter against a stored array requires all requested elements; against a stored scalar it matches any requested element. Comparisons are case-sensitive. Missing keys do not equal JSON null.

SQLite uses parameterized SQL for scalar filters and a shared Python comparison function executed within SQLite for complex filters. PostgreSQL compiles the same public matching behavior into parameterized JSONB predicates. Metadata filtering is not an authorization system.

SQLite databases and cleanup

Back up the database with SQLite's backup API (or copy it while all connections are closed) before upgrading. The first hybrid query creates and backfills an FTS5 index transactionally from existing text; no re-embedding occurs. Inserts, updates and clears keep the keyword index synchronized through database triggers. Unknown newer FTS schemas are rejected.

Vector-only mode does not require FTS5. If the SQLite build lacks FTS5, hybrid search raises VectorStoreError; use an FTS5-enabled Python/SQLite build. Migration failures roll back. Prefer the context manager above; close() is idempotent. The application owns injected stores and providers.

Performance and limitations

SQLite exact vector search remains linear in matching vectors. PostgreSQL can use exact search or a compatible cosine ANN index. Hybrid retrieval adds work and is not guaranteed to improve every query. Benchmark the selected store, filters, candidate counts, and index settings with representative data.

Run python benchmarks/retrieval.py for the checked-in corpus, query-level results, Recall@5, MRR@5, latency, and Python allocation measurements. Use --fixture only for an offline pipeline check, not semantic accuracy evidence. See benchmark results.

Hybrid retrieval can be followed by an application-provided reranker. See reranking and verified citations. Inline claim-level citations, resumable ingestion, and duplicate detection are not implemented.