“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 withtsvectorand 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.

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.
"""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:
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 targetThe 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:
\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; mode | countTwo 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.
-- $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
{
"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
{
"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
{
"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.
"""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 callMind 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
- The team writes 80 golden questions with expected file paths, including 25 that name identifiers.
- 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.
- Results are compared on recall@20 and p95 latency, plus a separate score for the identifier questions, where vector-only retrieval is weakest.
- 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.
"""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
| Symptom | Likely cause | Fix |
|---|---|---|
pgvector returns fewer rows than LIMIT | Filter applied after the HNSW scan | SET hnsw.iterative_scan = strict_order; raise ef_search; partial indexes or partitioning |
| Identifier questions miss the defining file | Vector-only retrieval | Add the lexical branch and fuse with RRF; boost the symbol field |
| Results from another tenant | Filter only on one branch, or applied in application code after retrieval | Filter inside every query branch; test with a cross-tenant canary |
S3 Vectors filtered query returns fewer than topK | CLASSIC index | Move the index to ENHANCED mode (see “Changing a vector index’s mode” in the S3 Vectors docs) |
PutVectors returns 400 | Metadata over the filterable limit, or wrong dimension | Mark large fields non-filterable; check the index dimension matches the model |
| Deleted functions still answer | No delete path on merge | Delete 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
- pgvector README (filtering, iterative index scans)
- Aurora PostgreSQL as a vector store
- OpenSearch Service k-NN search
- OpenSearch efficient k-NN filtering and hybrid search
- Amazon S3 Vectors, metadata filtering and limits
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.
Most viewed
- Python Decorators Explained: From Simple Wrappers to Production Patterns
- Claude Code vs Cursor vs GitHub Copilot: Choose by Workflow, and Set Up One Repo That Works With All Three
- AI Agent Frameworks in 2025: LangGraph vs CrewAI vs AutoGen vs Raw API
- Python String Methods: Every str Method With Real Production Examples
- REST API Design Best Practices: The Patterns That Make APIs a Joy to Use
Newly added
- AWS Systems Manager Parameter Store: Hierarchical Runtime Config for Coding Agents
- Amazon Cognito Identity Pools: Federate Coding-Agent Tool Callers Without Long-Lived Keys
- Amazon Athena for Coding Agents: Query Tool Audit Logs and Eval Traces on S3 Without a Warehouse
- AWS Batch for Coding Agents: Parallel Overnight PR Reviews and Long-Running Jobs Without Lambda Timeouts
- Amazon Bedrock Prompt Management: Version and A/B Test Coding-Agent System Prompts
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.

Pingback: Bedrock Retrieval Filters: Tenant Isolation for Multi-SaaS Code RAG - CheatCoders
Pingback: Fine-Tuning Titan: Internal SDK Autocomplete Without Public GitHub Noise - CheatCoders
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] .