Skip to content

feat(postgis): add PostGIS documentation support - #59

Merged
gregsaab merged 8 commits into
timescale:mainfrom
cbc3929:feat/postgis-support
Feb 25, 2026
Merged

feat(postgis): add PostGIS documentation support#59
gregsaab merged 8 commits into
timescale:mainfrom
cbc3929:feat/postgis-support

Conversation

@cbc3929

@cbc3929 cbc3929 commented Dec 31, 2025

Copy link
Copy Markdown
Contributor

Summary

This PR adds comprehensive PostGIS documentation support to pg-aiguide, enabling AI coding assistants to provide better guidance for spatial database operations.

New Features

  • PostGIS Documentation Scraper (ingest/postgis_docs.py)
    • Dedicated scraper for PostGIS manual (DocBook HTML format)
    • Supports both file and database storage modes
    • Header-based markdown chunking with token counting
  • Search APIs
    • semantic_search_postgis_docs - Vector similarity search for PostGIS documentation
    • keyword_search_postgis_docs - BM25 keyword search for PostGIS documentation
  • Database Migration
    • postgis_pages and postgis_chunks tables
    • HNSW index for fast vector similarity search

Enhanced Embedding Configuration

Added support for custom embedding providers (beyond OpenAI):

  • OPENAI_BASE_URL - Custom OpenAI-compatible API endpoint (e.g., Ollama, SiliconFlow)
  • EMBEDDING_MODEL - Custom embedding model name
  • EMBEDDING_DIMENSIONS - Configurable vector dimensions
    This allows users to use alternative embedding services while maintaining compatibility with the existing database schema.

Testing

  • ✅ TypeScript build passes
  • ✅ Python syntax validation passes
  • ✅ Database migration tested
  • ✅ Scraper tested with file mode (5 pages)
  • ✅ Scraper tested with database mode (3 pages, 43 chunks)
  • ✅ Semantic search verified with vector similarity queries

Usage

# Scrape PostGIS documentation
cd ingest
uv run python postgis_docs.py --version 3.5 --storage-type database
# With custom embedding provider
export OPENAI_BASE_URL=https://api.siliconflow.cn/v1
export EMBEDDING_MODEL=Qwen/Qwen3-Embedding-8B
uv run python postgis_docs.py --version 3.5 --storage-type database
Checklist
- Code follows project conventions
- All comments in English
- Documentation updated (README.md, .env.sample)
- Database migration included
- Tests performed locally

@CLAassistant

CLAassistant commented Dec 31, 2025

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@cbc3929
cbc3929 force-pushed the feat/postgis-support branch from b716454 to e5d7b77 Compare December 31, 2025 06:30

@murrayju murrayju left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the submission! A few minor requests, if you don't mind fixing them.

Comment thread ingest/postgis_docs.py Outdated
conn.execute("DROP TABLE IF EXISTS docs.postgis_chunks_tmp CASCADE")
conn.execute("DROP TABLE IF EXISTS docs.postgis_pages_tmp CASCADE")

# 创建页面表

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use English for all comments, please?

Comment thread ingest/postgis_docs.py Outdated
)
""")

# 创建块表

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here too

Comment thread ingest/postgis_docs.py Outdated

args = parser.parse_args()

# 验证数据库存储需求

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here as well.

, sub_chunk_index INTEGER NOT NULL DEFAULT 0
, content TEXT NOT NULL
, metadata JSONB
, embedding vector(1536)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The schema here (and in the existing pg and tiger schemas) hard codes the vector size to 1536. I don't think adding EMBEDDING_DIMENSIONS as an environment variable adds value, unless the schema would be updated/migrated to match. Otherwise, inserts will just fail.

I'd be inclined to keep this fixed as 1536 for now.

Comment thread ingest/postgis_docs.py Outdated
pg_database = os.environ.get("PGDATABASE")

if all([pg_user, pg_password, pg_host, pg_port, pg_database]):
return f"postgresql://{pg_user}:{pg_password}@{pg_host}:{pg_port}/{pg_database}"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The password is not being url-encoded, so a password containing e.g. @ would break the connection string. I can see this is also an issue in the existing code, so we could file a ticket to address this later if you'd prefer.

Comment thread ingest/postgis_docs.py Outdated
"""

import argparse
from dataclasses import dataclass, field

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

field is not used

Comment thread ingest/postgis_docs.py Outdated
from psycopg.sql import SQL, Identifier
import re
import requests
from urllib.parse import urljoin, urlparse

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

urlparse is not used

Comment thread ingest/postgis_docs.py Outdated
import time

THIS_DIR = Path(__file__).parent.resolve()
load_dotenv(dotenv_path=os.path.join(THIS_DIR, "..", ".env"))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
load_dotenv(dotenv_path=os.path.join(THIS_DIR, "..", ".env"))
load_dotenv(dotenv_path=THIS_DIR.parent / ".env")

@cbc3929

cbc3929 commented Jan 7, 2026

Copy link
Copy Markdown
Contributor Author

Hi @murrayju, thank you for the thorough review!

I've addressed all the feedback in my latest commits:

  • ✅ Replaced all Chinese comments with English - apologies for that oversight!
  • ✅ Removed unused imports (field, urlparse)
  • ✅ Updated load_dotenv to use pathlib style
  • ✅ Fixed EMBEDDING_DIMENSIONS to 1536 to match the database schema
  • ✅ Added URL encoding for password to handle special characters

Regarding EMBEDDING_DIMENSIONS: My original intent was to support alternative embedding providers like Qwen3-Embedding-8B (which defaults to 4096 dimensions) for users who prefer local/self-hosted models. However, I understand that without updating the database schema to match, this would just cause insert failures. I've reverted it to a fixed value of 1536 as you suggested. If there's interest in supporting configurable dimensions in the future, we could address the schema side as well.

I've also added a new skills/design-postgis-tables/SKILL.md - a comprehensive PostGIS spatial table design reference that complements PR #62's coding style guide.

Thanks again for the review!

@murrayju

murrayju commented Jan 8, 2026

Copy link
Copy Markdown
Member

Regarding EMBEDDING_DIMENSIONS: ... I've reverted it to a fixed value of 1536 as you suggested.

@cbc3929 I understood this to mean that you would remove the EMBEDDING_DIMENSIONS env var entirely, but I still see it in the diff. Am I missing something?

Could you also resolve the merge conflict? We switched to biome, and you just need to run ./bun lint --write to apply the fixes (sort the imports).

@cbc3929

cbc3929 commented Jan 9, 2026

Copy link
Copy Markdown
Contributor Author

Hi @murrayju, thanks for the follow-up!

I've addressed your feedback in my latest commits:

  • ✅ Completely removed EMBEDDING_DIMENSIONS env var from postgres_docs.py and tiger_docs.py (now fixed at 1536 in all three files)
  • ✅ Merged upstream/main and resolved the conflict in src/apis/index.ts
  • ✅ Ran biome lint --write to fix import ordering

I also noticed and fixed a related issue:

  • ✅ Added URL encoding for passwords in tiger_docs.py and postgres_docs.py (same fix that was in postgis_docs.py)
  • ✅ Updated .env.sample to clarify that embedding dimensions are fixed at 1536

Ready for another look! 🙏

@murrayju

Copy link
Copy Markdown
Member

@cbc3929 sorry for the delay here, I was at an offsite last week.

I just merged #68, which unfortunately creates some merge conflicts here. However, it addresses a concern with tool bloat as we add more docs sources to this project. You should be able to fold your additions into the singular search_docs tool.

I also added migrations for the bm25 indexes, which were missing here as well.

Let me know if any of that is unclear!

Add comprehensive PostGIS documentation scraping and semantic search capabilities:

New features:
- PostGIS manual scraper (postgis_docs.py) for DocBook HTML documentation
- Semantic search API (semantic_search_postgis_docs)
- Keyword search API (keyword_search_postgis_docs)
- Database migration for postgis_pages and postgis_chunks tables
- HNSW vector index for fast similarity search

Enhanced embedding configuration:
- Add OPENAI_BASE_URL for custom OpenAI-compatible endpoints
- Add EMBEDDING_MODEL for custom embedding model selection
- Add EMBEDDING_DIMENSIONS for configurable vector dimensions
- Support third-party embedding services (e.g., SiliconFlow, Ollama)

Updated files:
- README.md: Add PostGIS to supported extensions
- .env.sample: Document new embedding configuration options
- tiger_docs.py, postgres_docs.py: Add custom embedding support
- Replace Chinese comments with English
- Remove unused imports (field, urlparse)
- Use pathlib style for dotenv path
- Fix EMBEDDING_DIMENSIONS to 1536 to match database schema
- Add URL encoding for password in connection string
Comprehensive reference covering:
- Geometry vs Geography selection guide
- Coordinate systems (SRID) best practices
- Spatial indexing (GiST, BRIN, SP-GiST)
- Table design examples (POI, parcels, GPS tracking)
- Performance optimization patterns
- Data validation techniques
- Add URL encoding for database passwords in tiger_docs.py and postgres_docs.py
  to handle special characters like '@' in connection strings
- Update .env.sample to clarify that EMBEDDING_DIMENSIONS is fixed at 1536
  (removed configurable env var since it must match database schema)
@cbc3929
cbc3929 force-pushed the feat/postgis-support branch from 36e8e3b to 1694507 Compare January 22, 2026 03:10
@cbc3929

cbc3929 commented Jan 22, 2026

Copy link
Copy Markdown
Contributor Author

Hi @murrayju,

Thank you for your patience and the detailed review feedback. Apologies for not keeping up with the repository — I wasn't aware of the major refactoring in PR #68 until now.

I've now rebased onto main and integrated the PostGIS functionality into the unified search_docs tool as you suggested. Here's a summary of the changes:

Changes made:

  • Removed the standalone keywordSearchPostgisDocs.ts and semanticSearchPostgisDocs.ts files
  • Added postgis as a new source option in search_docs (supports both semantic and keyword search)
  • Added BM25 index for postgis_chunks in the migration file
  • Updated README.md and API.md to reflect the new unified tool structure

This should address the tool bloat concern and align with the patterns established in PR #68.

Please let me know if there's anything else that needs to be adjusted.

Thanks again for your guidance!

Comment on lines +30 to +34
await client.query(/* sql */ `
CREATE INDEX CONCURRENTLY IF NOT EXISTS postgis_chunks_content_idx
ON ${schema}.postgis_chunks
USING bm25(content) WITH (text_config='english');
`);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These should be added to a new migration, not edit an existing migration.

Comment thread ingest/postgres_docs.py Outdated
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL") # Optional: custom API endpoint
EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "text-embedding-3-small") # Default model
EMBEDDING_DIMENSIONS = int(os.getenv("EMBEDDING_DIMENSIONS", "1536")) # Default dimensions

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd still prefer to declare this as a const, not read the env var (the schema is inflexible)

Comment thread ingest/tiger_docs.py Outdated
OPENAI_API_KEY = os.getenv('OPENAI_API_KEY')
OPENAI_BASE_URL = os.getenv('OPENAI_BASE_URL') # Optional: custom API endpoint
EMBEDDING_MODEL = os.getenv('EMBEDDING_MODEL', 'text-embedding-3-small') # Default model
EMBEDDING_DIMENSIONS = int(os.getenv('EMBEDDING_DIMENSIONS', '1536')) # Default dimensions

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

- Create separate migration for PostGIS BM25 index instead of editing existing migration
- Change EMBEDDING_DIMENSIONS to const in postgres_docs.py and tiger_docs.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@cbc3929

cbc3929 commented Jan 29, 2026

Copy link
Copy Markdown
Contributor Author

Hi @murrayju,

Thanks for the feedback! I've addressed all three points:

  1. Migration file: Reverted 1767990776052-add-bm25-indexes.js to the original and created a new separate migration (1769649893644-add-postgis-bm25-index.js) for the PostGIS BM25 index.

  2. EMBEDDING_DIMENSIONS: Changed from os.getenv() to a fixed constant 1536 in both postgres_docs.py and tiger_docs.py to match the database schema.

Let me know if there's anything else!

Comment thread ingest/postgis_docs.py
POSTGIS_DOMAIN = "postgis.net"

# Token counting using tiktoken
ENC = tiktoken.get_encoding("cl100k_base")

@gregsaab gregsaab Feb 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
ENC = tiktoken.get_encoding("cl100k_base")
ENC = tiktoken.encoding_for_model(EMBEDDING_MODEL)

This simplifies code a bit and will prevent issues with tokenizer not matching a non-default model via envvar

Comment thread ingest/postgis_docs.py
return None

try:
time.sleep(self.delay)

@gregsaab gregsaab Feb 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is a sleep needed before making a request that has a 30 second timeout?

Comment thread ingest/postgis_docs.py

# Remove images with data: URLs
for img in soup.find_all("img", src=True):
if img["src"].startswith("data:"):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

may be a good idea to also look at the srcset array?

Comment thread ingest/postgis_docs.py
def chunk_markdown(self, markdown: str, page: Page) -> list[Chunk]:
"""Split Markdown into chunks based on headers."""
chunks = []
header_pattern = re.compile(r"^(#{1,3}) (.+)$", re.MULTILINE)

@gregsaab gregsaab Feb 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

given that we are splitting on newlines, we shouldn't need multiline. Also, is there a particular reason to limit the definition of a header to just # - ###?

Suggested change
header_pattern = re.compile(r"^(#{1,3}) (.+)$", re.MULTILINE)
header_pattern = re.compile(r"^(#+) (.+)$")

Comment thread ingest/postgis_docs.py
if current_chunk_lines:
content = "\n".join(current_chunk_lines).strip()
if content:
chunks.append(Chunk(

@gregsaab gregsaab Feb 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than creating chunks, appending them, then iterating over all of the chunks to then conditionally split them into subchunks, I think it would be more straighforward to create a method like

def create_chunks(idx: int, header: str, header_path: str, content: str) -> list[Chunk]:
     chunks = []
     tokens = ENC.encode(content)
     number_of_chunks = math.ceil(len(tokens) / MAX_CHUNK_TOKENS)

     for sub_idx in range(number_of_chunks):
          chunk_tokens = tokens[sub_idx * MAX_CHUNK_TOKENS:(sub_idx + 1)*MAX_CHUNK_TOKENS]
          chunk_content = ENC.decode(chunk_tokens)
          chunks.append(Chunk(
                    idx=idx,
                    header=header,
                    header_path=header_path,
                    content=chunk_content,
                    token_count=len(chunk_tokens),
                    subindex=sub_idx,
                ))

then this code would look like

for line in lines:
     match = header_pattern.match(line)
     if match:
         # Save current chunk
         if current_chunk_lines:
               content = "\n".join(current_chunk_lines).strip()
               if content:
                    chunks.extend(create_chunks(idx=idx, header=current_header, header_path=header_path.copy(), content=content)

Comment thread ingest/postgis_docs.py

# Token counting using tiktoken
ENC = tiktoken.get_encoding("cl100k_base")
MAX_CHUNK_TOKENS = 7000

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

openai allows for 8191 tokens per embedding (doc), is there a reason you chose 7000?

Comment thread ingest/postgis_docs.py
for chunk in chunks:
# Generate embedding using configurable model and dimensions
try:
embedding = (

@gregsaab gregsaab Feb 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Structure-wise, I would rather that the save_to_database method was more limited to just the persistence of the data models and not responsible for the logic to create embeddings.

Logic wise, there are a few issues with this:

  1. there is a limit to the number of tokens that can be embedded in one "document"
  2. higher chance of hitting rate limits on creating embeddings if you are calling that endpoint separately for every single chunk, rather than combining multiple chunks into a single request.

In order to minimize the risk of rate limiting (and reducing cost), we should combine multiple chunks into a single request

What if a function like this were added

MAX_TOKEN_PER_REQUEST = 300_000
async def get_chunk_embeddings(chunks: list[Chunk | None]) -> list[float]:
     # i would just move the client creation to the top of the script
     current_batch_token_count = 0
     current_batch: list[Chunk] = []
     embeddings: float[] = []
    
     chunks.append(None) #signal that we are out of chunks
     for chunk in chunks:
           add_chunk_to_batch = chunk.token_count + current_batch_token_count < MAX_TOKEN_PER_REQUEST if chunk else False
           if add_chunk_to_batch:
               current_batch.append(chunk)
               current_batch_token_count += chunk.token_count
           if not add_chunk_to_batch:
               batch_embeddings = client.embeddings.create(input=current_batch)
               embeddings.extend(batch_embeddedings)
               current_batch = [chunk] if chunk else []
               current_batch_token_count = chunk.token_count if chunk else 0
           

Then you would have a signature for this method like:

 def save_to_database(
        self,
        conn: psycopg.Connection,
        page: Page,
        chunks: list[Chunk],
       embeddings: list[float]

Comment thread ingest/postgis_docs.py

conn.execute(
"""
INSERT INTO docs.postgis_chunks_tmp

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rather than an execute insert for each chunk, you could pass an array as the parameter into the execute and use unnest in the sql to iterate over each json row.

".sr-only",
".code-block-copy-button"
]
# Add more domains as needed

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should probably keep this comment below your postgis.net entry

@gregsaab

gregsaab commented Feb 19, 2026

Copy link
Copy Markdown
Contributor

@cbc3929 I am going to merge your changes and will follow up with my own PR with my suggestions. Thank you for your contribution! We apologize for the duration of this review.

gregsaab and others added 2 commits February 25, 2026 10:05
Signed-off-by: Greg Saab <greg@onethirty.one>
@gregsaab
gregsaab merged commit 526b735 into timescale:main Feb 25, 2026
1 check passed
gregsaab added a commit that referenced this pull request Feb 26, 2026
## What
Refactor `postgres_docs.py` and `postgis_docs.py` so we can share
functionality between the two.

Other changes:
* add vscode launch/tasks for debugging
* add ruff rules
* add workflows for prod/dev

## Why
This is a follow up to a external PR submission -- decision was to merge
that PR then address refactoring.

## Testing
I tested postgres and postgis locally and verified that the outputs look
good. I scanned through all of the chunks and spot checked a bunch of
them.

I have attached the outputs from both

[postgres.json](https://github.com/user-attachments/files/25580373/postgres.json)

[postgis.json](https://github.com/user-attachments/files/25580374/postgis.json)


## Related PR
#59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants