-
Notifications
You must be signed in to change notification settings - Fork 3
fix(postgres): enable hnsw.iterative_scan so wing-scoped kNN stops returning 0 rows #446
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e037c65
b83d0ba
1a6e9d6
127c104
cbb62ec
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -776,8 +776,50 @@ def _get_conn(self): | |
| if self._conn is None or self._conn.closed: | ||
| self._conn = psycopg2.connect(self.dsn) | ||
| self._conn.autocommit = True | ||
| self._apply_session_settings(self._conn) | ||
| return self._conn | ||
|
|
||
| def _apply_session_settings(self, conn) -> None: | ||
| """Per-connection GUCs the kNN path depends on. | ||
|
|
||
| ``hnsw.iterative_scan`` (pgvector >= 0.8): a filtered kNN | ||
| (``WHERE wing = %s ORDER BY embedding <=> q LIMIT k``) is planned as | ||
| an HNSW index scan *then* a filter. The index hands back its | ||
| ``ef_search`` nearest rows palace-wide and the filter discards the | ||
| ones outside the wing — so a wing that isn't in the global top-N | ||
| gets ZERO rows, no matter how good its best match is. Measured | ||
| 2026-09-03 on a 757K-drawer palace: a 13K-drawer wing returned 0 for a | ||
| well-formed prose query (``Rows Removed by Filter: 47``) while the | ||
| same query unscoped returned 20 and an exact scan returned 5. | ||
| ``relaxed_order`` makes the index keep scanning until LIMIT rows | ||
| survive the filter. Session-scoped, so it must be set on every new | ||
| connection (autocommit means no ``SET LOCAL``). Tolerated on older | ||
| pgvector (unknown GUC) — logged once, never fatal. | ||
| MEMPALACE_PG_HNSW_ITERATIVE_SCAN=off disables; any other value is | ||
| passed through (``relaxed_order`` | ``strict_order``). | ||
| """ | ||
| mode = os.environ.get("MEMPALACE_PG_HNSW_ITERATIVE_SCAN", "relaxed_order").strip() | ||
| if not mode or mode.lower() == "off": | ||
| return | ||
| if mode not in ("relaxed_order", "strict_order"): | ||
| logger.warning( | ||
| "MEMPALACE_PG_HNSW_ITERATIVE_SCAN=%r not in (relaxed_order, strict_order); " | ||
| "using relaxed_order", | ||
| mode, | ||
| ) | ||
| mode = "relaxed_order" | ||
| try: | ||
| cur = conn.cursor() | ||
| cur.execute("SET hnsw.iterative_scan = " + mode) | ||
| except Exception as exc: # noqa: BLE001 — pgvector < 0.8 has no such GUC | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -euo pipefail
rg -n -C 5 'except Exception|UndefinedObject|ProgrammingError|OperationalError|hnsw\.iterative_scan' \
mempalace/backends/postgres.py testsRepository: techempower-org/mempalace Length of output: 50381 🏁 Script executed: #!/bin/bash
set -euo pipefail
sed -n '1,90p' mempalace/backends/postgres.py
sed -n '760,830p' mempalace/backends/postgres.py
rg -n -C 3 '_apply_session_settings|def _get_conn|_conn\b|psycopg2|_load' mempalace/backends/postgres.pyRepository: techempower-org/mempalace Length of output: 12595 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- dependency declarations ---'
rg -n -C 3 'psycopg|postgres' pyproject.toml setup.cfg setup.py requirements* poetry.lock uv.lock 2>/dev/null || true
printf '%s\n' '--- PostgreSQL backend tests ---'
rg -n -C 5 'iterative_scan|_apply_session_settings|_get_conn|UndefinedObject|PostgresCollection|_load_psycopg2' tests/test_backends_postgres.py tests
printf '%s\n' '--- relevant backend lifecycle ---'
sed -n '170,215p' mempalace/backends/postgres.py
sed -n '830,880p' mempalace/backends/postgres.pyRepository: techempower-org/mempalace Length of output: 50381 🌐 Web query:
💡 Result: The error "SQLSTATE 42704: unrecognized configuration parameter" is a PostgreSQL server-side error [1]—not a bug in Psycopg 3 itself—that occurs when a client application attempts to set or query a configuration parameter that the PostgreSQL server does not recognize [1]. This error typically arises from one of the following scenarios: 1. Incompatible Client/Server Versions: You may be using a newer client tool (e.g., pg_dump/pg_restore v17+) to interact with an older database server (e.g., v16 or lower) that does not support a specific parameter [2]. 2. Database-Specific Syntax Errors: Tools designed for other database systems (like MySQL) may issue commands (e.g., SHOW TABLES or SET sql_mode) that are not valid in PostgreSQL [3][4][5]. 3. Incorrect Connection String Parameters: Some libraries or frameworks may automatically append configuration parameters to the connection string that the PostgreSQL server does not recognize [6][7]. 4. Misconfigured Server Settings: The parameter may be misspelled or non-existent in the specific version of PostgreSQL you are running [8][1]. To resolve this: - Verify that the parameter being set is supported by your version of PostgreSQL [8][9]. - If using third-party tools or migration scripts, check if they are sending parameters intended for a different database dialect [3][4]. - If the error occurs during a backup/restore, ensure your command-line utilities match the major version of your database server, or manually edit the dump file to remove the unrecognized SET command [2]. - If you are using a connection pooler like PgBouncer, ensure your connection strings are formatted correctly for the specific database or service (e.g., connecting to the pgbouncer database for administrative tasks) [6][10]. Because Psycopg 3 communicates directly with the database, it faithfully reports this server-side error to your application, wrapped as an UndefinedObject exception. If you encounter this, inspect the SQL command or connection parameters being executed immediately before the error occurs [1]. Citations:
Propagate non-compatibility failures.
🤖 Prompt for AI Agents |
||
| if not getattr(self, "_iterative_scan_warned", False): | ||
| logger.info( | ||
| "postgres: hnsw.iterative_scan unavailable (%s); filtered kNN may " | ||
| "under-return on wing-scoped queries — upgrade pgvector to >= 0.8", | ||
| str(exc).splitlines()[0][:120], | ||
| ) | ||
| self._iterative_scan_warned = True | ||
|
|
||
| def _detect_extensions(self, *, create: bool = False) -> None: | ||
| if self._vec_type: | ||
| return | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| """pgvector filtered-kNN under-return: the backend must enable hnsw.iterative_scan. | ||
|
|
||
| Measured 2026-09-03 on the production palace (pgvector 0.8.2, 757K drawers): a | ||
| wing-scoped prose query returned 0 rows because the HNSW scan hands back its | ||
| global nearest ~ef_search rows and the wing filter discards them all | ||
| (EXPLAIN: ``Rows Removed by Filter: 47``). The same query unscoped returned 20; | ||
| an exact scan returned 5; ``SET hnsw.iterative_scan = relaxed_order`` made the | ||
| index scan return 5. The GUC is session-scoped, so it must be issued on every | ||
| new connection. | ||
| """ | ||
|
|
||
| import logging | ||
| from unittest.mock import patch | ||
|
|
||
|
|
||
| from mempalace.backends import postgres as pg | ||
|
|
||
|
|
||
| class _FakeCursor: | ||
| def __init__(self, conn): | ||
| self.conn = conn | ||
|
|
||
| def execute(self, sql, *a): | ||
| self.conn.executed.append(str(sql)) | ||
| if self.conn.fail_on_set and "hnsw.iterative_scan" in str(sql): | ||
| raise RuntimeError('unrecognized configuration parameter "hnsw.iterative_scan"') | ||
|
|
||
| def fetchall(self): | ||
| return [] | ||
|
|
||
|
|
||
| class _FakeConn: | ||
| def __init__(self, fail_on_set=False): | ||
| self.executed = [] | ||
| self.autocommit = None | ||
| self.closed = 0 | ||
| self.fail_on_set = fail_on_set | ||
|
|
||
| def cursor(self): | ||
| return _FakeCursor(self) | ||
|
|
||
|
|
||
| class _FakeMod: | ||
| def __init__(self, conn): | ||
| self._conn = conn | ||
|
|
||
| def connect(self, dsn): | ||
| return self._conn | ||
|
|
||
|
|
||
| def _owner(): | ||
| return next( | ||
| c | ||
| for c in vars(pg).values() | ||
| if isinstance(c, type) and hasattr(c, "_apply_session_settings") | ||
| ) | ||
|
|
||
|
|
||
| def _backend(conn): | ||
| b = _owner().__new__(_owner()) | ||
| b.dsn = "postgresql://fake/db" | ||
| b._conn = None | ||
| return b | ||
|
|
||
|
|
||
| def test_new_connection_enables_iterative_scan(monkeypatch): | ||
| monkeypatch.delenv("MEMPALACE_PG_HNSW_ITERATIVE_SCAN", raising=False) | ||
| conn = _FakeConn() | ||
| with patch.object(pg, "_load_psycopg2", return_value=(_FakeMod(conn), None)): | ||
| b = _backend(conn) | ||
| assert b._get_conn() is conn | ||
| sets = [s for s in conn.executed if "hnsw.iterative_scan" in s] | ||
| assert sets == ["SET hnsw.iterative_scan = relaxed_order"] | ||
| assert conn.autocommit is True | ||
|
|
||
|
|
||
| def test_reused_connection_does_not_reissue(monkeypatch): | ||
| monkeypatch.delenv("MEMPALACE_PG_HNSW_ITERATIVE_SCAN", raising=False) | ||
| conn = _FakeConn() | ||
| with patch.object(pg, "_load_psycopg2", return_value=(_FakeMod(conn), None)): | ||
| b = _backend(conn) | ||
| b._get_conn() | ||
| b._get_conn() | ||
| assert sum("hnsw.iterative_scan" in s for s in conn.executed) == 1 | ||
|
|
||
|
|
||
| def test_env_off_disables(monkeypatch): | ||
| monkeypatch.setenv("MEMPALACE_PG_HNSW_ITERATIVE_SCAN", "off") | ||
| conn = _FakeConn() | ||
| with patch.object(pg, "_load_psycopg2", return_value=(_FakeMod(conn), None)): | ||
| _backend(conn)._get_conn() | ||
| assert not any("hnsw.iterative_scan" in s for s in conn.executed) | ||
|
|
||
|
|
||
| def test_strict_order_passthrough_and_bad_value_falls_back(monkeypatch): | ||
| conn = _FakeConn() | ||
| monkeypatch.setenv("MEMPALACE_PG_HNSW_ITERATIVE_SCAN", "strict_order") | ||
| with patch.object(pg, "_load_psycopg2", return_value=(_FakeMod(conn), None)): | ||
| _backend(conn)._get_conn() | ||
| assert "SET hnsw.iterative_scan = strict_order" in conn.executed | ||
| conn2 = _FakeConn() | ||
| monkeypatch.setenv("MEMPALACE_PG_HNSW_ITERATIVE_SCAN", "bogus; DROP TABLE x") | ||
| with patch.object(pg, "_load_psycopg2", return_value=(_FakeMod(conn2), None)): | ||
| _backend(conn2)._get_conn() | ||
| assert "SET hnsw.iterative_scan = relaxed_order" in conn2.executed, "never pass through junk" | ||
|
|
||
|
|
||
| def test_old_pgvector_without_guc_is_tolerated(monkeypatch, caplog): | ||
| monkeypatch.delenv("MEMPALACE_PG_HNSW_ITERATIVE_SCAN", raising=False) | ||
| conn = _FakeConn(fail_on_set=True) | ||
| with patch.object(pg, "_load_psycopg2", return_value=(_FakeMod(conn), None)): | ||
| b = _backend(conn) | ||
| with caplog.at_level(logging.INFO, logger="mempalace.postgres"): | ||
| assert b._get_conn() is conn # no raise | ||
| b._conn = None # force a second connect → warn only once | ||
| b._get_conn() | ||
| assert sum("iterative_scan unavailable" in r.message for r in caplog.records) == 1 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: techempower-org/mempalace
Length of output: 10099
🏁 Script executed:
Repository: techempower-org/mempalace
Length of output: 7015
🌐 Web query:
pgvector hnsw.iterative_scan off relaxed_order strict_order GUC documentation💡 Result:
In pgvector (version 0.8.0 and later), the hnsw.iterative_scan GUC (Grand Unified Configuration) parameter controls the behavior of iterative index scans, which are designed to improve recall when approximate nearest neighbor (ANN) searches are combined with selective filtering (WHERE clauses) [1][2][3]. When an HNSW index scan with filters returns fewer results than requested, iterative scanning allows the system to automatically continue searching the index until the requested number of results is found or a resource limit is reached [1][4][5]. The hnsw.iterative_scan parameter accepts three values [5][6][7]: 1. off (Default): Iterative scanning is disabled. The index performs a standard, single-pass approximate search. This is the fastest mode but may return fewer results than the LIMIT if filters are highly selective [5][6][8]. 2. strict_order: Enables iterative scanning and ensures that the returned results maintain exact distance ordering from the query vector [1][4][6]. 3. relaxed_order: Enables iterative scanning but allows results to be slightly out of order by distance in exchange for better recall and potentially improved performance [1][5][7]. Associated parameters include: - hnsw.max_scan_tuples: Specifies the maximum number of tuples to visit during an iterative scan (default 20,000) [1][5][6]. - hnsw.scan_mem_multiplier: Sets the maximum memory allowed for the iterative scan as a multiple of work_mem (default 1) [1][5][6]. These settings can be configured at the session level using the SET command (e.g., SET hnsw.iterative_scan = 'strict_order';) [1][2][9].
Citations:
Set
hnsw.iterative_scantooff.When
MEMPALACE_PG_HNSW_ITERATIVE_SCAN=off,_apply_session_settingsreturns without overriding the session GUC. A role or database default can therefore keep iterative scanning enabled. Updatetests/test_postgres_iterative_scan.pyto assertSET hnsw.iterative_scan = off.🤖 Prompt for AI Agents