Ctrl K
ComunidadGuías…

scripts/benchmark_postgres_fts.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Reproduce large-project FTS costs against a disposable local PostgreSQL database.

Example (dedicated server; do not point this at a development database):
    docker run -d --name bm-fts-bench --cpus 1 --memory 2g \
        -e POSTGRES_PASSWORD=benchmark -e POSTGRES_DB=benchmark \
        -p 127.0.0.1:55491:5432 pgvector/pgvector:pg17 \
        -c shared_buffers=128MB -c work_mem=4MB \
        -c max_parallel_workers_per_gather=0 -c jit=off
    uv run python scripts/benchmark_postgres_fts.py --seed
    uv run python scripts/benchmark_postgres_fts.py --output /tmp/fts-before.json

The default corpus has 32k documents and 320k TOAST-heavy chunks, using the actual
search DDL and query compiler. Five percent of documents match the selective query,
with its words split across chunks. No embedding model or customer data is needed.

Use --restart-container bm-fts-bench to restart the dedicated server before each
statement, then measure it twice. This clears PostgreSQL shared buffers, NOT the
Docker VM's OS page cache or Neon's file cache. Treat buffer counts as the primary
comparison; these timings do not establish cold Neon latency. Never run benchmarks
concurrently. The JSON keeps complete EXPLAIN (ANALYZE, BUFFERS) plans.
"""

from __future__ import annotations

import argparse
import asyncio
import json
import subprocess
import time
from pathlib import Path
from typing import Any

from sqlalchemy import text
from sqlalchemy.engine import make_url
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine

from basic_memory.models import search as ddl
from basic_memory.repository.postgres_search_query import compile_fts_filter
from basic_memory.repository.search_query import PreparedSearchQuery
from basic_memory.repository.search_scope import ProjectScope
from basic_memory.schemas.search import SearchItemType

DEFAULT_URL = "postgresql+asyncpg://postgres:benchmark@127.0.0.1:55491/benchmark"
QUERIES = ("meeting budget review", "meeting", "archive", "missingword budget")


async def seed(engine: AsyncEngine, notes: int) -> None:
    """Create a fresh fixture; fail if the target already contains these tables."""
    async with engine.begin() as connection:
        await connection.execute(text("CREATE TABLE project (id integer PRIMARY KEY)"))
        await connection.execute(text("INSERT INTO project VALUES (1), (2), (3), (4)"))
        for statement in (
            ddl.CREATE_POSTGRES_SEARCH_INDEX_TABLE,
            ddl.CREATE_POSTGRES_SEARCH_INDEX_FTS_CHUNKS_TABLE,
        ):
            await connection.execute(statement)
        await connection.execute(
            text("""
                INSERT INTO search_index (
                    id, project_id, type, title, content_stems, content_snippet,
                    entity_id, file_path, permalink, metadata, created_at, updated_at
                )
                SELECT i, 1 + (i % 4), 'entity', 'Document ' || i, 'archive', 'archive',
                    i, 'notes/' || i || '.md', 'notes/' || i, '{}', now(), now()
                FROM generate_series(1, :notes) i
            """),
            {"notes": notes},
        )
        # Distinct lexemes defeat compression and force both text and vectors into
        # TOAST. Reusing 1000 vocabularies keeps seed generation manageable.
        await connection.execute(
            text("""
            CREATE TABLE filler AS
            SELECT i AS filler_id, string_agg(md5(i::text || '-' || j::text), ' ') AS body
            FROM generate_series(1,1000) i CROSS JOIN generate_series(1,128) j
            GROUP BY i
        """)
        )
    for start in range(0, notes, 1000):
        async with engine.begin() as connection:
            await connection.execute(
                text("""
                    INSERT INTO search_index_fts_chunks (
                        project_id, search_index_id, search_index_type, chunk_index, chunk_text
                    )
                    SELECT 1 + (i % 4), i, 'entity', j,
                        CASE WHEN i % 20 = 0 THEN
                            CASE j % 3 WHEN 0 THEN 'meeting '
                                WHEN 1 THEN 'budget ' ELSE 'review ' END
                            ELSE 'archive ' END || f.body
                    FROM generate_series(:start + 1, :stop) i
                    CROSS JOIN generate_series(0,9) j
                    JOIN filler f ON f.filler_id = 1 + (i % 1000)
                """),
                {"start": start, "stop": min(start + 1000, notes)},
            )
        print(f"Seeded {min(start + 1000, notes) * 10} chunks", flush=True)
    async with engine.begin() as connection:
        for statement in (
            ddl.CREATE_POSTGRES_SEARCH_INDEX_FTS,
            ddl.CREATE_POSTGRES_SEARCH_INDEX_FTS_CHUNKS_INDEX,
            ddl.CREATE_POSTGRES_SEARCH_INDEX_SCRIPT_NGRAMS_FTS,
            ddl.CREATE_POSTGRES_SEARCH_INDEX_FTS_CHUNKS_SCRIPT_NGRAMS_INDEX,
        ):
            await connection.execute(statement)
    async with engine.connect() as connection:
        await connection.execution_options(isolation_level="AUTOCOMMIT")
        await connection.execute(text("VACUUM ANALYZE"))


def restart(container: str) -> None:
    """Clear shared buffers on the explicitly named disposable benchmark server."""
    subprocess.run(["docker", "restart", container], check=True, capture_output=True)
    for _ in range(60):
        ready = subprocess.run(
            ["docker", "exec", container, "pg_isready", "-U", "postgres", "-d", "benchmark"],
            capture_output=True,
        )
        if ready.returncode == 0:
            return
        time.sleep(0.5)
    raise RuntimeError("Benchmark PostgreSQL did not restart within 30 seconds")


async def measure(engine: AsyncEngine, container: str | None) -> dict[str, Any]:
    measurements: dict[str, Any] = {}
    for query in QUERIES:
        compiled = compile_fts_filter(
            ProjectScope.of([1, 2, 3, 4]),
            PreparedSearchQuery(search_text=query, search_item_types=[SearchItemType.ENTITY]),
            allow_relaxed=True,
        )
        statements = {
            "page": (
                f"SELECT search_index.id, {compiled.score_expression} AS score "
                f"FROM {compiled.from_clause} WHERE {compiled.where_clause} "
                "ORDER BY score DESC, search_index.id ASC LIMIT 130"
            ),
            "count": (f"SELECT count(*) FROM {compiled.from_clause} WHERE {compiled.where_clause}"),
        }
        for kind, sql in statements.items():
            if container:
                await engine.dispose()
                await asyncio.to_thread(restart, container)
            for attempt in range(2):
                async with engine.begin() as connection:
                    # Match execute_fts_statement's planner setting for text queries.
                    await connection.execute(text("SET LOCAL enable_seqscan = off"))
                    await connection.execute(text("SET LOCAL statement_timeout = '120s'"))
                    result = await connection.execute(
                        text("EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) " + sql), compiled.params
                    )
                    explained = result.scalar_one()[0]
                label = f"{query}/{kind}/{attempt + 1}"
                measurements[label] = explained
                plan = explained["Plan"]
                print(
                    f"{label}: {explained['Execution Time']:.1f} ms; "
                    f"{plan.get('Shared Hit Blocks', 0)} hits, "
                    f"{plan.get('Shared Read Blocks', 0)} reads",
                    flush=True,
                )
    return measurements


async def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--url", default=DEFAULT_URL)
    parser.add_argument("--seed", action="store_true")
    parser.add_argument("--notes", type=int, default=32000)
    parser.add_argument("--restart-container")
    parser.add_argument("--output", type=Path, default=Path("/tmp/postgres-fts-benchmark.json"))
    args = parser.parse_args()
    url = make_url(args.url)
    if url.host not in {"localhost", "127.0.0.1", "::1"}:
        parser.error("Use a dedicated local PostgreSQL server")
    if args.notes <= 0:
        parser.error("--notes must be positive")
    engine = create_async_engine(url)
    try:
        if args.seed:
            await seed(engine, args.notes)
        else:
            results = await measure(engine, args.restart_container)
            args.output.write_text(json.dumps(results, indent=2) + "\n")
    finally:
        await engine.dispose()


if __name__ == "__main__":
    asyncio.run(main())