OpenSearch vs pgvector vs S3 Vectors for Codebase RAG on AWS

OpenSearch vs pgvector vs S3 Vectors for Codebase RAG on AWS
13 views

“Just use a vector database” is how codebase chat ends up citing the wrong file. Someone asks “where do we build the idempotency key?”, the store returns twenty chunks that are semantically about idempotency, and the one function actually named createIdempotencyKey isn’t among them, because the tenant filter discarded most of the candidates the index returned, and nothing searched for the identifier itself.

On AWS you have three serious choices for the vectors: Aurora PostgreSQL with pgvector, Amazon OpenSearch Service, and Amazon S3 Vectors. For code, the store’s brand matters less than three behaviors: how it combines metadata filters with approximate search, whether it can search exact identifiers alongside vectors, and how fast a merged change becomes searchable. This guide compares them on those points with tested SQL and API calls, and merges our earlier S3 Vectors vs OpenSearch comparison into it.

⚡ TL;DR
  • pgvector on Aurora: best fit when you already run Postgres and need SQL joins for permissions. Filters are applied after the HNSW scan, so enable hnsw.iterative_scan (pgvector 0.8.0+) or filtered queries can return far fewer rows than you asked for. Hybrid search is DIY with tsvector and reciprocal rank fusion.
  • OpenSearch: best fit for first-class hybrid keyword + vector search (hybrid query, OpenSearch 2.11+; RRF from 2.19) and efficient filtering inside k-NN queries. Use the Faiss or Lucene engine; NMSLIB is deprecated.
  • S3 Vectors: cheapest to operate for large, less frequently queried corpora. Writes are strongly consistent, and indexes in ENHANCED mode pre-filter on metadata. No keyword search, so pair it with a lexical index for identifiers.
  • Whatever you choose: chunk by symbol, filter by tenant in the same query, fuse lexical and vector results, and pick the store by recall@k on your own golden questions.

Who this is for: engineers building retrieval for a coding assistant or code-search bot on AWS. You should know what an embedding is; the guide explains the rest. The pgvector SQL ran on PostgreSQL 17 with pgvector 0.8.0; the S3 Vectors code was checked against the boto3 service model; the OpenSearch requests follow the OpenSearch documentation.

What code retrieval needs that generic RAG doesn’t

  • Exact identifiers. Questions name functions, error codes and config keys. Embeddings are bad at rare tokens, so you need lexical search too.
  • Hard filters. Tenant, repository and permission filters aren’t ranking hints. A chunk from another customer’s repo must never be returned.
  • Freshness. After a merge, a deleted function that still answers questions is worse than no answer.
  • Good chunks. A function split down the middle by a 512-token window can’t be cited usefully. Chunk by symbol.
Figure 1. One retrieval contract, three stores. The pipeline is the same; what differs is how each store filters, whether it searches keywords natively, and how quickly writes become visible. Diagram by CheatCoders.
Figure 1. One retrieval contract, three stores. The pipeline is the same; what differs is how each store filters, whether it searches keywords natively, and how quickly writes become visible. Diagram by CheatCoders.

Step 1: Chunk by symbol

For Python, the standard library’s ast module is enough: one chunk per top-level function, one per class, and one per method when a class is too big. Include decorators, and keep the class line as context for methods.

python
"""Symbol-aware chunks: one chunk per top-level function, and per method for classes."""
import ast
from dataclasses import dataclass

MAX_CHARS = 8000


@dataclass
class Chunk:
    path: str
    symbol: str
    start_line: int
    end_line: int
    text: str


def chunk_python_file(path: str, source: str) -> list:
    lines = source.splitlines()
    chunks = []

    def emit(node, name):
        start = min([d.lineno for d in getattr(node, "decorator_list", [])] + [node.lineno])
        text = "\n".join(lines[start - 1:node.end_lineno])
        chunks.append(Chunk(path, name, start, node.end_lineno, text))

    for node in ast.parse(source).body:
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            emit(node, node.name)
        elif isinstance(node, ast.ClassDef):
            block = "\n".join(lines[node.lineno - 1:node.end_lineno])
            if len(block) <= MAX_CHARS:
                emit(node, node.name)
            else:                                   # big class: one chunk per method, prefixed by the class line
                for item in node.body:
                    if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
                        emit(item, f"{node.name}.{item.name}")
                        chunks[-1].text = lines[node.lineno - 1] + "\n    ...\n" + chunks[-1].text
    return chunks


if __name__ == "__main__":
    src = '''import hashlib

def create_idempotency_key(req) -> str:
    return hashlib.sha256(req.body).hexdigest()

class Client:
    @staticmethod
    def build(cfg):
        return Client()
'''
    for c in chunk_python_file("payments/keys.py", src):
        print(c.symbol, c.start_line, c.end_line)

Store each chunk with tenant, repo, path, symbol and commit. For other languages, tree-sitter grammars give you the same structure.

Option A: Aurora PostgreSQL with pgvector

Aurora PostgreSQL supports the pgvector extension, including the 0.8.x releases on current engine versions (check the Aurora extension versions table for yours). The schema below keeps filter columns, a generated tsvector for keyword search, and an HNSW index for cosine distance:

sql
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE code_chunks (
  id          bigserial PRIMARY KEY,
  tenant_id   text   NOT NULL,
  repo        text   NOT NULL,
  path        text   NOT NULL,
  symbol      text,
  commit_sha  text   NOT NULL,
  content     text   NOT NULL,
  content_tsv tsvector GENERATED ALWAYS AS (to_tsvector('simple', coalesce(symbol, '') || ' ' || content)) STORED,
  embedding   vector(1024) NOT NULL          -- must match the embedding model's output size
);

CREATE INDEX code_chunks_tenant_repo ON code_chunks (tenant_id, repo);
CREATE INDEX code_chunks_tsv ON code_chunks USING gin (content_tsv);
CREATE INDEX code_chunks_hnsw ON code_chunks USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);      -- pgvector defaults; tune against your recall target

The filtering trap

pgvector’s documentation is clear that with approximate indexes, filtering is applied after the index is scanned. With the default hnsw.ef_search of 40, a filter that matches 10% of rows returns about 4 rows on average. We reproduced this on a 20,000-row table where one tenant owns 5% of the rows, asking for 20 nearest neighbors:

sql
\set q '(SELECT embedding FROM code_chunks WHERE id = 7)'
SET hnsw.iterative_scan = off;
SELECT 'iterative off' AS mode, count(*) FROM (SELECT id FROM code_chunks WHERE tenant_id = 'acme' ORDER BY embedding <=> :q LIMIT 20) s;
SET hnsw.iterative_scan = strict_order;
SELECT 'strict_order' AS mode, count(*) FROM (SELECT id FROM code_chunks WHERE tenant_id = 'acme' ORDER BY embedding <=> :q LIMIT 20) s;
text
 mode          | count

Two rows instead of twenty, with no error. Iterative index scans (added in pgvector 0.8.0) keep scanning until the filter is satisfied or hnsw.max_scan_tuples is reached. For a few large tenants, a partial index per tenant is another option; for many tenants, pgvector suggests partitioning.

Hybrid search in SQL

Postgres has full-text search built in. Run a vector query and a keyword query with the same filters, then fuse them with reciprocal rank fusion (RRF): each result scores 1 / (60 + rank) in each list, and the scores are added. RRF needs no score normalization, which matters because cosine distance and ts_rank_cd aren’t on the same scale.

sql
-- $1 = query embedding, $2 = tenant, $3 = repos, $4 = keyword query (identifiers, error codes)
BEGIN;
SET LOCAL hnsw.ef_search = 100;
SET LOCAL hnsw.iterative_scan = strict_order;   -- pgvector 0.8.0+: keep scanning until filters are satisfied

WITH dense AS (
  SELECT id, row_number() OVER (ORDER BY embedding <=> $1) AS rank
  FROM code_chunks
  WHERE tenant_id = $2 AND repo = ANY($3)
  ORDER BY embedding <=> $1
  LIMIT 40
),
lexical AS (
  SELECT id, row_number() OVER (ORDER BY ts_rank_cd(content_tsv, q) DESC) AS rank
  FROM code_chunks, plainto_tsquery('simple', $4) AS q
  WHERE tenant_id = $2 AND repo = ANY($3) AND content_tsv @@ q
  ORDER BY ts_rank_cd(content_tsv, q) DESC
  LIMIT 40
)
SELECT c.path, c.symbol, c.commit_sha,
       coalesce(1.0 / (60 + d.rank), 0) + coalesce(1.0 / (60 + l.rank), 0) AS rrf_score
FROM dense d
FULL OUTER JOIN lexical l USING (id)
JOIN code_chunks c ON c.id = coalesce(d.id, l.id)
ORDER BY rrf_score DESC
LIMIT 12;
COMMIT;

In our test data, the chunk containing createIdempotencyKey came back in the top two only because the lexical branch matched it; the dense branch alone didn’t rank it. Use the simple text search configuration for code so identifiers aren’t stemmed.

Option B: Amazon OpenSearch Service

OpenSearch has keyword search (BM25) and k-NN in one engine, so hybrid retrieval is a query rather than a project. Three details matter. Use the Faiss or Lucene engine: OpenSearch’s docs list NMSLIB as deprecated. Put filters inside the knn clause so they’re applied during the search (“efficient filtering”) instead of after it. And use the hybrid query (OpenSearch 2.11+) with a search pipeline that combines scores; the score-ranker-processor with RRF is available from 2.19.

Create the index with filter fields, a text field and a Faiss HNSW vector field: PUT /code-chunks

json
{
  "settings": { "index": { "knn": true } },
  "mappings": {
    "properties": {
      "tenant_id":  { "type": "keyword" },
      "repo":       { "type": "keyword" },
      "path":       { "type": "keyword" },
      "commit_sha": { "type": "keyword" },
      "symbol":     { "type": "text", "fields": { "raw": { "type": "keyword" } } },
      "content":    { "type": "text" },
      "embedding": {
        "type": "knn_vector",
        "dimension": 1024,
        "space_type": "cosinesimil",
        "method": { "name": "hnsw", "engine": "faiss", "parameters": { "m": 16, "ef_construction": 128 } }
      }
    }
  }
}

Create a search pipeline that fuses the two branches with RRF: PUT /_search/pipeline/code-rrf

json
{
  "description": "Fuse lexical and vector results with reciprocal rank fusion",
  "phase_results_processors": [
    { "score-ranker-processor": { "combination": { "technique": "rrf" } } }
  ]
}

Query: a filtered keyword branch and a filtered k-NN branch, combined by the pipeline: GET /code-chunks/_search?search_pipeline=code-rrf

json
{
  "size": 12,
  "query": {
    "hybrid": {
      "queries": [
        {
          "bool": {
            "must": [ { "multi_match": { "query": "createIdempotencyKey", "fields": ["symbol^3", "content"] } } ],
            "filter": [ { "term": { "tenant_id": "acme" } }, { "terms": { "repo": ["payments-api"] } } ]
          }
        },
        {
          "knn": {
            "embedding": {
              "vector": [0.012, -0.034, 0.051],
              "k": 40,
              "filter": { "bool": { "filter": [ { "term": { "tenant_id": "acme" } }, { "terms": { "repo": ["payments-api"] } } ] } }
            }
          }
        }
      ]
    }
  }
}

The filters appear in both sub-queries on purpose: each branch must be restricted to the tenant on its own. On OpenSearch Service, check the version your domain runs before relying on these features. If vector storage cost is the issue, OpenSearch Service can also store a field’s vectors in S3 with the s3vector engine (OpenSearch 2.19+ on OpenSearch Optimized instances), keeping keyword search in the domain.

Option C: Amazon S3 Vectors

S3 Vectors is a bucket type for vectors: you create a vector bucket, create indexes in it, and put and query vectors through a dedicated API with no cluster to provision. AWS describes query latency as subsecond for infrequent queries and as low as 100 ms for more frequent ones. Corrections to what we wrote previously: writes are strongly consistent, so a vector is queryable as soon as PutVectors returns, and indexes in ENHANCED mode evaluate metadata filters before the vector search, so filtered queries keep their recall. Vector buckets created on or after September 30, 2026 create ENHANCED indexes; older CLASSIC indexes filter during the search and can return fewer than topK results, and can be moved to ENHANCED.

python
"""Store and query code chunks in Amazon S3 Vectors with tenant/repo pre-filtering."""
import boto3

s3v = boto3.client("s3vectors", region_name="us-east-1")
BUCKET, INDEX = "acme-code-rag", "chunks-v1"


def create_index():
    s3v.create_index(vectorBucketName=BUCKET, indexName=INDEX, dataType="float32",
                     dimension=1024, distanceMetric="cosine",
                     metadataConfiguration={"nonFilterableMetadataKeys": ["content"]})


def put_chunks(rows):
    """rows: [{'key', 'embedding', 'tenant', 'repo', 'path', 'commit', 'content'}]; max 500 per call."""
    for i in range(0, len(rows), 500):
        s3v.put_vectors(vectorBucketName=BUCKET, indexName=INDEX, vectors=[{
            "key": r["key"],
            "data": {"float32": r["embedding"]},
            "metadata": {"tenant": r["tenant"], "repo": r["repo"], "path": r["path"],
                         "commit": r["commit"], "content": r["content"]},
        } for r in rows[i:i + 500]])


def query(embedding, tenant, repos, k=20):
    resp = s3v.query_vectors(vectorBucketName=BUCKET, indexName=INDEX, topK=k,
                             queryVector={"float32": embedding},
                             filter={"$and": [{"tenant": {"$eq": tenant}}, {"repo": {"$in": repos}}]},
                             returnMetadata=True, returnDistance=True)
    return [(v["metadata"]["path"], v["distance"]) for v in resp["vectors"]]


def delete_file(keys):
    s3v.delete_vectors(vectorBucketName=BUCKET, indexName=INDEX, keys=keys)   # max 500 keys per call

Mind the limits: up to 500 vectors per PutVectors or DeleteVectors call, 40 KB of metadata per vector of which only 2 KB can be filterable, and up to 10 non-filterable keys per index. That’s why content is declared non-filterable above. S3 Vectors has no keyword search, so keep a small lexical or symbol index (OpenSearch, Postgres full-text, or a ctags-style table) for identifier questions and fuse the results. Bedrock Knowledge Bases can also use S3 Vectors as its vector store if you’d rather not manage ingestion yourself.

Freshness: deleted code must stop answering

On every merge, re-chunk changed files, upsert their chunks with the new commit SHA, and delete chunks of removed or renamed files. All three stores support deletes by ID or key. Track merge-to-searchable lag as a metric with a target (for an IDE assistant, a couple of minutes is a reasonable starting point), and have answers cite the commit SHA so stale results are visible.

Worked example: picking a store for a 40-repo monorepo assistant

  1. The team writes 80 golden questions with expected file paths, including 25 that name identifiers.
  2. They load the same chunks and embeddings into Aurora (existing cluster), a small OpenSearch domain and an S3 vector index, and run the harness below against each, with tenant filters on.
  3. Results are compared on recall@20 and p95 latency, plus a separate score for the identifier questions, where vector-only retrieval is weakest.
  4. They pick by the numbers and operating cost: for example Aurora with iterative scans and RRF if it’s close, OpenSearch if hybrid quality wins clearly, or S3 Vectors plus a lexical sidecar for a large, rarely queried archive.
python
"""Compare stores on the same golden questions: recall@k on expected files, plus latency."""
import statistics
import time


def recall_at_k(expected: set, hits: list, k: int) -> float:
    return len(expected & set(hits[:k])) / len(expected) if expected else 0.0


def evaluate(retrieve, golden: list, k: int = 20) -> dict:
    """retrieve(question) -> ordered list of paths; golden: [{'q': ..., 'paths': {...}}]"""
    recalls, latencies = [], []
    for item in golden:
        start = time.perf_counter()
        hits = retrieve(item["q"])
        latencies.append((time.perf_counter() - start) * 1000)
        recalls.append(recall_at_k(item["paths"], hits, k))
    latencies.sort()
    return {"recall@k": round(statistics.mean(recalls), 3),
            "p50_ms": round(latencies[len(latencies) // 2], 1),
            "p95_ms": round(latencies[int(len(latencies) * 0.95) - 1], 1)}


if __name__ == "__main__":
    golden = [{"q": "where is the idempotency key built?", "paths": {"payments/keys.py"}},
              {"q": "retry policy for the ledger client", "paths": {"ledger/client.py", "ledger/retry.py"}}]
    fake = lambda q: ["payments/keys.py", "ledger/client.py"]
    print(evaluate(fake, golden))

Troubleshooting

SymptomLikely causeFix
pgvector returns fewer rows than LIMITFilter applied after the HNSW scanSET hnsw.iterative_scan = strict_order; raise ef_search; partial indexes or partitioning
Identifier questions miss the defining fileVector-only retrievalAdd the lexical branch and fuse with RRF; boost the symbol field
Results from another tenantFilter only on one branch, or applied in application code after retrievalFilter inside every query branch; test with a cross-tenant canary
S3 Vectors filtered query returns fewer than topKCLASSIC indexMove the index to ENHANCED mode (see “Changing a vector index’s mode” in the S3 Vectors docs)
PutVectors returns 400Metadata over the filterable limit, or wrong dimensionMark large fields non-filterable; check the index dimension matches the model
Deleted functions still answerNo delete path on mergeDelete by key on file removal; alert on merge-to-search lag

Production checklist

  • ☐ Symbol-aware chunks with tenant, repo, path, symbol and commit
  • ☐ One embedding model and dimension per index; re-index on model change
  • ☐ Tenant filters inside every query branch, verified by a canary
  • ☐ Lexical + vector fusion for identifier questions
  • ☐ pgvector: iterative scans on; OpenSearch: Faiss or Lucene with efficient filtering; S3 Vectors: ENHANCED indexes
  • ☐ Upserts and deletes on every merge, with a lag metric
  • ☐ Store chosen by recall@k and p95 on your own golden set

FAQ

Which store is cheapest?

It depends on corpus size and query volume. S3 Vectors has no cluster to keep running, pgvector on an Aurora cluster you already pay for adds little, and an OpenSearch domain is a standing cost. Price your own numbers with the AWS pricing pages.

Do I need hybrid search for code?

For most code assistants, yes. Exact identifiers and error codes are common in questions, and vector search alone handles them poorly.

Can I switch stores later?

Yes, if you keep chunks and embeddings in a neutral format (for example JSONL in S3) and code against a small retrieval interface. Re-loading is cheap; re-embedding is the expensive part.

Should I use Bedrock Knowledge Bases instead?

If its chunking and metadata options fit your code, it removes the ingestion work and supports Aurora, OpenSearch and S3 Vectors as stores. The same filtering and hybrid questions still apply.

Official documentation

Related on CheatCoders: PII redaction before embeddings

Last updated on October 10, 2026

Watch: 100 Days of AI on YouTube

Short videos from CheatCoders, one AI engineering topic per day.

Open the playlist on YouTube · CheatCoders on YouTube

Deep-dive PDF

Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.

Already subscribed? or open the subscribe page.


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

3 Comments

  1. So I’ve been wondering what exactly an SEO consultant spends their day doing, and after working with one for a few months I think I finally get it. They basically dig through your website, figure out what’s broken or slow, check what competitors are ranking for, and then give you a plan to fix things step by step. It’s less magic and more spreadsheets than people assume.

    What surprised me most is how much of the job is just communication, explaining to clients why rankings don’t improve overnight and managing expectations. A lot of consultants also coordinate with whoever writes your content or builds your site, since SEO touches basically everything. Anyone here actually worked with one long term, was it worth the monthly cost?

    I talked to a few different consultants before settling on one, and the biggest thing I learned is that most of them just repeat generic advice you could find anywhere. The guy I eventually went with actually looked at my site specifics instead of giving me a template checklist. That alone made the fee worth it honestly.

    One thing I’d tell anyone thinking about hiring someone is to ask for examples of past work, not just promises of rankings. Rankings mean nothing if the traffic doesn’t convert into anything useful for your business. Has anyone else found that consultants focus too much on vanity metrics instead of actual results?
    what an SEO consultant does [url=https://yourhomeyourwayltd.co.uk/what-an-seo-consultant-actually-does-advice/#what-an-SEO-consultant-does]https://yourhomeyourwayltd.co.uk/what-an-seo-consultant-actually-does-advice/[/url] .

Leave a comment

No account needed. Name and email are optional.